From c51a848dfbf246c2896457a46a15b772686a3270 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:37:35 +0200 Subject: [PATCH] fix: stop reporting a send as failed when only filing it into Sent failed `sendEmail` threw on any method error in the response. Stalwart appends one for the implicit `onSuccessUpdateEmail` Email/set that moves the sent message from Drafts to Sent, so a message that had already gone out showed "Send failed" and a retry sent it a second time. A method error that follows a created submission is now returned as a filing warning, like the record-level filing failures already were; errors before the submission exists still fail the send. --- src/api/__tests__/email-submission.test.ts | 56 +++++++++++++++++++++- src/api/email.ts | 9 ++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/api/__tests__/email-submission.test.ts b/src/api/__tests__/email-submission.test.ts index de98474c..e77d8881 100644 --- a/src/api/__tests__/email-submission.test.ts +++ b/src/api/__tests__/email-submission.test.ts @@ -11,7 +11,7 @@ vi.mock('../jmap-client', () => ({ })); import { jmapClient } from '../jmap-client'; -import { rescheduleScheduledSend } from '../email'; +import { rescheduleScheduledSend, sendEmail } from '../email'; const mockRequest = jmapClient.request as ReturnType; @@ -102,3 +102,57 @@ describe('rescheduleScheduledSend', () => { expect(mockRequest.mock.calls[0][0][0][1].create.replacement).toEqual({ emailId: 'e-1', identityId: 'id-1' }); }); }); + +describe('sendEmail', () => { + const OUTGOING = { + from: [{ email: 'me@example.com' }], + to: [{ email: 'you@example.com' }], + subject: 'Hello', + textBody: 'Hi', + }; + + it('reports a failed post-send filing as a warning, not a failed send (B23)', async () => { + // Stalwart answers the implicit onSuccessUpdateEmail Email/set with a + // method error after the submission was created: the mail went out. + mockRequest.mockResolvedValueOnce({ + methodResponses: [ + ['Email/set', { created: { draft: { id: 'e-new' } } }, '0'], + ['EmailSubmission/set', { created: { 'sub-1': { id: 's-1', sendAt: '2026-09-24T10:00:30Z' } } }, '1'], + ['error', { type: 'forbidden', description: 'You do not have access to this mailbox' }, '1'], + ], + }); + + const result = await sendEmail(OUTGOING, 'identity-1', 'sent-mb', 30, { draftsMailboxId: 'drafts-mb' }); + + expect(result).toMatchObject({ + scheduled: true, + emailId: 'e-new', + emailSubmissionId: 's-1', + sendAt: '2026-09-24T10:00:30Z', + filingWarning: 'You do not have access to this mailbox', + }); + }); + + it('still fails when the submission itself was refused', async () => { + mockRequest.mockResolvedValueOnce({ + methodResponses: [ + ['Email/set', { created: { draft: { id: 'e-new' } } }, '0'], + ['error', { type: 'invalidArguments', description: 'Invalid envelope' }, '1'], + ], + }); + + await expect(sendEmail(OUTGOING, 'identity-1', 'sent-mb', undefined, { draftsMailboxId: 'drafts-mb' })) + .rejects.toThrow('Invalid envelope'); + }); + + it('still fails when the message could not be created', async () => { + mockRequest.mockResolvedValueOnce({ + methodResponses: [ + ['error', { type: 'serverFail', description: 'Disk full' }, '0'], + ['EmailSubmission/set', { notCreated: { 'sub-1': { type: 'invalidProperties' } } }, '1'], + ], + }); + + await expect(sendEmail(OUTGOING, 'identity-1', 'sent-mb')).rejects.toThrow('Disk full'); + }); +}); diff --git a/src/api/email.ts b/src/api/email.ts index d881bedc..a3d55b2c 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -1177,6 +1177,15 @@ export async function sendEmail( let filingWarning: string | undefined; for (const [methodName, result] of res.methodResponses) { if (methodName === 'error' || methodName.endsWith('/error')) { + // Once the submission exists the message has left (or is held), so a + // later error is the implicit `onSuccessUpdateEmail` Email/set that + // Stalwart appends failing to file it. Failing the send here made the + // user retry and send the message twice. + if (emailSubmissionId) { + const err = result as { description?: string; type?: string }; + filingWarning = filingWarning ?? (err.description || err.type || 'post-send filing failed'); + continue; + } throw new Error((result as { description?: string }).description ?? 'Send failed'); } if (methodName === 'Email/set') {