mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
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.
This commit is contained in:
@@ -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<typeof vi.fn>;
|
||||
|
||||
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' });
|
||||
});
|
||||
});
|
||||
+20
-3
@@ -1379,7 +1379,7 @@ export async function cancelScheduledSend(emailSubmissionId: string): Promise<vo
|
||||
/**
|
||||
* Change when a scheduled message goes out (or send it now with `holdForSeconds`
|
||||
* = 0): cancel the pending submission and create a replacement for the same
|
||||
* Email with a fresh HOLDFOR envelope, in one request. Returns the new
|
||||
* Email with the held envelope and a fresh HOLDFOR, in one request. Returns the new
|
||||
* submission id and resolved send time.
|
||||
*/
|
||||
export async function rescheduleScheduledSend(
|
||||
@@ -1390,10 +1390,27 @@ export async function rescheduleScheduledSend(
|
||||
const accountId = jmapClient.accountId;
|
||||
const create: Record<string, unknown> = { 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,
|
||||
|
||||
Reference in New Issue
Block a user