From fa0045e01b9a072403dd8b2c14a4a3194beab25f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 13 Apr 2026 00:50:13 +0200 Subject: [PATCH] fix: use onSuccessUpdateEmail to send before storing in Sent #188 --- lib/jmap/client.ts | 85 +++++++++++++++++++++++++++++++++++++------ stores/email-store.ts | 3 +- 2 files changed, 75 insertions(+), 13 deletions(-) diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index cddd4bea..e2c5e950 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1783,6 +1783,10 @@ export class JMAPClient implements IJMAPClient { if (!sentMailbox) { throw new Error('No sent mailbox found'); } + const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); + if (!draftsMailbox) { + throw new Error('No drafts mailbox found'); + } let finalIdentityId = identityId; let identityReplyTo: EmailAddress[] | undefined; @@ -1819,8 +1823,8 @@ export class JMAPClient implements IJMAPClient { cc: cc?.map(email => ({ email })), bcc: bcc?.map(email => ({ email })), subject, - keywords: { "$seen": true }, - mailboxIds: { [sentMailbox.id]: true }, + keywords: { "$seen": true, "$draft": true }, + mailboxIds: { [draftsMailbox.id]: true }, }; if (htmlBody) { @@ -1847,6 +1851,17 @@ export class JMAPClient implements IJMAPClient { const methodCalls: JMAPMethodCall[] = []; + // Use onSuccessUpdateEmail to move from Drafts to Sent after submission. + // This ensures SMTP send happens before the email lands in Sent, avoiding + // issues with servers that encrypt on append (e.g. Stalwart). See #188. + const onSuccessUpdateEmail = { + "#1": { + [`mailboxIds/${draftsMailbox.id}`]: null, + [`mailboxIds/${sentMailbox.id}`]: true, + "keywords/$draft": null, + }, + }; + if (draftId) { // Destroy the old draft and create a new email with the final body methodCalls.push(["Email/set", { @@ -1860,6 +1875,7 @@ export class JMAPClient implements IJMAPClient { methodCalls.push(["EmailSubmission/set", { accountId: this.accountId, create: { "1": { emailId: `#${emailId}`, identityId: finalIdentityId } }, + onSuccessUpdateEmail, }, "2"]); } else { methodCalls.push(["Email/set", { @@ -1869,6 +1885,7 @@ export class JMAPClient implements IJMAPClient { methodCalls.push(["EmailSubmission/set", { accountId: this.accountId, create: { "1": { emailId: `#${emailId}`, identityId: finalIdentityId } }, + onSuccessUpdateEmail, }, "1"]); } @@ -1915,6 +1932,10 @@ export class JMAPClient implements IJMAPClient { if (!sentMailbox) { throw new Error('No sent mailbox found'); } + const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); + if (!draftsMailbox) { + throw new Error('No drafts mailbox found'); + } let finalIdentityId = opts.identityId; if (!finalIdentityId) { @@ -2015,8 +2036,8 @@ export class JMAPClient implements IJMAPClient { from: [{ name: opts.attendeeName || undefined, email: opts.attendeeEmail }], to: [{ name: opts.organizerName || undefined, email: opts.organizerEmail }], subject, - keywords: { "$seen": true }, - mailboxIds: { [sentMailbox.id]: true }, + keywords: { "$seen": true, "$draft": true }, + mailboxIds: { [draftsMailbox.id]: true }, bodyStructure: { type: 'multipart/alternative', subParts: [ @@ -2038,6 +2059,13 @@ export class JMAPClient implements IJMAPClient { ["EmailSubmission/set", { accountId: this.accountId, create: { "sub-1": { emailId: `#${emailId}`, identityId: finalIdentityId } }, + onSuccessUpdateEmail: { + "#sub-1": { + [`mailboxIds/${draftsMailbox.id}`]: null, + [`mailboxIds/${sentMailbox.id}`]: true, + "keywords/$draft": null, + }, + }, }, "1"], ]; @@ -2076,6 +2104,10 @@ export class JMAPClient implements IJMAPClient { if (!sentMailbox) { throw new Error('No sent mailbox found'); } + const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); + if (!draftsMailbox) { + throw new Error('No drafts mailbox found'); + } // Find the organizer participant const organizerEntry = Object.values(event.participants).find(p => p.roles?.owner); @@ -2177,8 +2209,8 @@ export class JMAPClient implements IJMAPClient { from: [{ name: organizerName || undefined, email: organizerEmail }], to: toAddresses, subject, - keywords: { "$seen": true }, - mailboxIds: { [sentMailbox.id]: true }, + keywords: { "$seen": true, "$draft": true }, + mailboxIds: { [draftsMailbox.id]: true }, bodyStructure: { type: 'multipart/alternative', subParts: [ @@ -2200,6 +2232,13 @@ export class JMAPClient implements IJMAPClient { ["EmailSubmission/set", { accountId: this.accountId, create: { "sub-1": { emailId: `#${emailId}`, identityId } }, + onSuccessUpdateEmail: { + "#sub-1": { + [`mailboxIds/${draftsMailbox.id}`]: null, + [`mailboxIds/${sentMailbox.id}`]: true, + "keywords/$draft": null, + }, + }, }, "1"], ]; @@ -2233,6 +2272,10 @@ export class JMAPClient implements IJMAPClient { if (!sentMailbox) { throw new Error('No sent mailbox found'); } + const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); + if (!draftsMailbox) { + throw new Error('No drafts mailbox found'); + } const organizerEntry = Object.values(event.participants).find(p => p.roles?.owner); const organizerEmail = organizerEntry?.email || organizerEntry?.sendTo?.imip?.replace('mailto:', '') || this.username; @@ -2313,8 +2356,8 @@ export class JMAPClient implements IJMAPClient { from: [{ name: organizerName || undefined, email: organizerEmail }], to: toAddresses, subject, - keywords: { "$seen": true }, - mailboxIds: { [sentMailbox.id]: true }, + keywords: { "$seen": true, "$draft": true }, + mailboxIds: { [draftsMailbox.id]: true }, bodyStructure: { type: 'multipart/alternative', subParts: [ @@ -2336,6 +2379,13 @@ export class JMAPClient implements IJMAPClient { ["EmailSubmission/set", { accountId: this.accountId, create: { "sub-1": { emailId: `#${emailId}`, identityId } }, + onSuccessUpdateEmail: { + "#sub-1": { + [`mailboxIds/${draftsMailbox.id}`]: null, + [`mailboxIds/${sentMailbox.id}`]: true, + "keywords/$draft": null, + }, + }, }, "1"], ]; @@ -4747,21 +4797,23 @@ export class JMAPClient implements IJMAPClient { blob: Blob, identityId: string, sentMailboxId: string, - _draftMailboxId?: string, + draftMailboxId?: string, ): Promise { // Upload the raw message const file = new File([blob], 'message.eml', { type: 'message/rfc822' }); const { blobId } = await this.uploadBlob(file); - // Import into Sent, mark as seen, and submit — all in one request + // Import into Drafts first, then move to Sent after submission succeeds. + // This avoids encrypt-on-append affecting the SMTP send. See #188. + const importMailboxId = draftMailboxId || sentMailboxId; const methodCalls: [string, Record, string][] = [ ['Email/import', { accountId: this.accountId, emails: { 'raw-import': { blobId, - mailboxIds: { [sentMailboxId]: true }, - keywords: { '$seen': true }, + mailboxIds: { [importMailboxId]: true }, + keywords: draftMailboxId ? { '$seen': true, '$draft': true } : { '$seen': true }, }, }, }, '0'], @@ -4773,6 +4825,15 @@ export class JMAPClient implements IJMAPClient { identityId, }, }, + ...(draftMailboxId ? { + onSuccessUpdateEmail: { + '#raw-submit': { + [`mailboxIds/${draftMailboxId}`]: null, + [`mailboxIds/${sentMailboxId}`]: true, + 'keywords/$draft': null, + }, + }, + } : {}), }, '1'], ]; diff --git a/stores/email-store.ts b/stores/email-store.ts index db01b5be..69a60616 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -453,7 +453,8 @@ export const useEmailStore = create((set, get) => ({ const mailboxes = await client.getMailboxes(); const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); if (!sentMailbox) throw new Error('No sent mailbox found'); - await client.sendRawEmail(rawMimeBlob, identityId, sentMailbox.id); + const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); + await client.sendRawEmail(rawMimeBlob, identityId, sentMailbox.id, draftsMailbox?.id); set({ isLoading: false }); } catch (error) { set({