mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
fix: keep delivery notifications and encrypted delivery when a scheduled send is moved
This commit is contained in:
@@ -20,7 +20,7 @@ vi.mock('../jmap-client', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { jmapClient } from '../jmap-client';
|
import { jmapClient } from '../jmap-client';
|
||||||
import { saveDraftVersion, sendEmail } from '../email';
|
import { rescheduleScheduledSend, saveDraftVersion, sendEmail } from '../email';
|
||||||
import { draftContextFromEmail } from '../../lib/draft-context';
|
import { draftContextFromEmail } from '../../lib/draft-context';
|
||||||
import type { Email } from '../types';
|
import type { Email } from '../types';
|
||||||
|
|
||||||
@@ -118,3 +118,40 @@ describe('delivery status notifications and REQUIRETLS', () => {
|
|||||||
expect(sentCalls().submission.envelope).toBeUndefined();
|
expect(sentCalls().submission.envelope).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('moving a held send', () => {
|
||||||
|
it('keeps its delivery notification and REQUIRETLS, and only those', async () => {
|
||||||
|
const set = (body: Record<string, unknown>) => ({ methodResponses: [['EmailSubmission/set', body, '0']] });
|
||||||
|
mockRequest.mockReset();
|
||||||
|
mockRequest
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
methodResponses: [
|
||||||
|
['EmailSubmission/get', {
|
||||||
|
list: [{
|
||||||
|
id: 'sub-1',
|
||||||
|
envelope: {
|
||||||
|
mailFrom: { email: 'me@example.com', parameters: { HOLDFOR: '3600', RET: 'HDRS', REQUIRETLS: null, SIZE: '1234' } },
|
||||||
|
rcptTo: [{ email: 'you@example.com', parameters: { NOTIFY: 'SUCCESS,FAILURE,DELAY', ORCPT: 'x' } }],
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}, '0'],
|
||||||
|
['Email/get', { list: [{ id: 'e-1', to: [{ email: 'you@example.com' }] }] }, '1'],
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce(set({ updated: { 'sub-1': null } }))
|
||||||
|
.mockResolvedValueOnce(set({ destroyed: ['sub-1'] }))
|
||||||
|
.mockResolvedValueOnce(set({ created: { replacement: { id: 'sub-2', sendAt: '2026-11-01T08:00:00Z' } } }));
|
||||||
|
|
||||||
|
await rescheduleScheduledSend(
|
||||||
|
{ emailSubmissionId: 'sub-1', emailId: 'e-1', identityId: 'id-1', from: [{ email: 'me@example.com' }], to: [{ email: 'you@example.com' }] } as never,
|
||||||
|
7200,
|
||||||
|
);
|
||||||
|
const create = mockRequest.mock.calls
|
||||||
|
.map((c) => c[0][0])
|
||||||
|
.find(([method, args]: [string, { create?: object }]) => method === 'EmailSubmission/set' && args.create)[1];
|
||||||
|
expect(create.create.replacement.envelope).toEqual({
|
||||||
|
mailFrom: { email: 'me@example.com', parameters: { RET: 'HDRS', REQUIRETLS: null, HOLDFOR: '7200' } },
|
||||||
|
rcptTo: [{ email: 'you@example.com', parameters: { NOTIFY: 'SUCCESS,FAILURE,DELAY' } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
|
||||||
import { JMAPClient } from '../jmap-client';
|
import { JMAPClient } from '../jmap-client';
|
||||||
import { cancelScheduledSend, destroyEmails, getMailboxes, listScheduledEmails, sendEmail } from '../email';
|
import { cancelScheduledSend, destroyEmails, getMailboxes, listScheduledEmails, rescheduleScheduledSend, sendEmail } from '../email';
|
||||||
import { getIdentities } from '../identity';
|
import { getIdentities } from '../identity';
|
||||||
import { supportsSubmissionExtension } from '../../lib/submission-options';
|
import { supportsSubmissionExtension } from '../../lib/submission-options';
|
||||||
|
|
||||||
@@ -103,6 +103,17 @@ live('live Stalwart: composer send options', () => {
|
|||||||
expect(submission.envelope.rcptTo).toEqual([
|
expect(submission.envelope.rcptTo).toEqual([
|
||||||
expect.objectContaining({ email: identity.email, parameters: expect.objectContaining({ NOTIFY: 'SUCCESS,FAILURE,DELAY' }) }),
|
expect.objectContaining({ email: identity.email, parameters: expect.objectContaining({ NOTIFY: 'SUCCESS,FAILURE,DELAY' }) }),
|
||||||
]);
|
]);
|
||||||
await cancelScheduledSend(result.emailSubmissionId!);
|
|
||||||
|
// Moved to another time, it keeps them.
|
||||||
|
const held = (await listScheduledEmails()).find((s) => s.emailSubmissionId === result.emailSubmissionId)!;
|
||||||
|
const moved = await rescheduleScheduledSend(held, 7200);
|
||||||
|
const after = await singleton.request([['EmailSubmission/get', {
|
||||||
|
accountId: singleton.accountId, ids: [moved.emailSubmissionId], properties: ['envelope'],
|
||||||
|
}, '0']], ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:submission']);
|
||||||
|
const [replacement] = after.methodResponses[0][1].list;
|
||||||
|
expect(replacement.envelope.mailFrom.parameters).toMatchObject({ RET: 'HDRS', HOLDFOR: '7200' });
|
||||||
|
expect('REQUIRETLS' in replacement.envelope.mailFrom.parameters).toBe(true);
|
||||||
|
expect(replacement.envelope.rcptTo[0].parameters).toMatchObject({ NOTIFY: 'SUCCESS,FAILURE,DELAY' });
|
||||||
|
await cancelScheduledSend(moved.emailSubmissionId!);
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
});
|
});
|
||||||
|
|||||||
+32
-4
@@ -2289,6 +2289,22 @@ export class RescheduleLostError extends Error {
|
|||||||
* and send time, and the recipients the server refused for the replacement
|
* and send time, and the recipients the server refused for the replacement
|
||||||
* when it took the others.
|
* when it took the others.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* The SMTP parameters among `names` that a held submission's envelope
|
||||||
|
* carries, or undefined with none. Only the ones the composer sets are kept,
|
||||||
|
* never parameters the server may have added.
|
||||||
|
*/
|
||||||
|
function keptSubmissionParameters(
|
||||||
|
parameters: Record<string, string | null> | null | undefined,
|
||||||
|
names: string[],
|
||||||
|
): Record<string, string | null> | undefined {
|
||||||
|
if (!parameters) return undefined;
|
||||||
|
const kept = Object.fromEntries(
|
||||||
|
Object.entries(parameters).filter(([key]) => names.includes(key.toUpperCase())),
|
||||||
|
);
|
||||||
|
return Object.keys(kept).length > 0 ? kept : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export async function rescheduleScheduledSend(
|
export async function rescheduleScheduledSend(
|
||||||
scheduled: {
|
scheduled: {
|
||||||
emailSubmissionId: string;
|
emailSubmissionId: string;
|
||||||
@@ -2321,8 +2337,9 @@ export async function rescheduleScheduledSend(
|
|||||||
],
|
],
|
||||||
[CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.SUBMISSION],
|
[CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.SUBMISSION],
|
||||||
);
|
);
|
||||||
|
type EnvelopeAddress = { email?: string; parameters?: Record<string, string | null> | null };
|
||||||
const original = (requireMethodResult(lookup, '0', 'EmailSubmission/get').list as Array<{
|
const original = (requireMethodResult(lookup, '0', 'EmailSubmission/get').list as Array<{
|
||||||
envelope?: { mailFrom?: { email?: string }; rcptTo?: Array<{ email?: string }> } | null;
|
envelope?: { mailFrom?: EnvelopeAddress; rcptTo?: EnvelopeAddress[] } | null;
|
||||||
sendAt?: string;
|
sendAt?: string;
|
||||||
identityId?: string;
|
identityId?: string;
|
||||||
}> | undefined)?.[0];
|
}> | undefined)?.[0];
|
||||||
@@ -2335,7 +2352,15 @@ export async function rescheduleScheduledSend(
|
|||||||
const held = requireMethodResult(lookup, '1', 'Email/get').list as Email[] | undefined;
|
const held = requireMethodResult(lookup, '1', 'Email/get').list as Email[] | undefined;
|
||||||
const headerRecipients = [...(held?.[0]?.to ?? []), ...(held?.[0]?.cc ?? []), ...(held?.[0]?.bcc ?? [])];
|
const headerRecipients = [...(held?.[0]?.to ?? []), ...(held?.[0]?.cc ?? []), ...(held?.[0]?.bcc ?? [])];
|
||||||
const source = recipients ?? (envelope?.rcptTo?.length ? envelope.rcptTo : headerRecipients);
|
const source = recipients ?? (envelope?.rcptTo?.length ? envelope.rcptTo : headerRecipients);
|
||||||
const rcptTo = source.map((r) => ({ email: (r.email ?? '').trim() })).filter((r) => r.email);
|
// The original's delivery notification (RET / NOTIFY) and REQUIRETLS
|
||||||
|
// ride along; dropping them turned a moved send into a plain one.
|
||||||
|
const keptMailFromParams = keptSubmissionParameters(envelope?.mailFrom?.parameters, ['RET', 'REQUIRETLS']);
|
||||||
|
const keptRcptParams = keptSubmissionParameters(
|
||||||
|
envelope?.rcptTo?.find((r) => r.parameters)?.parameters, ['NOTIFY'],
|
||||||
|
);
|
||||||
|
const rcptTo = source
|
||||||
|
.map((r) => ({ email: (r.email ?? '').trim(), ...(keptRcptParams ? { parameters: keptRcptParams } : {}) }))
|
||||||
|
.filter((r) => r.email);
|
||||||
const mailFrom = envelope?.mailFrom?.email || scheduled.from?.[0]?.email;
|
const mailFrom = envelope?.mailFrom?.email || scheduled.from?.[0]?.email;
|
||||||
// Without an answer the replacement is made without filing, and a message
|
// Without an answer the replacement is made without filing, and a message
|
||||||
// that can't be put back stays where it is.
|
// that can't be put back stays where it is.
|
||||||
@@ -2366,9 +2391,12 @@ export async function rescheduleScheduledSend(
|
|||||||
emailId: scheduled.emailId,
|
emailId: scheduled.emailId,
|
||||||
identityId,
|
identityId,
|
||||||
envelope: {
|
envelope: {
|
||||||
mailFrom: seconds === undefined
|
mailFrom: seconds === undefined && !keptMailFromParams
|
||||||
? { email: mailFrom }
|
? { email: mailFrom }
|
||||||
: { email: mailFrom, parameters: { HOLDFOR: String(seconds) } },
|
: {
|
||||||
|
email: mailFrom,
|
||||||
|
parameters: { ...keptMailFromParams, ...(seconds === undefined ? {} : { HOLDFOR: String(seconds) }) },
|
||||||
|
},
|
||||||
rcptTo,
|
rcptTo,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user