[Tracking Issue] Pre-Messages
Pre-messages
Motivation
- Make background-fetch work reliably. On iOS, we somehow even have crashes because of timeout or memory exhaustion.
- This will also make calls arrive more reliably.
- Also, this will make download-on-demand more user-friendly. Currently, pre-messages are often assigned to the wrong chat (1:1 email chat with the sender, rather than group chat or 1:1 encrypted chat). Also, we'll be able to directly show more of the message, e.g. the text.
- previous discussions, mainly focused on chat assignment https://github.com/chatmail/core/issues/5888
- On the chatmail server, we'll be able to delete large messages more eagerly, because the recipient will at least have an indication that there was a missed message.
Implementation
- No UI changes should be needed in the beginning; from the UI side, everything should stay the same except that the pre-message is directly assigned to the correct chat. (and possibly that the text is already there before clicking "Download")
- Ideally, it will be backwards-compatible, i.e. messages sent with the new version of DC look OKish on old versions. It's probably OKish if message content is shown twice.
- In the beginning, we'll only care about big attachments, and ignore the case of very long text messages that exceed 160KB.
- Note that there may be existing message-stubs that are not downloaded yet. After updating, the "Download" button will probably become non-functional; this is fine. We should make sure that DC doesn't crash when clicking "Download".
- After this change all large messages without the new
Chat-Is-Full-Messageheader are downloaded regardless of download setting, especially classical emails. But this only happens when the user has the app in foreground.- so the download size setting would only apply to messages sent by the new version of delta chat.
Steps
-
[x] Remove "Download maximum available until", because it's unreliable - chatmail servers may delete full-messages quicker. https://github.com/chatmail/core/pull/7369
-
[x] Remove partial-download #7373
-
[x] Sending messages. https://github.com/chatmail/core/pull/7410
- All encrypted messages with an attachment get a pre-message. We can exclude attachments smaller than e.g. 10KB or 50KB or 100KB.
- The pre-message needs to reference the full-message in an encrypted header, so that the full-message can be downloaded when the user requests it. The header can be called
Chat-Full-Message-ID. - The full-message gets a cleartext header
Chat-Is-Full-Message - The best place to check whether the message should be split up into two is probably
create_send_msg_jobs(). - The message's state must be set to MessageState::OutDelivered only after both messages are sent. If a read receipt is received, the message can be OutMdnRcvd or OutPending; let's just do whatever is easiest for now. Take care not to revert from OutMdnReceived to OutDelivered if we first receive a read receipt and then deliver the full message.
- (Autocrypt-gossip and selfavatar should never go into full-messages, because that would just make it unnecessarily large - Edit: Note that this is actually an optimization, would not be necessary in the first iteration. But it's already implemented, so, if it's not a source of troubles, can stay as-is)
-
[ ] Receiving messages. https://github.com/chatmail/core/pull/7431
- In receive_imf, the full-message can be handled similarly to
Chat-Edit(grep for "ChatEdit" in receive_imf.rs). - As opposed to the current download-on-demand, we won't replace the whole msgs row, because this way, it will just work if an edit-message is received, and then later the user requests to download the attachment.
- Need to change Viewtype, probably metadata params like image size
- pre-message gets Viewtype::Text in the database until downloaded
- A read receipt should be shown already when the user saw the pre-message.
- In receive_imf, the full-message can be handled similarly to
-
[x] check if we re-used
partial_download_msg_bodystock string, if not, then remove it -> removed in #7373, see https://github.com/chatmail/core/pull/7373#discussion_r2515702616 -
[ ] update spec files where appropriate
-
[ ] ask Asiel whether this will make problems with bots (because the pre-message already creates an event, and always has text mimetype)
Future possibilities (out of scope right now)
- Nicer pre-message with an image thumbnail etc.
- Care about very long text messages that exceed 160kb:
- We could just deny sending this large messages
- Or we could introduce some extra complexity to make it just work.
- We could emit an event when we ignore messages >1MB in background_fetch (both classical emails and very large text messages)
- Show pre-messages in the UI as having the correct file type, showing that this file isn't downloaded yet
- If the full-message only contains an attachment, include a blake3 hash of the attachment into the pre-message. Then, if we already have this file, we don't need to download it.
- Request full-messages from other devices via iroh (either your second device or the sender's device)
Download logic pseudo-code
Logic with Chat-Is-Full-Message header (or Chat-Has-Premessage or Chat-Can-Be-Safely-Ignored-For-Now),
unconditionally downloading classic email & messages from old clients:
Click to show logic before #7588
// with this logic, classic email will be downloaded unconditionally, ignoring config.download_limit
// simplification is not only in this code, but mainly in the removed partial_download logic
download_when_normal_starts = [] // might be sql table "download"
available_full_msgs = [] // new sql table - all full-messages we've seen on the server but not downloaded yet. Remember to remove messages from here when deleted
background_fetch()
{
while (msg = get_message_prefetch_header())
{
if !msg.fullMessage {
if msg.size < 1MB {
download_message() // may be a pre-message or a pure-text message
} else {
// This is e.g. a classical email
// Queue for full download, in order to prevent missing messages
download_when_normal_starts.push(msg.rfc724_mid)
}
} else {
// This is a full-message
if msg.size < config.download_limit {
download_when_normal_starts.push(msg.rfc724_mid)
}
available_full_msgs.push(msg.rfc724_mid)
}
}
// optionally, maybe later, download larger messages if time
while (msg = download_when_normal_starts.pop()) {
// ... (like in normal_fetch())
}
// optionally, in order to guard against lost pre-messages:
while (msg = available_full_msgs){
// ... (like in normal_fetch())
}
}
normal_fetch()
{
while (msg = get_message_prefetch_header())
{
if !msg.fullMessage {
download_message()
} else if msg.size < config.download_limit {
download_when_normal_starts.push(msg.rfc724_mid) // this may be a full-message replacing a pre-message
available_full_msgs.push(msg.rfc724_mid)
} else {
available_full_msgs.push(msg.rfc724_mid)
}
while (msg = download_when_normal_starts) {
let res = download_message()
if res.is_ok() {
download_when_normal_starts.remove(msg)
available_full_msgs.remove(msg)
}
if res.is_err() {
if !premessage_is_downloaded_for(msg.rfc724_mid) {
warn!() // This is probably a classical email that vanished before we could download it
download_when_normal_starts.remove(msg)
} else if available_full_msgs.contains(msg.rfc724_mid) {
// set the message to DC_DOWNLOAD_FAILURE - probably it was deleted on the server in the meantime
download_when_normal_starts.remove(msg)
available_full_msgs.remove(msg)
} else {
// leave the message in DC_DOWNLOAD_IN_PROGRESS;
// it will be downloaded once it arrives.
}
}
}
// optionally, in order to guard against lost pre-messages:
while (msg = available_full_msgs) {
if !premessage_is_downloaded_for(msg.rfc724_mid) {
// Download the full-message unconditionally,
// because the pre-message got lost.
// The message may be in the wrong order,
// but at least we have it at all.
res = download_msg()
if res.is_ok() {available_full_msgs.remove(msg)}
}
}
}
fetch()
{
while (msg = get_message_prefetch_header())
{
if !msg.postMessage {
download_message()
} else {
// This is a post-message
if msg.size < config.download_limit {
download.push(msg.rfc724_mid)
}
available_post_msgs.push(msg.rfc724_mid)
}
}
while (msg = download) {
let res = download_message()
if res.is_ok() {
download.remove(msg)
available_post_msgs.remove(msg)
}
if res.is_err() {
if !premessage_is_downloaded_for(msg.rfc724_mid) {
warn!() // This is probably a classical email that vanished before we could download it
download.remove(msg)
} else if available_post_msgs.contains(msg.rfc724_mid) {
// set the message to DC_DOWNLOAD_FAILURE - probably it was deleted on the server in the meantime
download.remove(msg)
available_post_msgs.remove(msg)
} else {
// leave the message in DC_DOWNLOAD_IN_PROGRESS;
// it will be downloaded once it arrives.
}
}
}
// optionally, in order to guard against lost pre-messages:
while (msg = available_post_msgs) {
if !premessage_is_downloaded_for(msg.rfc724_mid) {
// Download the post-message unconditionally,
// because the pre-message got lost.
// The message may be in the wrong order,
// but at least we have it at all.
res = download_msg()
if res.is_ok() {available_post_msgs.remove(msg)}
}
}
}
Partial download was also used to show a stub message when parsing an IMAP message failed, so user could press "download message" to try it again after updating delta chat.
https://github.com/chatmail/core/blob/8b4c718b6b1076678dd77a7eb0426cff25077e91/src/imap.rs#L1479-L1491
One case where this can happen is when old versions of delta chat interact with future non released versions. The current version understands both message formats. Removing partial downloads removes this fallback, but it's probably not an issue because the old version still has it.
Partial download was also used to show a stub message when parsing an IMAP message failed, so user could press "download message" to try it again after updating delta chat.
This logic was added not because of some particular version incompatibility, but as a general solution if smth goes wrong e.g. an older version can't receive "new" messages (as already mentioned), or there's some bug preventing full processing of a received message, or a misconfiguration. Then the user can resolve the issue and download the failed message. If we remove this logic, we should make sure that:
- The IMAP loop doesn't stuck if
receive_imf_inner()can't process the message (we had such a blocker bug before). - We don't miss failed messages silently, this will cause communication issues and may lead to bugs never fixed because of no trace of missed messages. Probably we should add a system message (a translation isn't necessary) to the right chat so that the user can report the bug and also everything is visible on screenshots.
The message's state must be set to MessageState::OutDelivered only after both messages are sent.
this is already the case because the check for setting the state to delivered is basically to check that no rows with that message id are left in the smtp table.
If a read receipt is received, the message can be OutMdnRcvd or OutPending; let's just do whatever is easiest for now. Take care not to revert from OutMdnReceived to OutDelivered if we first receive a read receipt and then deliver the full message.
We may need some new internal state or field to represent the case that OutMdnRcvd while the message is still pending. it would be confusing UX if the UI showed double checkmark while the message is still uploading. (user may leave the app and interrupt/cancel the upload thinking the process was completed) Like keep the state pending, but as soon as it is done set it to OutMdnRcvd, if an Mdn was received in the meantime?
Note that OutMdnRcvd is a "virtual" message state, it's not used in the db for new messages (instead, we just look whether a message has MDNs). I'd also add a db migration replacing OutMdnRcvd with OutDelivered, it will not affect how messages are displayed, and having fewer message states in the db simplifies writing SQL code.
For the beginning, let's just do what's easiest, and then care about such details later. A migration replacing OutMdnRcvd with OutDelivered sounds good to me (note though that I don't know that part of the code very well - ping @link2xt do you also think it makes sense?)
If this makes other things easier, then this could even be a small PR right now, otherwise, we can ignore the problem for now and do the migration later.
while (msg = get_message_prefetch_header()) {
if msg.fullMessage {
// This is a full-message
available_full_msgs.push(msg.rfc724_mid)
if msg.size < config.download_limit {
// if background_fetch {
download_when_normal_starts.push(msg.rfc724_mid)
// } else {
// download_message()
// QUESTION: is this branch really needed or can we just always add to `download_when_normal_starts`?
// message order is still correct because pre-message was (probably) received before
// also not directly downloading may speed up receiving smaller messages?
// }
}
} else {
// this is not a full message
// if background_fetch {
// if msg.size < 1MB {
// download_message(msg) // may be a pre-message or a pure-text message
// } else {
// // This is e.g. a classical email
// // Queue for full download, in order to prevent missing messages
// download_when_normal_starts.push(msg.rfc724_mid)
// }
// } else {
download_message(msg)
// }
// (Edit from Hocuri: Simplified the logic a bit more here after talking with link2xt)
}
}
Is this pseudo code correct for unifying the logic of the first step of the normal and background fetch? Can we get rid of the first "if background_fetch" branch?
Right, I think it makes sense to get rid of this branch. I also edited my post above.
A question: for large classical emails (encrypted as well), can't we artificially generate a pre-message and pass it to receive_imf (or download_message() as per the code above) so that when the user downloads it, the same things happen as for Delta Chat messages? Maybe this will even simplify some logic, but if not, may be considered as a future improvement to save traffic.
No, we can't. The sending side can't because it's a classical MUA. The chatmail relay can't because it can't decrypt the message. The receiving side can't because it would need to download the full message for this, and at this point you can just normally receive the message.
The receiving side can't because it would need to download the full message for this, and at this point you can just normally receive the message.
I meant, just make some standard pre-message and assign it to the 1:1 chat with the sender as currently (if it's encrypted). And if it's unencrypted, it can be assigned to the target chat right away.
background_fetch() ... // optionally, in order to guard against lost pre-messages: while (msg = available_full_msgs){ // ... (like in normal_fetch()) }
I think this should be removed, we just need to download messages having Chat-Is-Full-Message which are < 1M and not referenced by any pre-message (in an additional batch, after "normal" batches have been downloaded). If the pre-message is lost, there should be no difference in handling usual big messages and "full messages", both are equally important.
How should we handle forwarding for pre-messages? maybe we need to discuss it later, because it the receiver will not be able to download the message.
How should we handle forwarding for pre-messages? maybe we need to discuss it later, because it the receiver will not be able to download the message.
Forwarding should not fail, but it is fine to do something minimal like forwarding the text, with or without [Viewtype xxx kb] text. If user selects a lot of messages and some of them are not fully downloaded, forwarding should still work and not fail with an error that makes the user find all the non-forwardable message, but does not need to be nice. We already allow to forward info messages and everything else that is selectable for similar reasons.