fix(email): route outgoing mail through Drafts to avoid self-send dedup

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.
This commit is contained in:
Matthieu MALVACHE
2026-04-17 14:48:17 +02:00
parent 5f2792c991
commit acbbcb134c
+33 -13
View File
@@ -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"]);
}