From acbbcb134c8d4546040e508d9bad81889283ef0b Mon Sep 17 00:00:00 2001 From: Matthieu MALVACHE Date: Fri, 17 Apr 2026 14:48:17 +0200 Subject: [PATCH] fix(email): route outgoing mail through Drafts to avoid self-send dedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating the outgoing Email directly in Sent before running EmailSubmission/set makes Stalwart's duplicate-message check trigger on inbound delivery when a user sends to themselves: the SMTP- delivered copy matches the local Sent copy's Message-ID and gets dropped, so the inbox stays empty. Keep the email in Drafts (or the sent mailbox as a fallback if no Drafts exists) during submission and let EmailSubmission's onSuccessUpdateEmail atomically move it to Sent — clearing the $draft keyword and setting $seen — only after the server has handed the outbound copy to SMTP. SMTP delivery for email-to-self now sees no existing Message-ID in the account and goes through. Applies to both the draft-send and the send-from-scratch paths. Closes #60. --- lib/jmap/client.ts | 46 +++++++++++++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 2f976a8..311f23b 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1324,6 +1324,7 @@ export class JMAPClient { if (!sentMailbox) { throw new Error('No sent mailbox found'); } + const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); let finalIdentityId = identityId; if (!finalIdentityId) { @@ -1343,21 +1344,32 @@ export class JMAPClient { const methodCalls: JMAPMethodCall[] = []; + // Stalwart's duplicate-message check (and many MDAs) consults the + // sender's own account when delivering inbound mail. If we land the + // outgoing copy in Sent before EmailSubmission runs, the subsequent + // SMTP delivery for email-to-self matches the already-stored + // Message-ID and gets dropped as a duplicate. Keep the email in the + // Drafts mailbox (or any non-shared inbox proxy) during submission + // and let EmailSubmission's onSuccessUpdateEmail move it to Sent + // only after the server has handed the outbound copy to SMTP. + // Closes #60. + const holdingMailboxId = draftsMailbox?.id ?? sentMailbox.id; + if (draftId) { - methodCalls.push(["Email/set", { - accountId: this.accountId, - update: { - [draftId]: { - "keywords/$draft": false, - "keywords/$seen": true, - mailboxIds: { [sentMailbox.id]: true }, - }, - }, - }, "0"]); + // Draft already lives in Drafts — don't touch its mailbox until + // after submission succeeds. methodCalls.push(["EmailSubmission/set", { accountId: this.accountId, create: { "1": { emailId: draftId, identityId: finalIdentityId } }, - }, "1"]); + onSuccessUpdateEmail: { + "#1": { + [`mailboxIds/${holdingMailboxId}`]: null, + [`mailboxIds/${sentMailbox.id}`]: true, + "keywords/$draft": null, + "keywords/$seen": true, + }, + }, + }, "0"]); } else { methodCalls.push(["Email/set", { accountId: this.accountId, @@ -1368,8 +1380,8 @@ export class JMAPClient { cc: cc?.map(email => ({ email })), bcc: bcc?.map(email => ({ email })), subject, - keywords: { "$seen": true }, - mailboxIds: { [sentMailbox.id]: true }, + keywords: { "$draft": true }, + mailboxIds: { [holdingMailboxId]: true }, bodyValues: { "1": { value: body } }, textBody: [{ partId: "1", type: "text/plain" }], }, @@ -1378,6 +1390,14 @@ export class JMAPClient { methodCalls.push(["EmailSubmission/set", { accountId: this.accountId, create: { "1": { emailId: `#${emailId}`, identityId: finalIdentityId } }, + onSuccessUpdateEmail: { + "#1": { + [`mailboxIds/${holdingMailboxId}`]: null, + [`mailboxIds/${sentMailbox.id}`]: true, + "keywords/$draft": null, + "keywords/$seen": true, + }, + }, }, "1"]); }