From fa7e29f980e106901c9e55ec9a7c84df83029caa Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:09:11 +0200 Subject: [PATCH] fix: remove the unsent copy from Drafts when a send fails --- lib/__tests__/jmap-draft-lifecycle.test.ts | 5 +-- .../jmap-send-rejected-recipients.test.ts | 18 +++++++++++ lib/jmap/client.ts | 31 +++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/lib/__tests__/jmap-draft-lifecycle.test.ts b/lib/__tests__/jmap-draft-lifecycle.test.ts index 0b657dfc1..6b2720f27 100644 --- a/lib/__tests__/jmap-draft-lifecycle.test.ts +++ b/lib/__tests__/jmap-draft-lifecycle.test.ts @@ -208,8 +208,9 @@ describe('draft replace lifecycle (#849)', () => { 'draft-old-1', )).rejects.toThrow(); + // Only the unsent copy may go; the user's draft stays for a retry. for (const set of emailSetCalls(captured)) { - expect(set.destroy).toBeUndefined(); + expect(set.destroy ?? []).not.toContain('draft-old-1'); } }); @@ -242,7 +243,7 @@ describe('compose failure recovery', () => { // Only the missing submission is unconfirmed; a method error is a plain failure. options.missingSubmission ? SendUnconfirmedError : Error, ); - expect(emailSetCalls(captured).some(call => call.destroy)).toBe(false); + expect(emailSetCalls(captured).some(call => (call.destroy as string[] | undefined)?.includes('old-draft'))).toBe(false); }); it('accepts empty notCreated maps as success', async () => { const client = createClient(); mockFlow({ emptyErrors: true }); diff --git a/lib/__tests__/jmap-send-rejected-recipients.test.ts b/lib/__tests__/jmap-send-rejected-recipients.test.ts index 3b5f429dc..b854f51aa 100644 --- a/lib/__tests__/jmap-send-rejected-recipients.test.ts +++ b/lib/__tests__/jmap-send-rejected-recipients.test.ts @@ -225,6 +225,16 @@ describe('JMAPClient.sendEmail refused recipients (#1123)', () => { await expect(send(createClient())).rejects.toThrow('Sending is disabled.'); }); + it('removes the unsent copy from Drafts when the submission is refused', async () => { + // The copy was created in Drafts next to the user's own draft. Left + // there, every failed attempt added a duplicate that outlived the send. + const { destroyed } = mockServer({ refuseSubmission: true }); + + await expect(send(createClient(), { draftId: 'draft-1' })).rejects.toThrow('Sending is disabled.'); + + expect(destroyed).toEqual(['email-9']); + }); + it('treats a server that cannot answer the read-back as a plain success', async () => { mockServer({ noSubmissionGet: true }); @@ -288,6 +298,14 @@ describe('JMAPClient raw sends refused recipients (#1123)', () => { expect(destroyed).toEqual(['email-9']); }); + it('sendRawEmail removes the imported copy when the submission is refused', async () => { + const { destroyed } = mockServer({ refuseSubmission: true }); + + await expect(rawClient().sendRawEmail(new Blob(['raw']), 'identity-1', 'mb-sent', 'mb-drafts')) + .rejects.toThrow('Sending is disabled.'); + expect(destroyed).toEqual(['email-9']); + }); + it('submitRawEmail fails when every recipient was refused', async () => { const { requests } = mockServer({ deliveryStatus: { 'gone@example.com': { delivered: 'no', smtpReply: MAILBOX_GONE, displayed: 'unknown' } }, diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 165d676d6..c83ef34c6 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -4179,8 +4179,23 @@ export class JMAPClient implements IJMAPClient { const statusReadBack = takeDeliveryStatus(response); response = statusReadBack.response; + // The outgoing copy is created in Drafts, next to the user's own draft. + // When the submission fails it never moves to Sent, so remove it before + // reporting the failure; the user's draft is kept for the retry. + const unsentCopyId = response.methodResponses + ?.find(([name]) => name === 'Email/set')?.[1]?.created?.[emailId]?.id as string | undefined; + const dropUnsentCopy = async () => { + if (!unsentCopyId) return; + try { + await this.request([["Email/set", { accountId: targetAccountId, destroy: [unsentCopyId] }, "0"]]); + } catch (err) { + console.error('[sendEmail] removing the unsent copy from Drafts failed:', err); + } + }; + const { failure, filing } = sendMethodErrors(response.methodResponses); if (failure) { + await dropUnsentCopy(); console.error('[sendEmail] JMAP method error:', failure); const propsHint = failure.properties?.length ? ` (properties: ${failure.properties.join(', ')})` : ''; throw new Error(`${failure.description || `Failed to send email: ${failure.type}`}${propsHint}`); @@ -4208,6 +4223,7 @@ export class JMAPClient implements IJMAPClient { `[sendEmail] ${methodName} notCreated:`, JSON.stringify(errors, null, 2), ); + if (methodName === 'EmailSubmission/set') await dropUnsentCopy(); this.throwIfHoldTooLong(firstError); const propsHint = firstError?.properties?.length ? ` (properties: ${firstError.properties.join(', ')})` @@ -9234,14 +9250,29 @@ export class JMAPClient implements IJMAPClient { let emailSubmissionId: string | undefined; let serverSendAt: string | undefined; + // A failed submission leaves the imported copy behind; drop it so retries + // do not pile up copies next to the draft (see sendEmail). + const importedId = (response.methodResponses ?? []) + .find(([name]) => name === 'Email/import')?.[1]?.created?.['raw-import']?.id as string | undefined; + const dropUnsentCopy = async () => { + if (!importedId) return; + try { + await this.request([['Email/set', { accountId: this.accountId, destroy: [importedId] }, '0']]); + } catch (err) { + console.error('[sendRawEmail] removing the unsent copy failed:', err); + } + }; + // Check for errors const { failure } = sendMethodErrors(response.methodResponses); if (failure) { + await dropUnsentCopy(); throw new Error(failure.description || `Failed: ${failure.type}`); } for (const [methodName, result] of response.methodResponses ?? []) { const r = result as { notCreated?: Record }; if (r.notCreated) { + if (methodName === 'EmailSubmission/set') await dropUnsentCopy(); const firstErr = Object.values(r.notCreated)[0]; this.throwIfHoldTooLong(firstErr); throw new Error(firstErr?.description || firstErr?.type || 'Failed to send raw email');