Compare commits

...
Author SHA1 Message Date
WofWca b7ebd82efa 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 846c8e7f1b
and 21d13e8a9c.

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.
2026-08-04 19:10:37 +04:00
WofWca 903a3a9822 refactor: shuffle syntax in prepare_msg_raw
As preparation for us getting the `rfc724_mid` from the database
inside the transaction.
2026-08-04 19:10:37 +04:00
WofWca 1bbcacb89c 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.
2026-08-04 19:10:35 +04:00
WofWca 40796156d0 refactor: extract draft reuse from send_msg_ex
We will need `send_msg_ex` in JSON-RPC API.
2026-08-04 19:04:07 +04:00
WofWca ff9623bc19 refactor: add ChatId::get_draft_trans() 2026-08-04 13:34:45 +04:00
WofWca 0806cac0cb Merge branch 'wofwca/fix-get-draft-race' into wofwca/keep-draft-id-json-rpc-2 2026-08-04 13:33:58 +04:00
WofWca 56e66047bf refactor: de-indent transaction fns in chat.rs 2026-08-04 13:29:35 +04:00
WofWca ea01dd2a98 fix: set_draft mutating real messages (race)
The issue has been introduced in
cf33db3dcb
(https://github.com/chatmail/core/pull/2887).

This, again, has to do with a race where the draft message
is sent in another Future after `get_draft` but before `sql.execute`.

Related:
- 07fa9c35ee
  (https://github.com/chatmail/core/pull/6052).
- df4fd82140
  (https://github.com/chatmail/core/pull/6061).
2026-08-04 13:29:35 +04:00
WofWca a85ce53680 fix: don't send an already sent draft
Calling `send_msg()` with a draft message that was already sent
(by specifying `msg.id`)
would mutate that message in the DB and try to send it again.

Additionally, `prepare_msg_raw` now errors out
if the draft is not present in the database.
Previosuly the `UPDATE` query would simply update 0 rows
and we would proceed with trying to send a message
without having it in the `msgs` table.

The bug has been introduced in cf33db3dcb
(https://github.com/chatmail/core/pull/2887).

Semantically this makes `prepare_msg_raw` API less generic,
narrowing down its `update_msg_id` function only to drafts.
The "update draft" is anyway the only use case so far
for this parameter.
Thus this also removes the ability to specify an ID
that is different from `msg.id`, as was suggested in
https://github.com/chatmail/core/pull/2887#discussion_r767256419.
These IDs were always the same anyway.

Maybe it would make sense to, instead of returning an error
if the draft is already sent or does not exist,
simply upsert a new message without looking at `msg.id`,
as we would do with non-draft `msg.state`s,
but I wasn't sure how CFFI users (DC Android and DC iOS)
would take that.
So for now let's simply return an error instead of messing up the DB.
2026-08-04 13:29:22 +04:00
WofWca c1a8fc54c3 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.
2026-08-02 23:31:54 +04:00
WofWca 6bd6fa9b5f test: add test_dont_send_sent_draft
Currently failing due to a bug.
2026-08-02 23:31:39 +04:00
5 changed files with 421 additions and 140 deletions
+10 -4
View File
@@ -2457,14 +2457,20 @@ impl CommandApi {
async fn send_msg(&self, account_id: u32, chat_id: u32, data: MessageData) -> Result<u32> {
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)
}
@@ -616,6 +616,27 @@ pub struct MessageData {
/// Quoted message id. Takes preference over `quoted_text` (see below).
pub quoted_message_id: Option<u32>,
pub quoted_text: Option<String>,
/// 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 {
+320 -130
View File
@@ -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,24 +739,82 @@ 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<Option<MsgId>> {
let msg_id: Option<MsgId> = 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<Option<MsgId>> {
let msg_id: Option<MsgId> = conn
.query_row(
"SELECT id FROM msgs WHERE chat_id=? AND state=?;",
(self, MessageState::OutDraft),
|row| row.get(0),
)
.await?;
.optional()?;
Ok(msg_id)
}
fn has_draft_with_id(&self, conn: &rusqlite::Connection, draft_id: &MsgId) -> Result<bool> {
let count: u32 = conn.query_row(
"SELECT
COUNT(*)
FROM msgs
WHERE id=? AND chat_id=? AND state=?",
(draft_id, self, MessageState::OutDraft),
|row| row.get(0),
)?;
Ok(count > 0)
}
fn can_reuse_draft(context: &Context, old: &Message, new: &Message) -> Result<bool> {
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<Option<Message>> {
let query_only = true;
context
.sql
.transaction_ex(query_only, |transaction| {
self.get_draft_trans(context, transaction)
})
.await
}
/// See [`Self::get_draft`].
pub fn get_draft_trans(
self,
context: &Context,
conn: &rusqlite::Connection,
) -> Result<Option<Message>> {
if self.is_special() {
return Ok(None);
}
match self.get_draft_msg_id(context).await? {
match self.get_draft_msg_id_trans(conn)? {
Some(draft_msg_id) => {
let msg = Message::load_from_db(context, draft_msg_id).await?;
let msg = Message::load_from_db_trans(context, conn, draft_msg_id)?;
Ok(Some(msg))
}
None => Ok(None),
@@ -783,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<bool> {
@@ -819,48 +881,54 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
msg.state = MessageState::OutDraft;
msg.chat_id = self;
// if possible, replace existing draft and keep id
if !msg.id.is_special()
&& let Some(old_draft) = self.get_draft(context).await?
&& old_draft.id == msg.id
&& old_draft.chat_id == self
&& old_draft.state == MessageState::OutDraft
{
let affected_rows = context
.sql.execute(
"UPDATE msgs
SET timestamp=?1,type=?2,txt=?3,txt_normalized=?4,param=?5,mime_in_reply_to=?6
WHERE id=?7
AND (type <> ?2
OR txt <> ?3
OR txt_normalized <> ?4
OR param <> ?5
OR mime_in_reply_to <> ?6);",
(
time(),
msg.viewtype,
&msg.text,
normalize_text(&msg.text),
msg.param.to_string(),
msg.in_reply_to.as_deref().unwrap_or_default(),
msg.id,
),
).await?;
return Ok(affected_rows > 0);
}
let row_id = context
.sql
.transaction(|transaction| {
// Delete existing draft if it exists.
transaction.execute(
"DELETE FROM msgs WHERE chat_id=? AND state=?",
(self, MessageState::OutDraft),
let trans_fn = |transaction: &mut rusqlite::Transaction| {
// if possible, replace existing draft and keep id
let reuse_existing_id: Option<MsgId> = 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
WHERE id=?7
AND (type <> ?2
OR txt <> ?3
OR txt_normalized <> ?4
OR param <> ?5
OR mime_in_reply_to <> ?6);",
(
time(),
msg.viewtype,
&msg.text,
normalize_text(&msg.text),
msg.param.to_string(),
msg.in_reply_to.as_deref().unwrap_or_default(),
reuse_existing_id,
),
)?;
let changed = affected_rows > 0;
return Ok((reuse_existing_id, changed));
}
// Insert new draft.
transaction.execute(
"INSERT INTO msgs (
// Delete existing draft if it exists.
transaction.execute(
"DELETE FROM msgs WHERE chat_id=? AND state=?",
(self, MessageState::OutDraft),
)?;
// Insert new draft.
transaction.execute(
"INSERT INTO msgs (
chat_id,
rfc724_mid,
from_id,
@@ -873,26 +941,28 @@ SELECT id, rfc724_mid, pre_rfc724_mid, timestamp, ?, 1 FROM msgs WHERE chat_id=?
hidden,
mime_in_reply_to)
VALUES (?,?,?,?,?,?,?,?,?,?,?);",
(
self,
&msg.rfc724_mid,
ContactId::SELF,
time(),
msg.viewtype,
MessageState::OutDraft,
&msg.text,
normalize_text(&msg.text),
msg.param.to_string(),
1,
msg.in_reply_to.as_deref().unwrap_or_default(),
),
)?;
(
self,
&msg.rfc724_mid,
ContactId::SELF,
time(),
msg.viewtype,
MessageState::OutDraft,
&msg.text,
normalize_text(&msg.text),
msg.param.to_string(),
1,
msg.in_reply_to.as_deref().unwrap_or_default(),
),
)?;
Ok(transaction.last_insert_rowid())
})
.await?;
msg.id = MsgId::new(row_id.try_into()?);
Ok(true)
let msg_id = MsgId::new(transaction.last_insert_rowid().try_into()?);
let changed = true;
Ok((msg_id, changed))
};
let (msg_id, changed) = context.sql.transaction(trans_fn).await?;
msg.id = msg_id;
Ok(changed)
}
/// Returns number of messages in a chat.
@@ -1758,21 +1828,24 @@ impl Chat {
/// Adds missing values to the msg object,
/// writes the record to the database.
///
/// If `update_msg_id` is set, that record is reused;
/// if `update_msg_id` is None, a new record is created.
/// If `update_existing_draft == `[`UseExistingDraftPolicy::Reuse`],
/// 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(
&mut self,
context: &Context,
msg: &mut Message,
update_msg_id: Option<MsgId>,
update_existing_draft: UseExistingDraftPolicy,
) -> Result<()> {
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
@@ -1806,11 +1879,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<String>;
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
@@ -1858,9 +1933,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.
@@ -1868,7 +1943,13 @@ 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;
}
fn get_new_references<'a>(
new_references_opt: &'a Option<String>,
rfc724_mid: &'a str,
) -> &'a str {
new_references_opt.as_deref().unwrap_or(rfc724_mid)
}
// add independent location to database
@@ -1933,10 +2014,54 @@ impl Chat {
msg.from_id = ContactId::SELF;
// add message to the database
if let Some(update_msg_id) = update_msg_id {
context
.sql
.execute(
let trans_fn = |transaction: &mut rusqlite::Transaction| {
let try_reuse: bool = update_existing_draft == UseExistingDraftPolicy::Reuse;
let reuse_existing: Option<(MsgId, String)> = if !try_reuse {
None
} else if !msg.id.is_special() {
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)?
{
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, 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)? {
bail!(
concat!(
"wanted to prepare existing draft for sending in chat {0}, ",
"but no draft with ID {1} is present ",
"(it might have been sent or deleted)"
),
self.id,
reuse_existing_id
);
}
transaction.execute(
"UPDATE msgs
SET rfc724_mid=?, chat_id=?, from_id=?, to_id=?, timestamp=?, type=?,
state=?, txt=?, txt_normalized=?, subject=?, param=?,
@@ -1945,7 +2070,7 @@ impl Chat {
ephemeral_timestamp=?
WHERE id=?;",
params_slice![
msg.rfc724_mid,
rfc724_mid,
msg.chat_id,
msg.from_id,
to_id,
@@ -1958,45 +2083,49 @@ 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,
ephemeral_timer,
ephemeral_timestamp,
update_msg_id
reuse_existing_id
],
)
.await?;
msg.id = update_msg_id;
} else {
let raw_id = context
.sql
.insert(
)?;
let inserted = false;
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,
chat_id,
from_id,
to_id,
timestamp,
type,
state,
txt,
txt_normalized,
subject,
param,
hidden,
mime_in_reply_to,
mime_references,
mime_modified,
mime_headers,
mime_compressed,
location_id,
ephemeral_timer,
ephemeral_timestamp)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?,?);",
rfc724_mid,
chat_id,
from_id,
to_id,
timestamp,
type,
state,
txt,
txt_normalized,
subject,
param,
hidden,
mime_in_reply_to,
mime_references,
mime_modified,
mime_headers,
mime_compressed,
location_id,
ephemeral_timer,
ephemeral_timestamp)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?,?);",
params_slice![
msg.rfc724_mid,
rfc724_mid,
msg.chat_id,
msg.from_id,
to_id,
@@ -2009,17 +2138,25 @@ 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,
ephemeral_timer,
ephemeral_timestamp
],
)
.await?;
)?;
let msg_id = MsgId::new(transaction.last_insert_rowid().try_into()?);
let inserted = true;
Ok((msg_id, rfc724_mid.to_string(), inserted))
}
};
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();
msg.id = MsgId::new(u32::try_from(raw_id)?);
maybe_set_logging_xdc(context, msg, self.id).await?;
context
@@ -2607,6 +2744,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.
@@ -2614,6 +2759,21 @@ 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<MsgId> {
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
}
/// 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,
msg: &mut Message,
update_existing_draft: UseExistingDraftPolicy,
) -> Result<MsgId> {
ensure!(
!chat_id.is_special(),
"chat_id cannot be a special chat: {chat_id}"
@@ -2630,7 +2790,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);
}
@@ -2650,7 +2813,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<MsgId> {
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);
}
@@ -2671,6 +2836,7 @@ async fn prepare_send_msg(
context: &Context,
chat_id: ChatId,
msg: &mut Message,
update_existing_draft: UseExistingDraftPolicy,
) -> Result<Vec<i64>> {
let mut chat = Chat::load_from_db(context, chat_id).await?;
@@ -2715,17 +2881,9 @@ async fn prepare_send_msg(
);
}
// check current MessageState for drafts (to keep msg_id) ...
let update_msg_id = if msg.state == MessageState::OutDraft {
if msg.state == MessageState::OutDraft {
msg.hidden = false;
if !msg.id.is_special() && msg.chat_id == chat_id {
Some(msg.id)
} else {
None
}
} else {
None
};
}
if msg.state == MessageState::Undefined
// Legacy SecureJoin "v*-request" messages are unencrypted.
@@ -2744,7 +2902,8 @@ async fn prepare_send_msg(
if !msg.hidden {
chat_id.unarchive_if_not_muted(context, msg.state).await?;
}
chat.prepare_msg_raw(context, msg, update_msg_id).await?;
chat.prepare_msg_raw(context, msg, update_existing_draft)
.await?;
let row_ids = create_send_msg_jobs(context, msg)
.await
@@ -2755,6 +2914,36 @@ async fn prepare_send_msg(
Ok(row_ids)
}
/// When sending a message or setting the draft,
/// what to do about the potentially existing draft.
///
/// This is basically a more explicit bool.
#[derive(Debug, PartialEq)]
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
/// keeps its original ID.
/// Useful for WebXDC attachments.
Reuse,
}
impl From<UseExistingDraftPolicy> for bool {
fn from(reuse: UseExistingDraftPolicy) -> bool {
match reuse {
UseExistingDraftPolicy::DontReuse => false,
UseExistingDraftPolicy::Reuse => true,
}
}
}
impl From<bool> for UseExistingDraftPolicy {
fn from(reuse: bool) -> UseExistingDraftPolicy {
match reuse {
false => UseExistingDraftPolicy::DontReuse,
true => UseExistingDraftPolicy::Reuse,
}
}
}
/// Renders the Message or splits it into Pre- and Post-Message.
///
/// Pre-Message is a small message with metadata which announces a larger Post-Message.
@@ -4576,7 +4765,8 @@ pub async fn forward_msgs_2ctx(
msg.rfc724_mid = create_outgoing_rfc724_mid();
msg.pre_rfc724_mid.clear();
msg.timestamp_sort = now;
chat.prepare_msg_raw(ctx_dst, &mut msg, None).await?;
chat.prepare_msg_raw(ctx_dst, &mut msg, UseExistingDraftPolicy::DontReuse)
.await?;
if !create_send_msg_jobs(ctx_dst, &mut msg).await?.is_empty() {
ctx_dst.scheduler.interrupt_smtp().await;
+35
View File
@@ -175,6 +175,41 @@ async fn test_draft_stable_ids() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_dont_send_sent_draft() -> Result<()> {
let mut tcm = TestContextManager::new();
let t = &tcm.alice().await;
let bob = &tcm.bob().await;
let chat_id = t.create_chat(bob).await.id;
let mut msg = Message::new_text("original".to_string());
chat_id.set_draft(t, Some(&mut msg)).await?;
assert_eq!(msg.state, MessageState::OutDraft);
let mut msg_clone = msg.clone();
send_msg(t, chat_id, &mut msg).await?;
assert_eq!(msg.state, MessageState::OutPending);
msg_clone.set_text("modified".to_string());
// Try to send the stale draft Message object with the same ID again.
assert_eq!(msg_clone.id, msg.id);
assert_eq!(msg_clone.state, MessageState::OutDraft);
let msg_from_db_before_send = Message::load_from_db(t, msg.id).await?;
let send_res = send_msg(t, chat_id, &mut msg_clone).await;
assert!(send_res.is_err());
let msg_from_db_after_send = Message::load_from_db(t, msg.id).await?;
assert_eq!(msg_from_db_after_send.text, "original");
assert_eq!(
serde_json::to_string_pretty(&msg_from_db_before_send).unwrap(),
serde_json::to_string_pretty(&msg_from_db_after_send).unwrap()
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_only_one_draft_per_chat() -> Result<()> {
let t = TestContext::new_alice().await;
+35 -6
View File
@@ -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<Message> {
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<Message> {
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<Option<Message>> {
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<Option<Message>> {
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 {