From e67947aa933261e865d2b8f491f9b3b54db9838c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:36:17 +0200 Subject: [PATCH] fix: keep cc and bcc recipients when rescheduling a send Rescheduling a held message rebuilt the replacement submission's envelope from the Email's To only, so Cc and Bcc recipients silently stopped getting the message, and a catch-all From was used as the envelope sender the server refuses. The replacement now reuses the held submission's envelope (`rcptTo` and `mailFrom`), falling back to the message's To, Cc and Bcc when the server returns none. --- src/api/__tests__/email-submission.test.ts | 104 +++++++++++++++++++++ src/api/email.ts | 23 ++++- 2 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 src/api/__tests__/email-submission.test.ts diff --git a/src/api/__tests__/email-submission.test.ts b/src/api/__tests__/email-submission.test.ts new file mode 100644 index 00000000..de98474c --- /dev/null +++ b/src/api/__tests__/email-submission.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../jmap-client', () => ({ + jmapClient: { + accountId: 'acc-1', + request: vi.fn(), + getMaxCallsInRequest: vi.fn(() => 16), + getMaxObjectsInGet: vi.fn(() => 500), + getMaxObjectsInSet: vi.fn(() => 500), + }, +})); + +import { jmapClient } from '../jmap-client'; +import { rescheduleScheduledSend } from '../email'; + +const mockRequest = jmapClient.request as ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +const SCHEDULED = { + emailSubmissionId: 'sub-1', + emailId: 'e-1', + identityId: 'id-1', + from: [{ email: 'news@shop.example' }], + to: [{ email: 'to@example.com' }], +}; + +const REPLACED = [['EmailSubmission/set', { + updated: { 'sub-1': null }, + created: { replacement: { id: 'sub-2', sendAt: '2026-09-25T08:00:00Z' } }, +}, '0']]; + +describe('rescheduleScheduledSend', () => { + it('keeps Cc, Bcc and the envelope sender of the held submission (B19)', async () => { + mockRequest + .mockResolvedValueOnce({ + methodResponses: [ + ['EmailSubmission/get', { + list: [{ + id: 'sub-1', + envelope: { + mailFrom: { email: 'me@example.com', parameters: { HOLDFOR: '3600' } }, + rcptTo: [ + { email: 'to@example.com', parameters: null }, + { email: 'cc@example.com', parameters: null }, + { email: 'bcc@example.com', parameters: null }, + ], + }, + }], + }, '0'], + ['Email/get', { list: [{ id: 'e-1', to: [{ email: 'to@example.com' }], cc: [{ email: 'cc@example.com' }] }] }, '1'], + ], + }) + .mockResolvedValueOnce({ methodResponses: REPLACED }); + + const result = await rescheduleScheduledSend(SCHEDULED, 7200); + + expect(result).toEqual({ emailSubmissionId: 'sub-2', sendAt: '2026-09-25T08:00:00Z' }); + const [lookup] = mockRequest.mock.calls[0][0]; + expect(lookup).toEqual(['EmailSubmission/get', { accountId: 'acc-1', ids: ['sub-1'], properties: ['envelope'] }, '0']); + const args = mockRequest.mock.calls[1][0][0][1]; + expect(args.update).toEqual({ 'sub-1': { undoStatus: 'canceled' } }); + expect(args.create.replacement.envelope).toEqual({ + mailFrom: { email: 'me@example.com', parameters: { HOLDFOR: '7200' } }, + rcptTo: [{ email: 'to@example.com' }, { email: 'cc@example.com' }, { email: 'bcc@example.com' }], + }); + }); + + it("falls back to the message's To, Cc and Bcc when the submission has no envelope", async () => { + mockRequest + .mockResolvedValueOnce({ + methodResponses: [ + ['EmailSubmission/get', { list: [{ id: 'sub-1', envelope: null }] }, '0'], + ['Email/get', { + list: [{ + id: 'e-1', + to: [{ email: 'to@example.com' }], + cc: [{ name: 'Cc', email: 'cc@example.com' }], + bcc: [{ email: ' bcc@example.com ' }], + }], + }, '1'], + ], + }) + .mockResolvedValueOnce({ methodResponses: REPLACED }); + + await rescheduleScheduledSend(SCHEDULED, 60); + + expect(mockRequest.mock.calls[1][0][0][1].create.replacement.envelope).toEqual({ + mailFrom: { email: 'news@shop.example', parameters: { HOLDFOR: '60' } }, + rcptTo: [{ email: 'to@example.com' }, { email: 'cc@example.com' }, { email: 'bcc@example.com' }], + }); + }); + + it('sends now without a lookup and lets the server derive the envelope', async () => { + mockRequest.mockResolvedValueOnce({ methodResponses: REPLACED }); + + await rescheduleScheduledSend(SCHEDULED, 0); + + expect(mockRequest).toHaveBeenCalledTimes(1); + expect(mockRequest.mock.calls[0][0][0][1].create.replacement).toEqual({ emailId: 'e-1', identityId: 'id-1' }); + }); +}); diff --git a/src/api/email.ts b/src/api/email.ts index 77b906f6..d881bedc 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -1379,7 +1379,7 @@ export async function cancelScheduledSend(emailSubmissionId: string): Promise = { emailId: scheduled.emailId, identityId: scheduled.identityId }; if (holdForSeconds > 0) { - const rcpt = (recipients ?? scheduled.to ?? []).map((r) => ({ email: r.email.trim() })).filter((r) => r.email); + // Reuse the held submission's envelope: it names every recipient, + // Cc and Bcc included, and the envelope sender a catch-all From went + // out through. Rebuilding it from the Email's To dropped Cc and Bcc. + // The Email's To/Cc/Bcc are the fallback when the server has none. + const lookup = await jmapClient.request( + [ + ['EmailSubmission/get', { accountId, ids: [scheduled.emailSubmissionId], properties: ['envelope'] }, '0'], + ['Email/get', { accountId, ids: [scheduled.emailId], properties: ['to', 'cc', 'bcc'] }, '1'], + ], + [CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.SUBMISSION], + ); + const envelope = (requireMethodResult(lookup, '0', 'EmailSubmission/get').list as Array<{ + envelope?: { mailFrom?: { email?: string }; rcptTo?: Array<{ email?: string }> } | null; + }> | undefined)?.[0]?.envelope; + const held = requireMethodResult(lookup, '1', 'Email/get').list as Email[] | undefined; + const headerRecipients = [...(held?.[0]?.to ?? []), ...(held?.[0]?.cc ?? []), ...(held?.[0]?.bcc ?? [])]; + const source = recipients ?? (envelope?.rcptTo?.length ? envelope.rcptTo : headerRecipients); + const rcpt = source.map((r) => ({ email: (r.email ?? '').trim() })).filter((r) => r.email); create.envelope = { mailFrom: { - email: scheduled.from?.[0]?.email, + email: envelope?.mailFrom?.email || scheduled.from?.[0]?.email, parameters: { HOLDFOR: String(Math.ceil(holdForSeconds)) }, }, rcptTo: rcpt,