From c1a8fc54c3d2bcc336f45bd36b1c109d68bc3077 Mon Sep 17 00:00:00 2001 From: WofWca Date: Sat, 1 Aug 2026 16:09:00 +0400 Subject: [PATCH 1/6] fix: `get_draft` possibly returning non-draft msg Due to a gap between `get_draft_msg_id()` and `Message::load_from_db`. Possibly can happen if the draft gets sent while `get_draft()` is in progress. Solved by doing both queries in a transaction. --- src/chat.rs | 35 +++++++++++++++++++++++++---------- src/message.rs | 41 +++++++++++++++++++++++++++++++++++------ 2 files changed, 60 insertions(+), 16 deletions(-) diff --git a/src/chat.rs b/src/chat.rs index 8a0ea1cc9f..6d5a0a3e00 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -13,6 +13,7 @@ use chrono::TimeZone; use deltachat_contact_tools::{ContactAddress, sanitize_bidi_characters, sanitize_single_line}; use humansize::{BINARY, format_size}; use mail_builder::mime::MimePart; +use rusqlite::OptionalExtension; use serde::{Deserialize, Serialize}; use strum_macros::EnumIter; @@ -738,13 +739,21 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=? /// Returns ID of the draft message, if there is one. async fn get_draft_msg_id(self, context: &Context) -> Result> { - let msg_id: Option = context + let query_only = true; + context .sql - .query_get_value( + // `call` instead of `transaction_ex` because it's a single query. + .call(query_only, |conn| self.get_draft_msg_id_trans(conn)) + .await + } + fn get_draft_msg_id_trans(self, conn: &rusqlite::Connection) -> Result> { + let msg_id: Option = conn + .query_row( "SELECT id FROM msgs WHERE chat_id=? AND state=?;", (self, MessageState::OutDraft), + |row| row.get(0), ) - .await?; + .optional()?; Ok(msg_id) } @@ -753,13 +762,19 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=? if self.is_special() { return Ok(None); } - match self.get_draft_msg_id(context).await? { - Some(draft_msg_id) => { - let msg = Message::load_from_db(context, draft_msg_id).await?; - Ok(Some(msg)) - } - None => Ok(None), - } + let query_only = true; + context + .sql + .transaction_ex(query_only, |transaction| { + match self.get_draft_msg_id_trans(transaction)? { + Some(draft_msg_id) => { + let msg = Message::load_from_db_trans(context, transaction, draft_msg_id)?; + Ok(Some(msg)) + } + None => Ok(None), + } + }) + .await } /// Deletes draft message, if there is one. diff --git a/src/message.rs b/src/message.rs index 550e0ad5e9..266434ee3c 100644 --- a/src/message.rs +++ b/src/message.rs @@ -10,6 +10,7 @@ use deltachat_derive::{FromSql, ToSql}; use humansize::BINARY; use humansize::format_size; use num_traits::FromPrimitive; +use rusqlite::OptionalExtension; use serde::{Deserialize, Serialize}; use tokio::{fs, io}; @@ -493,8 +494,22 @@ impl Message { /// /// Returns an error if the message does not exist. pub async fn load_from_db(context: &Context, id: MsgId) -> Result { - let message = Self::load_from_db_optional(context, id) - .await? + let query_only = true; + context + .sql + // `call` instead of `transaction_ex` because it's a single query. + .call(query_only, |conn| { + Self::load_from_db_trans(context, conn, id) + }) + .await + } + /// See [`Self::load_from_db`]. + pub(crate) fn load_from_db_trans( + context: &Context, + conn: &rusqlite::Connection, + id: MsgId, + ) -> Result { + let message = Self::load_from_db_optional_trans(context, conn, id)? .with_context(|| format!("Message {id} does not exist"))?; Ok(message) } @@ -503,13 +518,27 @@ impl Message { /// /// Returns `None` if the message does not exist. pub async fn load_from_db_optional(context: &Context, id: MsgId) -> Result> { + let query_only = true; + context + .sql + // `call` instead of `transaction_ex` because it's a single query. + .call(query_only, |conn| { + Self::load_from_db_optional_trans(context, conn, id) + }) + .await + } + /// See [`Self::load_from_db_optional`]. + pub(crate) fn load_from_db_optional_trans( + context: &Context, + conn: &rusqlite::Connection, + id: MsgId, + ) -> Result> { ensure!( !id.is_special(), "Can not load special message ID {id} from DB" ); - let mut msg = context - .sql - .query_row_optional( + let mut msg = conn + .query_row( "SELECT m.id AS id, rfc724_mid AS rfc724mid, @@ -603,7 +632,7 @@ impl Message { Ok(msg) }, ) - .await + .optional() .with_context(|| format!("failed to load message {id} from the database"))?; if let Some(msg) = &mut msg { From ff9623bc195b4a59bbb8c3984dda6b72ecc18710 Mon Sep 17 00:00:00 2001 From: WofWca Date: Sun, 2 Aug 2026 14:49:03 +0400 Subject: [PATCH 2/6] refactor: add `ChatId::get_draft_trans()` --- src/chat.rs | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/chat.rs b/src/chat.rs index 6c82a6dfd1..07a9713f16 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -771,23 +771,31 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=? /// Returns draft message, if there is one. pub async fn get_draft(self, context: &Context) -> Result> { - if self.is_special() { - return Ok(None); - } let query_only = true; context .sql .transaction_ex(query_only, |transaction| { - match self.get_draft_msg_id_trans(transaction)? { - Some(draft_msg_id) => { - let msg = Message::load_from_db_trans(context, transaction, draft_msg_id)?; - Ok(Some(msg)) - } - None => Ok(None), - } + self.get_draft_trans(context, transaction) }) .await } + /// See [`Self::get_draft`]. + pub fn get_draft_trans( + self, + context: &Context, + conn: &rusqlite::Connection, + ) -> Result> { + if self.is_special() { + return Ok(None); + } + match self.get_draft_msg_id_trans(conn)? { + Some(draft_msg_id) => { + let msg = Message::load_from_db_trans(context, conn, draft_msg_id)?; + Ok(Some(msg)) + } + None => Ok(None), + } + } /// Deletes draft message, if there is one. /// From 40796156d08faa2d969034e4af40c2a945ae68c1 Mon Sep 17 00:00:00 2001 From: WofWca Date: Tue, 4 Aug 2026 14:48:42 +0400 Subject: [PATCH 3/6] refactor: extract draft reuse from `send_msg_ex` We will need `send_msg_ex` in JSON-RPC API. --- src/chat.rs | 41 +++++++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/src/chat.rs b/src/chat.rs index 07a9713f16..0776e94f49 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -2659,6 +2659,14 @@ pub async fn is_contact_in_chat( Ok(exists) } +fn is_initialized_draft_msg_of_chat(msg: &Message, chat_id: ChatId) -> bool { + if msg.state == MessageState::OutDraft { + !msg.id.is_special() && msg.chat_id == chat_id + } else { + false + } +} + /// Sends a message object to a chat. /// /// Sends the event #DC_EVENT_MSGS_CHANGED on success. @@ -2666,6 +2674,17 @@ pub async fn is_contact_in_chat( /// sending may be delayed eg. due to network problems. However, from your /// view, you're done with the message. Sooner or later it will find its way. pub async fn send_msg(context: &Context, chat_id: ChatId, msg: &mut Message) -> Result { + let update_existing_draft = is_initialized_draft_msg_of_chat(msg, chat_id); + + send_msg_ex(context, chat_id, msg, update_existing_draft.into()).await +} +/// See [`send_msg`] and [`send_msg_sync`]. +pub async fn send_msg_ex( + context: &Context, + chat_id: ChatId, + msg: &mut Message, + update_existing_draft: UseExistingDraftPolicy, +) -> Result { ensure!( !chat_id.is_special(), "chat_id cannot be a special chat: {chat_id}" @@ -2682,7 +2701,10 @@ pub async fn send_msg(context: &Context, chat_id: ChatId, msg: &mut Message) -> msg.text = sanitize_bidi_characters(&msg.text); } - if !prepare_send_msg(context, chat_id, msg).await?.is_empty() { + if !prepare_send_msg(context, chat_id, msg, update_existing_draft) + .await? + .is_empty() + { if !msg.hidden { context.emit_msgs_changed(msg.chat_id, msg.id); } @@ -2702,7 +2724,9 @@ pub async fn send_msg(context: &Context, chat_id: ChatId, msg: &mut Message) -> /// Creates jobs in the `smtp` table, then drectly opens an SMTP connection and sends the /// message. If this fails, the jobs remain in the database for later sending. pub async fn send_msg_sync(context: &Context, chat_id: ChatId, msg: &mut Message) -> Result { - let rowids = prepare_send_msg(context, chat_id, msg).await?; + let update_existing_draft = is_initialized_draft_msg_of_chat(msg, chat_id); + + let rowids = prepare_send_msg(context, chat_id, msg, update_existing_draft.into()).await?; if rowids.is_empty() { return Ok(msg.id); } @@ -2723,6 +2747,7 @@ async fn prepare_send_msg( context: &Context, chat_id: ChatId, msg: &mut Message, + update_existing_draft: UseExistingDraftPolicy, ) -> Result> { let mut chat = Chat::load_from_db(context, chat_id).await?; @@ -2767,17 +2792,9 @@ async fn prepare_send_msg( ); } - // check current MessageState for drafts (to keep msg_id) ... - let update_existing_draft = if msg.state == MessageState::OutDraft { + if msg.state == MessageState::OutDraft { msg.hidden = false; - if !msg.id.is_special() && msg.chat_id == chat_id { - UseExistingDraftPolicy::Reuse - } else { - UseExistingDraftPolicy::DontReuse - } - } else { - UseExistingDraftPolicy::DontReuse - }; + } if msg.state == MessageState::Undefined // Legacy SecureJoin "v*-request" messages are unencrypted. From 1bbcacb89c8543760ab7ef00e8d632d05bd33db8 Mon Sep 17 00:00:00 2001 From: WofWca Date: Tue, 4 Aug 2026 15:08:27 +0400 Subject: [PATCH 4/6] feat: allow to prepare WebXDC in draft in JSON-RPC To be precise, when sending a message, reuse the existing draft message's ID. Closes https://github.com/chatmail/core/issues/8485. Closes https://github.com/chatmail/core/issues/4643. Supersedes https://github.com/chatmail/core/pull/6426. This feature already works in CFFI, however CFFI API requires a fully formed `Message` object, with all its fields set, and this is probably not something that public API should offer or require. I decided not to go for exposing the draft ID in the JSON-RPC API, and go for `bool reuse_existing_draft` instead. Exposing and requiring the ID would make the API more complex, yet I can't come up with an example of how this bool API could be problematic, at least in the case where there aren't multiple concurrent users of the same account's database. Perhaps the ID system would make sense if it was possible to have multiple drafts per chat, and treat those like "almost sent messages" that you can edit before finally sending (or deleting) them, but it's not what we have now. This adds the check for whether the .xdc file gets replaced or removed and creates a new draft message if so, which makes sure that old WebXDC status updates don't apply to the new app, as was suggested in https://github.com/chatmail/core/pull/6426#issuecomment-2585657331. So the API user doesn't have to be careful not to ask Core to reuse the draft when that would be incorrect. --- deltachat-jsonrpc/src/api.rs | 14 +++- deltachat-jsonrpc/src/api/types/message.rs | 21 ++++++ src/chat.rs | 85 ++++++++++++++++++---- 3 files changed, 103 insertions(+), 17 deletions(-) diff --git a/deltachat-jsonrpc/src/api.rs b/deltachat-jsonrpc/src/api.rs index 84c2c994aa..7f72e487aa 100644 --- a/deltachat-jsonrpc/src/api.rs +++ b/deltachat-jsonrpc/src/api.rs @@ -2457,14 +2457,20 @@ impl CommandApi { async fn send_msg(&self, account_id: u32, chat_id: u32, data: MessageData) -> Result { let ctx = self.get_context(account_id).await?; + let reuse_existing_draft = data.reuse_existing_draft; let mut message = data .create_message(&ctx) .await .context("Failed to create message")?; - let msg_id = chat::send_msg(&ctx, ChatId::new(chat_id), &mut message) - .await - .context("Failed to send created message")? - .to_u32(); + let msg_id = chat::send_msg_ex( + &ctx, + ChatId::new(chat_id), + &mut message, + reuse_existing_draft.into(), + ) + .await + .context("Failed to send created message")? + .to_u32(); Ok(msg_id) } diff --git a/deltachat-jsonrpc/src/api/types/message.rs b/deltachat-jsonrpc/src/api/types/message.rs index 40640880a3..3ddb4f2b58 100644 --- a/deltachat-jsonrpc/src/api/types/message.rs +++ b/deltachat-jsonrpc/src/api/types/message.rs @@ -616,6 +616,27 @@ pub struct MessageData { /// Quoted message id. Takes preference over `quoted_text` (see below). pub quoted_message_id: Option, pub quoted_text: Option, + /// Useful for WebXDC app attachments, which can also be opened + /// for draft messages. + /// Setting this to `true` will ensure that the WebXDC status updates + /// of the current draft are preserved when sending the message + /// or updating the draft. + /// + /// `false` by default, for backwards compatibility. + /// However, you probably want to set it to `true` + /// when sending or updating the draft + /// from the main message composer section, + /// and to `false` when sending a message from secondary places, + /// such as a notification "Reply" input. + /// + /// Reusing the draft will also automatically remove the draft + /// when it's sent. + /// + /// Note that sometimes the draft cannot be reused, + /// for example when the WebXDC attachment [`Self::file`] changes. + /// If the draft cannot be reused, it will not get auto-removed. + #[serde(default)] + pub reuse_existing_draft: bool, } impl MessageData { diff --git a/src/chat.rs b/src/chat.rs index 0776e94f49..d0df49281b 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -769,6 +769,30 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=? Ok(count > 0) } + fn can_reuse_draft(context: &Context, old: &Message, new: &Message) -> Result { + ensure!( + old.chat_id.is_unset() || new.chat_id.is_unset() || new.chat_id == old.chat_id, + "messages belong to different chats" + ); + ensure!( + old.get_state() == MessageState::Undefined || old.get_state() == MessageState::OutDraft, + "old message is a real message, not a draft" + ); + + // Reusing the draft is only useful for WebXDC messages, + // but for consistency let's reuse it whenever possible. + + if old.get_viewtype() == Viewtype::Webxdc + && (new.get_viewtype() != Viewtype::Webxdc + || old.get_file(context) != new.get_file(context)) + { + // Old draft's WebXDC attachment got removed or replaced. + return Ok(false); + } + + Ok(true) + } + /// Returns draft message, if there is one. pub async fn get_draft(self, context: &Context) -> Result> { let query_only = true; @@ -818,6 +842,9 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=? /// thus preserving the ID and possible WebXDC status updates /// associated with the draft message. /// + /// If `msg.id` is specified, the [`Self::can_reuse_draft`] check + /// will be skipped. + /// /// Returns `false` if the existing draft is already at the state /// that the caller tried to set it to, so it was unchanged. async fn do_set_draft(self, context: &Context, msg: &mut Message) -> Result { @@ -856,7 +883,20 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=? let trans_fn = |transaction: &mut rusqlite::Transaction| { // if possible, replace existing draft and keep id - if !msg.id.is_special() && self.has_draft_with_id(transaction, &msg.id)? { + let reuse_existing_id: Option = if !msg.id.is_special() { + if self.has_draft_with_id(transaction, &msg.id)? { + Some(msg.id) + } else { + None + } + } else if let Some(existing) = self.get_draft_trans(context, transaction)? + && Self::can_reuse_draft(context, &existing, msg)? + { + Some(existing.id) + } else { + None + }; + if let Some(reuse_existing_id) = reuse_existing_id { let affected_rows = transaction.execute( "UPDATE msgs SET timestamp=?1,type=?2,txt=?3,txt_normalized=?4,param=?5,mime_in_reply_to=?6 @@ -873,11 +913,11 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=? normalize_text(&msg.text), msg.param.to_string(), msg.in_reply_to.as_deref().unwrap_or_default(), - msg.id, + reuse_existing_id, ), )?; let changed = affected_rows > 0; - return Ok((msg.id, changed)); + return Ok((reuse_existing_id, changed)); } // Delete existing draft if it exists. @@ -1789,8 +1829,12 @@ impl Chat { /// writes the record to the database. /// /// If `update_existing_draft == `[`UseExistingDraftPolicy::Reuse`], - /// the existing draft with ID == msg.id is reused. - /// If no such draft exists, an error is returned. + /// we will reuse the draft by the specified `msg.id`, + /// or if it can be reused according to [`ChatId::can_reuse_draft`]. + /// If `msg.id` is specified, the [`ChatId::can_reuse_draft`] check + /// will be skipped. + /// If ID was specified but no such draft exists, an error is returned. + /// /// If `update_existing_draft == `[`UseExistingDraftPolicy::DontReuse`], /// a new record is created. async fn prepare_msg_raw( @@ -1967,11 +2011,22 @@ impl Chat { // add message to the database let trans_fn = |transaction: &mut rusqlite::Transaction| { - if update_existing_draft == UseExistingDraftPolicy::Reuse { - // This check also covers the `msg.id.is_special()` case. + let try_reuse: bool = update_existing_draft == UseExistingDraftPolicy::Reuse; + let reuse_existing_id: Option = if !try_reuse { + None + } else if !msg.id.is_special() { + Some(msg.id) + } else if let Some(existing) = self.id.get_draft_trans(context, transaction)? + && ChatId::can_reuse_draft(context, &existing, msg)? + { + Some(existing.id) + } else { + None + }; + if let Some(reuse_existing_id) = reuse_existing_id { // Maybe we could try to somehow gracefully recover from this, // but better safe than sorry. - if !self.id.has_draft_with_id(transaction, &msg.id)? { + if !self.id.has_draft_with_id(transaction, &reuse_existing_id)? { bail!( concat!( "wanted to prepare existing draft for sending in chat {0}, ", @@ -1979,7 +2034,7 @@ impl Chat { "(it might have been sent or deleted)" ), self.id, - msg.id + reuse_existing_id ); } @@ -2011,11 +2066,11 @@ impl Chat { location_id as i32, ephemeral_timer, ephemeral_timestamp, - msg.id + reuse_existing_id ], )?; let inserted = false; - Ok((msg.id, inserted)) + Ok((reuse_existing_id, inserted)) } else { transaction.execute( "INSERT INTO msgs ( @@ -2678,7 +2733,11 @@ pub async fn send_msg(context: &Context, chat_id: ChatId, msg: &mut Message) -> send_msg_ex(context, chat_id, msg, update_existing_draft.into()).await } -/// See [`send_msg`] and [`send_msg_sync`]. +/// Unlike [`send_msg`] (and [`send_msg_sync`]), +/// this function allows for reusing the draft even if `msg` +/// is not a fully initialized [`Message`], +/// i.e. if [`Message::get_id`], [`Message::get_viewtype`], +/// [`Message::rfc724_mid`] etc. are unset. pub async fn send_msg_ex( context: &Context, chat_id: ChatId, @@ -2830,7 +2889,7 @@ async fn prepare_send_msg( /// /// This is basically a more explicit bool. #[derive(Debug, PartialEq)] -enum UseExistingDraftPolicy { +pub enum UseExistingDraftPolicy { /// Create a brand new draft or message, don't reuse the existing one's ID. DontReuse, /// Resuse the existing draft, so that the new draft or message From 903a3a98223cd8bca1b59b8a1a7c4132727e2170 Mon Sep 17 00:00:00 2001 From: WofWca Date: Mon, 3 Aug 2026 18:50:10 +0400 Subject: [PATCH 5/6] refactor: shuffle syntax in `prepare_msg_raw` As preparation for us getting the `rfc724_mid` from the database inside the transaction. --- src/chat.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/chat.rs b/src/chat.rs index d0df49281b..3b4133a6a6 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -1883,11 +1883,13 @@ impl Chat { // Set "In-Reply-To:" to identify the message to which the composed message is a reply. // Set "References:" to identify the "thread" of the conversation. // Both according to [RFC 5322 3.6.4, page 25](https://www.rfc-editor.org/rfc/rfc5322#section-3.6.4). - let new_references; + // + // When `None`, we'll use the current message's `rfc724_mid` as the reference. + let new_references_opt: Option; if self.is_self_talk() { // As self-talks are mainly used to transfer data between devices, // we do not set In-Reply-To/References in this case. - new_references = String::new(); + new_references_opt = Some(String::new()); } else if let Some((parent_rfc724_mid, parent_in_reply_to, parent_references)) = // We don't filter `OutPending` and `OutFailed` messages because the new message for // which `parent_query()` is done may assume that it will be received in a context @@ -1935,9 +1937,9 @@ impl Chat { if references_vec.is_empty() { // As a fallback, use our Message-ID, // same as in the case of top-level message. - new_references = msg.rfc724_mid.clone(); + new_references_opt = None; } else { - new_references = references_vec.join(" "); + new_references_opt = Some(references_vec.join(" ")); } } else { // This is a top-level message. @@ -1945,8 +1947,9 @@ impl Chat { // This allows us to identify replies to our message even if // email server such as Outlook changes `Message-ID:` header. // MUAs usually keep the first Message-ID in `References:` header unchanged. - new_references = msg.rfc724_mid.clone(); + new_references_opt = None; } + let new_references = new_references_opt.as_ref().unwrap_or(&msg.rfc724_mid); // add independent location to database if msg.param.exists(Param::SetLatitude) From b7ebd82efa1c103957b1cae026a61346317d0300 Mon Sep 17 00:00:00 2001 From: WofWca Date: Mon, 3 Aug 2026 23:47:28 +0400 Subject: [PATCH 6/6] fix: JSON-RPC: keep `selfAddr` when sending draft ...by keeping `message.rfc724_mid`. We had the same issue with CFFI before: https://github.com/chatmail/core/issues/6621, fixed in 846c8e7f1b7c07d5ec0817a1a82bc55bea9a72e4 and 21d13e8a9c0fda73be70a242128998a0d1c0d42d. This commit also makes `send_msg` return an error when an `OutDraft` message with a non-empty ID and an empty `rfc724_mid` is passed to it, which previously would cause `send_msg` to generate and set a new `rfc724_mid` on the message. --- src/chat.rs | 59 ++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/src/chat.rs b/src/chat.rs index 3b4133a6a6..a050fc1ac5 100644 --- a/src/chat.rs +++ b/src/chat.rs @@ -1846,10 +1846,6 @@ impl Chat { let mut to_id = 0; let mut location_id = 0; - if msg.rfc724_mid.is_empty() { - msg.rfc724_mid = create_outgoing_rfc724_mid(); - } - if self.typ == Chattype::Single { if let Some(id) = context .sql @@ -1949,7 +1945,12 @@ impl Chat { // MUAs usually keep the first Message-ID in `References:` header unchanged. new_references_opt = None; } - let new_references = new_references_opt.as_ref().unwrap_or(&msg.rfc724_mid); + fn get_new_references<'a>( + new_references_opt: &'a Option, + rfc724_mid: &'a str, + ) -> &'a str { + new_references_opt.as_deref().unwrap_or(rfc724_mid) + } // add independent location to database if msg.param.exists(Param::SetLatitude) @@ -2015,18 +2016,37 @@ impl Chat { // add message to the database let trans_fn = |transaction: &mut rusqlite::Transaction| { let try_reuse: bool = update_existing_draft == UseExistingDraftPolicy::Reuse; - let reuse_existing_id: Option = if !try_reuse { + let reuse_existing: Option<(MsgId, String)> = if !try_reuse { None } else if !msg.id.is_special() { - Some(msg.id) + ensure!( + !msg.rfc724_mid.is_empty(), + concat!( + "cannot reuse existing draft: ", + "when `message.id` is set, `message.rfc724_mid` must also be set ", + "(as well as all other necessary properties); " + ) + ); + + Some((msg.id, msg.rfc724_mid.to_owned())) } else if let Some(existing) = self.id.get_draft_trans(context, transaction)? && ChatId::can_reuse_draft(context, &existing, msg)? { - Some(existing.id) + ensure!( + !existing.rfc724_mid.is_empty(), + concat!( + "cannot reuse existing draft: ", + "expected its `rfc724_mid` to be already set in the DB, but it's empty" + ) + ); + + Some((existing.id, existing.rfc724_mid.to_owned())) } else { None }; - if let Some(reuse_existing_id) = reuse_existing_id { + if let Some((reuse_existing_id, rfc724_mid)) = reuse_existing { + ensure!(!rfc724_mid.is_empty()); + // Maybe we could try to somehow gracefully recover from this, // but better safe than sorry. if !self.id.has_draft_with_id(transaction, &reuse_existing_id)? { @@ -2050,7 +2070,7 @@ impl Chat { ephemeral_timestamp=? WHERE id=?;", params_slice![ - msg.rfc724_mid, + rfc724_mid, msg.chat_id, msg.from_id, to_id, @@ -2063,7 +2083,7 @@ impl Chat { msg.param.to_string(), msg.hidden, msg.in_reply_to.as_deref().unwrap_or_default(), - new_references, + get_new_references(&new_references_opt, &rfc724_mid), new_mime_headers.is_some(), new_mime_headers.unwrap_or_default(), location_id as i32, @@ -2073,8 +2093,14 @@ impl Chat { ], )?; let inserted = false; - Ok((reuse_existing_id, inserted)) + Ok((reuse_existing_id, rfc724_mid, inserted)) } else { + let rfc724_mid = if !msg.rfc724_mid.is_empty() { + &msg.rfc724_mid + } else { + &create_outgoing_rfc724_mid() + }; + transaction.execute( "INSERT INTO msgs ( rfc724_mid, @@ -2099,7 +2125,7 @@ impl Chat { ephemeral_timestamp) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?,?);", params_slice![ - msg.rfc724_mid, + rfc724_mid, msg.chat_id, msg.from_id, to_id, @@ -2112,7 +2138,7 @@ impl Chat { msg.param.to_string(), msg.hidden, msg.in_reply_to.as_deref().unwrap_or_default(), - new_references, + get_new_references(&new_references_opt, rfc724_mid), new_mime_headers.is_some(), new_mime_headers.unwrap_or_default(), location_id as i32, @@ -2122,12 +2148,13 @@ impl Chat { )?; let msg_id = MsgId::new(transaction.last_insert_rowid().try_into()?); let inserted = true; - Ok((msg_id, inserted)) + Ok((msg_id, rfc724_mid.to_string(), inserted)) } }; - let (msg_id, inserted) = context.sql.transaction(trans_fn).await?; + let (msg_id, rfc724_mid, inserted) = context.sql.transaction(trans_fn).await?; msg.id = msg_id; + msg.rfc724_mid = rfc724_mid; if inserted { context.new_msgs_notify.notify_one();