mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-03 08:06:59 +00:00
Merge branch 'cu-compx' into catch-up-2026-11
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// The composer's message priority (webmail #970) and the DSN / REQUIRETLS
|
||||
// submission options (webmail submissionEnvelopeParameters): what the
|
||||
// Email/set create and the EmailSubmission envelope carry.
|
||||
|
||||
vi.mock('../jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
request: vi.fn(),
|
||||
learnHoldLimit: vi.fn(),
|
||||
getSubmissionAccountIds: vi.fn(() => ['acc-1']),
|
||||
hasDelayedSend: vi.fn(() => true),
|
||||
hasAccountCapability: vi.fn(() => false),
|
||||
getMaxCallsInRequest: vi.fn(() => 16),
|
||||
getMaxObjectsInGet: vi.fn(() => 500),
|
||||
getMaxObjectsInSet: vi.fn(() => 500),
|
||||
getAccountCapability: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { jmapClient } from '../jmap-client';
|
||||
import { rescheduleScheduledSend, saveDraftVersion, sendEmail } from '../email';
|
||||
import { draftContextFromEmail } from '../../lib/draft-context';
|
||||
import type { Email } from '../types';
|
||||
|
||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||
|
||||
const OUTGOING = {
|
||||
from: [{ email: 'me@example.com' }],
|
||||
to: [{ email: 'you@example.com' }],
|
||||
cc: [{ email: 'cc@example.com' }],
|
||||
subject: 'Hello',
|
||||
textBody: 'Hi',
|
||||
};
|
||||
|
||||
const SENT = {
|
||||
methodResponses: [
|
||||
['Email/set', { created: { draft: { id: 'e-new' } } }, '0'],
|
||||
['EmailSubmission/set', { created: { 'sub-1': { id: 's-1' } } }, '1'],
|
||||
],
|
||||
};
|
||||
|
||||
function sentCalls() {
|
||||
const calls = mockRequest.mock.calls[0][0] as Array<[string, Record<string, any>, string]>;
|
||||
const create = calls.find(([name]) => name === 'Email/set')![1].create.draft;
|
||||
const submission = calls.find(([name]) => name === 'EmailSubmission/set')![1].create['sub-1'];
|
||||
return { create, submission };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRequest.mockResolvedValue(SENT);
|
||||
});
|
||||
|
||||
describe('message priority', () => {
|
||||
it('writes X-Priority and Importance for a high-priority message', async () => {
|
||||
await sendEmail({ ...OUTGOING, priority: 'high' }, 'id-1', 'sent', undefined, { draftsMailboxId: 'drafts' });
|
||||
const { create } = sentCalls();
|
||||
expect(create['header:X-Priority:asText']).toBe('1 (Highest)');
|
||||
expect(create['header:Importance:asText']).toBe('high');
|
||||
});
|
||||
|
||||
it('writes the low values for a low-priority message', async () => {
|
||||
await sendEmail({ ...OUTGOING, priority: 'low' }, 'id-1', 'sent');
|
||||
const { create } = sentCalls();
|
||||
expect(create['header:X-Priority:asText']).toBe('5 (Lowest)');
|
||||
expect(create['header:Importance:asText']).toBe('low');
|
||||
});
|
||||
|
||||
it('writes no priority header for a normal message', async () => {
|
||||
await sendEmail({ ...OUTGOING, priority: 'normal' }, 'id-1', 'sent');
|
||||
const { create } = sentCalls();
|
||||
expect(Object.keys(create).some((k) => /Priority|Importance/.test(k))).toBe(false);
|
||||
});
|
||||
|
||||
it('saves the priority with a draft, and a re-opened draft reads it back', async () => {
|
||||
mockRequest.mockResolvedValueOnce({
|
||||
methodResponses: [
|
||||
['Email/set', { created: { draft: { id: 'd-1' } } }, '0'],
|
||||
['Email/get', { list: [] }, '1'],
|
||||
],
|
||||
});
|
||||
await saveDraftVersion({ ...OUTGOING, priority: 'high' }, 'drafts');
|
||||
const create = (mockRequest.mock.calls[0][0] as any[])[0][1].create.draft;
|
||||
expect(create['header:X-Priority:asText']).toBe('1 (Highest)');
|
||||
|
||||
const reopened = draftContextFromEmail({
|
||||
id: 'd-1',
|
||||
headers: [{ name: 'X-Priority', value: ' 1 (Highest)' }, { name: 'Importance', value: ' high' }],
|
||||
} as unknown as Email);
|
||||
expect(reopened.priority).toBe('high');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delivery status notifications and REQUIRETLS', () => {
|
||||
it('asks every recipient for a DSN with headers only', async () => {
|
||||
await sendEmail(OUTGOING, 'id-1', 'sent', undefined, { draftsMailboxId: 'drafts', requestDsn: true });
|
||||
const { submission } = sentCalls();
|
||||
expect(submission.envelope).toEqual({
|
||||
mailFrom: { email: 'me@example.com', parameters: { RET: 'HDRS' } },
|
||||
rcptTo: [
|
||||
{ email: 'you@example.com', parameters: { NOTIFY: 'SUCCESS,FAILURE,DELAY' } },
|
||||
{ email: 'cc@example.com', parameters: { NOTIFY: 'SUCCESS,FAILURE,DELAY' } },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('puts REQUIRETLS next to the HOLDFOR of a held send', async () => {
|
||||
await sendEmail(OUTGOING, 'id-1', 'sent', 30, { draftsMailboxId: 'drafts', requireTls: true });
|
||||
const { submission } = sentCalls();
|
||||
expect(submission.envelope.mailFrom.parameters).toEqual({ HOLDFOR: '30', REQUIRETLS: null });
|
||||
expect(submission.envelope.rcptTo).toEqual([{ email: 'you@example.com' }, { email: 'cc@example.com' }]);
|
||||
});
|
||||
|
||||
it('leaves the envelope to the server without options, hold or From override', async () => {
|
||||
await sendEmail(OUTGOING, 'id-1', 'sent', undefined, { draftsMailboxId: 'drafts' });
|
||||
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' } }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Live checks of the composer's send options against a real Stalwart: the
|
||||
* priority headers (webmail #970), the read-receipt request and the DSN /
|
||||
* REQUIRETLS envelope parameters (webmail submissionEnvelopeParameters).
|
||||
*
|
||||
* Opt-in like live-stalwart.test.ts: skipped unless `BULWARK_LIVE_JMAP` and
|
||||
* `BULWARK_LIVE_PASS` are set.
|
||||
*
|
||||
* BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \
|
||||
* BULWARK_LIVE_USER=e2e-perf@example.org BULWARK_LIVE_PASS=… \
|
||||
* npx vitest run src/api/__tests__/live-stalwart-send-options.test.ts
|
||||
*
|
||||
* Mail goes to the user's own address; everything it creates is removed.
|
||||
* Verified against Stalwart 0.16.27 and 1.0.1 (2026-10-31).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
|
||||
import { JMAPClient } from '../jmap-client';
|
||||
import { cancelScheduledSend, destroyEmails, getMailboxes, listScheduledEmails, rescheduleScheduledSend, sendEmail } from '../email';
|
||||
import { getIdentities } from '../identity';
|
||||
import { supportsSubmissionExtension } from '../../lib/submission-options';
|
||||
|
||||
const SERVER = process.env.BULWARK_LIVE_JMAP;
|
||||
const USER = process.env.BULWARK_LIVE_USER ?? 'e2e-perf@example.org';
|
||||
const PASS = process.env.BULWARK_LIVE_PASS ?? '';
|
||||
const live = SERVER && PASS ? describe : describe.skip;
|
||||
|
||||
const RUN_STARTED = Date.now();
|
||||
const RUN = RUN_STARTED.toString(36);
|
||||
|
||||
let singleton: JMAPClient;
|
||||
|
||||
live('live Stalwart: composer send options', () => {
|
||||
let drafts = '';
|
||||
let sent = '';
|
||||
const tag = `bulwark-sendopts-${RUN}`;
|
||||
const createdEmails: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import('../jmap-client');
|
||||
singleton = mod.jmapClient as unknown as JMAPClient;
|
||||
const client = new JMAPClient();
|
||||
await client.connect(SERVER!, USER, PASS);
|
||||
singleton.restoreSnapshot(client.snapshot());
|
||||
const boxes = await getMailboxes();
|
||||
drafts = boxes.find((b) => b.role === 'drafts')?.id ?? '';
|
||||
sent = boxes.find((b) => b.role === 'sent')?.id ?? '';
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
const held = await listScheduledEmails().catch(() => []);
|
||||
for (const s of held.filter((h) => createdEmails.includes(h.emailId))) {
|
||||
await cancelScheduledSend(s.emailSubmissionId).catch(() => undefined);
|
||||
}
|
||||
// Not a subject search: Stalwart answers that from a full-text index
|
||||
// that lags. Every message since the run began is read and compared.
|
||||
const res = await singleton.request([
|
||||
['Email/query', { accountId: singleton.accountId, filter: { after: new Date(RUN_STARTED - 60_000).toISOString() }, limit: 500 }, '0'],
|
||||
['Email/get', {
|
||||
accountId: singleton.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['id', 'subject'],
|
||||
}, '1'],
|
||||
]);
|
||||
const matching = (res.methodResponses[1][1].list as Array<{ id: string; subject?: string }> ?? [])
|
||||
.filter((e) => e.subject?.includes(tag)).map((e) => e.id);
|
||||
const leftovers = [...new Set([...(matching), ...createdEmails])];
|
||||
if (leftovers.length) await destroyEmails(leftovers).catch(() => undefined);
|
||||
}, 60_000);
|
||||
|
||||
it('lists DSN and REQUIRETLS in the submission extensions', () => {
|
||||
expect(supportsSubmissionExtension('DSN')).toBe(true);
|
||||
expect(supportsSubmissionExtension('REQUIRETLS')).toBe(true);
|
||||
});
|
||||
|
||||
it('files the priority and read-receipt headers with the sent message', async () => {
|
||||
const [identity] = await getIdentities();
|
||||
const result = await sendEmail(
|
||||
{
|
||||
from: [{ email: identity.email }], to: [{ email: identity.email }],
|
||||
subject: `priority ${tag}`, textBody: 'live', priority: 'high', requestReadReceipt: true,
|
||||
},
|
||||
identity.id, sent, undefined, { draftsMailboxId: drafts, requestDsn: true },
|
||||
);
|
||||
createdEmails.push(result.emailId!);
|
||||
expect(result.emailSubmissionId).toBeTruthy();
|
||||
const res = await singleton.request([['Email/get', {
|
||||
accountId: singleton.accountId,
|
||||
ids: [result.emailId],
|
||||
properties: ['mailboxIds', 'header:X-Priority:asText', 'header:Importance:asText', 'header:Disposition-Notification-To:asText'],
|
||||
}, '0']]);
|
||||
const [email] = res.methodResponses[0][1].list;
|
||||
expect(email.mailboxIds[sent]).toBe(true);
|
||||
expect(email['header:X-Priority:asText']?.trim()).toBe('1 (Highest)');
|
||||
expect(email['header:Importance:asText']?.trim()).toBe('high');
|
||||
expect(email['header:Disposition-Notification-To:asText']?.trim()).toBe(identity.email);
|
||||
}, 60_000);
|
||||
|
||||
it('submits with RET/NOTIFY and REQUIRETLS on the envelope', async () => {
|
||||
const [identity] = await getIdentities();
|
||||
// Held, so the submission record (and its envelope) can be read back.
|
||||
const result = await sendEmail(
|
||||
{ from: [{ email: identity.email }], to: [{ email: identity.email }], subject: `dsn ${tag}`, textBody: 'live' },
|
||||
identity.id, sent, 3600, { draftsMailboxId: drafts, requestDsn: true, requireTls: true },
|
||||
);
|
||||
createdEmails.push(result.emailId!);
|
||||
const res = await singleton.request([['EmailSubmission/get', {
|
||||
accountId: singleton.accountId, ids: [result.emailSubmissionId], properties: ['envelope', 'undoStatus'],
|
||||
}, '0']], ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:submission']);
|
||||
const [submission] = res.methodResponses[0][1].list;
|
||||
expect(submission.undoStatus).toBe('pending');
|
||||
expect(submission.envelope.mailFrom.email).toBe(identity.email);
|
||||
expect(submission.envelope.mailFrom.parameters).toMatchObject({ RET: 'HDRS', HOLDFOR: '3600' });
|
||||
expect('REQUIRETLS' in submission.envelope.mailFrom.parameters).toBe(true);
|
||||
expect(submission.envelope.rcptTo).toEqual([
|
||||
expect.objectContaining({ email: identity.email, parameters: expect.objectContaining({ NOTIFY: 'SUCCESS,FAILURE,DELAY' }) }),
|
||||
]);
|
||||
|
||||
// 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);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Live checks of the offline send queue's rule against sending twice: a
|
||||
* message is found by its Message-ID, a copy that went out stops a resend,
|
||||
* and the autosaved draft alone does not.
|
||||
*
|
||||
* Opt-in like live-stalwart.test.ts: skipped unless `BULWARK_LIVE_JMAP` and
|
||||
* `BULWARK_LIVE_PASS` are set.
|
||||
*
|
||||
* BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \
|
||||
* BULWARK_LIVE_USER=e2e-perf@example.org BULWARK_LIVE_PASS=… \
|
||||
* npx vitest run src/api/__tests__/live-stalwart-send-queue.test.ts
|
||||
*
|
||||
* Mail goes to the user's own address; everything it creates is removed.
|
||||
* Verified against Stalwart 0.16.27 and 1.0.1 (2026-10-31).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
|
||||
import { JMAPClient } from '../jmap-client';
|
||||
import { destroyEmails, getMailboxes, saveDraftVersion, sendEmail } from '../email';
|
||||
import { getIdentities } from '../identity';
|
||||
import { findCopiesByMessageId, runQueuedSend } from '../queued-send';
|
||||
import { generateMessageId } from '../../lib/email-threading';
|
||||
import { newQueuedSend } from '../../lib/send-queue';
|
||||
import type { Identity, Mailbox } from '../types';
|
||||
|
||||
const SERVER = process.env.BULWARK_LIVE_JMAP;
|
||||
const USER = process.env.BULWARK_LIVE_USER ?? 'e2e-perf@example.org';
|
||||
const PASS = process.env.BULWARK_LIVE_PASS ?? '';
|
||||
const live = SERVER && PASS ? describe : describe.skip;
|
||||
|
||||
const RUN_STARTED = Date.now();
|
||||
const RUN = RUN_STARTED.toString(36);
|
||||
|
||||
let singleton: JMAPClient;
|
||||
|
||||
live('live Stalwart: offline send queue', () => {
|
||||
let drafts = '';
|
||||
let sent = '';
|
||||
let boxes: Mailbox[] = [];
|
||||
let identity: Identity;
|
||||
const tag = `bulwark-sendqueue-${RUN}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import('../jmap-client');
|
||||
singleton = mod.jmapClient as unknown as JMAPClient;
|
||||
const client = new JMAPClient();
|
||||
await client.connect(SERVER!, USER, PASS);
|
||||
singleton.restoreSnapshot(client.snapshot());
|
||||
boxes = await getMailboxes();
|
||||
drafts = boxes.find((b) => b.role === 'drafts')?.id ?? '';
|
||||
sent = boxes.find((b) => b.role === 'sent')?.id ?? '';
|
||||
[identity] = await getIdentities();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
// Not a subject search: Stalwart answers that from a full-text index
|
||||
// that lags. Every message since the run began is read and compared.
|
||||
const res = await singleton.request([
|
||||
['Email/query', { accountId: singleton.accountId, filter: { after: new Date(RUN_STARTED - 60_000).toISOString() }, limit: 500 }, '0'],
|
||||
['Email/get', {
|
||||
accountId: singleton.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['id', 'subject'],
|
||||
}, '1'],
|
||||
]);
|
||||
const matching = (res.methodResponses[1][1].list as Array<{ id: string; subject?: string }> ?? [])
|
||||
.filter((e) => e.subject?.includes(tag)).map((e) => e.id);
|
||||
const leftovers = matching;
|
||||
if (leftovers.length) await destroyEmails(leftovers).catch(() => undefined);
|
||||
}, 60_000);
|
||||
|
||||
const message = (subject: string) => ({
|
||||
from: [{ email: identity.email }],
|
||||
to: [{ email: identity.email }],
|
||||
subject: `${subject} ${tag}`,
|
||||
textBody: 'live',
|
||||
messageId: generateMessageId(identity.email),
|
||||
});
|
||||
|
||||
const started = Date.now();
|
||||
const lookup = () => ({ mailboxIds: [sent, drafts], since: started });
|
||||
|
||||
const payloadFor = (outgoing: ReturnType<typeof message>, draftId?: string) => ({
|
||||
outgoing, identity, fromEmail: identity.email, sentMailboxId: sent, draftsMailboxId: drafts, draftId,
|
||||
});
|
||||
|
||||
it('finds a message by its Message-ID, and only that one', async () => {
|
||||
const outgoing = message('lookup');
|
||||
const result = await sendEmail(outgoing, identity.id, sent, undefined, { draftsMailboxId: drafts });
|
||||
const copies = await findCopiesByMessageId(outgoing.messageId, lookup());
|
||||
expect(copies.map((c) => c.id)).toEqual([result.emailId]);
|
||||
expect(copies[0].keywords?.$draft).toBeFalsy();
|
||||
expect(await findCopiesByMessageId(generateMessageId(identity.email), lookup())).toEqual([]);
|
||||
}, 60_000);
|
||||
|
||||
it('does not send again a message an unanswered attempt already sent', async () => {
|
||||
const outgoing = message('sent-once');
|
||||
const first = await sendEmail(outgoing, identity.id, sent, undefined, { draftsMailboxId: drafts });
|
||||
// The answer to that send was lost: the queued entry is still marked.
|
||||
const outcome = await runQueuedSend(newQueuedSend('q1', payloadFor(outgoing), Date.now(), true), boxes);
|
||||
expect(outcome.kind).toBe('alreadySent');
|
||||
const copies = await findCopiesByMessageId(outgoing.messageId, lookup());
|
||||
expect(copies.map((c) => c.id)).toEqual([first.emailId]);
|
||||
}, 60_000);
|
||||
|
||||
it('sends a marked message whose only copy is its draft, and removes the draft', async () => {
|
||||
const outgoing = message('draft-only');
|
||||
const draft = await saveDraftVersion(outgoing, drafts);
|
||||
const outcome = await runQueuedSend(newQueuedSend('q2', payloadFor(outgoing, draft.id), Date.now(), true), boxes);
|
||||
expect(outcome.kind).toBe('sent');
|
||||
const copies = await findCopiesByMessageId(outgoing.messageId, lookup());
|
||||
expect(copies).toHaveLength(1);
|
||||
expect(copies[0].id).not.toBe(draft.id);
|
||||
expect(copies[0].keywords?.$draft).toBeFalsy();
|
||||
const res = await singleton.request([['Email/get', { accountId: singleton.accountId, ids: [copies[0].id], properties: ['mailboxIds'] }, '0']]);
|
||||
expect(res.methodResponses[0][1].list[0].mailboxIds[sent]).toBe(true);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Sending from the offline queue: a message an unanswered attempt may have
|
||||
// sent is looked up by its Message-ID first and not sent again when a copy
|
||||
// went out.
|
||||
|
||||
vi.mock('../jmap-client', () => ({
|
||||
jmapClient: { accountId: 'acc-1', request: vi.fn(), getAccountCapability: vi.fn(() => undefined) },
|
||||
}));
|
||||
const sendEmail = vi.fn(async (..._a: any[]) => ({ scheduled: false, emailId: 'e-new', emailSubmissionId: 's-1' }));
|
||||
const destroyEmails = vi.fn(async (..._a: any[]) => undefined);
|
||||
vi.mock('../email', () => ({
|
||||
sendEmail: (...a: any[]) => sendEmail(...a),
|
||||
destroyEmails: (...a: any[]) => destroyEmails(...a),
|
||||
}));
|
||||
vi.mock('../../lib/compose-send-account', () => ({
|
||||
sendTargetFor: vi.fn(async (identity: { id: string }) => ({ identityId: identity.id, sentMailboxId: 'sent', draftsMailboxId: 'drafts' })),
|
||||
}));
|
||||
|
||||
import { jmapClient } from '../jmap-client';
|
||||
import { findCopiesByMessageId, runQueuedSend } from '../queued-send';
|
||||
import { newQueuedSend, type QueuedSendPayload } from '../../lib/send-queue';
|
||||
|
||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||
|
||||
const PAYLOAD = {
|
||||
outgoing: { from: [{ email: 'me@x.de' }], to: [{ email: 'you@x.de' }], subject: 'Hi', textBody: 'x', messageId: 'm1@x.de' },
|
||||
identity: { id: 'id-1', email: 'me@x.de', name: 'Me' },
|
||||
fromEmail: 'me@x.de',
|
||||
sentMailboxId: 'sent',
|
||||
draftsMailboxId: 'drafts',
|
||||
draftId: 'draft-1',
|
||||
requestDsn: true,
|
||||
} as unknown as QueuedSendPayload;
|
||||
|
||||
const found = (list: Array<{ id: string; keywords?: Record<string, boolean>; messageId?: string[] }>) => ({
|
||||
methodResponses: [
|
||||
['Email/query', { ids: list.map((e) => e.id) }, '0'],
|
||||
['Email/get', { list }, '1'],
|
||||
],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('runQueuedSend', () => {
|
||||
it('sends a message no attempt reached the server with, without looking first', async () => {
|
||||
const outcome = await runQueuedSend(newQueuedSend('q', PAYLOAD, 0, false), []);
|
||||
expect(outcome.kind).toBe('sent');
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
expect(sendEmail).toHaveBeenCalledWith(
|
||||
PAYLOAD.outgoing, 'id-1', 'sent', undefined,
|
||||
// DSN only where the account lists it; this one lists nothing.
|
||||
expect.objectContaining({ draftsMailboxId: 'drafts', draftId: 'draft-1', requestDsn: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not send again a message whose earlier attempt went out, and removes its old draft', async () => {
|
||||
mockRequest.mockResolvedValueOnce(found([
|
||||
{ id: 'draft-1', keywords: { $draft: true }, messageId: ['m1@x.de'] },
|
||||
{ id: 'sent-1', keywords: { $seen: true }, messageId: ['<m1@x.de>'] },
|
||||
]));
|
||||
const outcome = await runQueuedSend(newQueuedSend('q', PAYLOAD, 0, true), []);
|
||||
expect(outcome.kind).toBe('alreadySent');
|
||||
expect(sendEmail).not.toHaveBeenCalled();
|
||||
expect(destroyEmails).toHaveBeenCalledWith(['draft-1'], 'acc-1');
|
||||
});
|
||||
|
||||
it('removes a copy a refused attempt left behind, then sends', async () => {
|
||||
mockRequest.mockResolvedValueOnce(found([
|
||||
{ id: 'draft-1', keywords: { $draft: true }, messageId: ['m1@x.de'] },
|
||||
{ id: 'left-1', keywords: { $draft: true, $seen: true }, messageId: ['m1@x.de'] },
|
||||
]));
|
||||
const outcome = await runQueuedSend(newQueuedSend('q', PAYLOAD, 0, true), []);
|
||||
expect(outcome.kind).toBe('sent');
|
||||
expect(destroyEmails).toHaveBeenCalledWith(['left-1'], 'acc-1');
|
||||
expect(sendEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('holds a scheduled message for the time that is left', async () => {
|
||||
const now = Date.parse('2026-10-31T12:00:00Z');
|
||||
await runQueuedSend(newQueuedSend('q', { ...PAYLOAD, scheduledAt: '2026-10-31T13:00:00Z' }, now, false), [], now);
|
||||
expect(sendEmail.mock.calls[0][3]).toBe(3600);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findCopiesByMessageId', () => {
|
||||
it('reads the recent messages of Sent and Drafts and keeps exact matches only', async () => {
|
||||
mockRequest.mockResolvedValueOnce(found([
|
||||
{ id: 'a', keywords: {}, messageId: ['m1@x.de'] },
|
||||
{ id: 'b', keywords: {}, messageId: ['other-m1@x.de'] },
|
||||
{ id: 'c', keywords: {}, messageId: [] },
|
||||
]));
|
||||
const copies = await findCopiesByMessageId('<m1@x.de>', { mailboxIds: ['sent', 'drafts'], since: Date.parse('2026-10-31T12:00:00Z') });
|
||||
expect(copies.map((c) => c.id)).toEqual(['a']);
|
||||
const [query] = mockRequest.mock.calls[0][0];
|
||||
// Not the header filter: Stalwart answers that from a lagging index.
|
||||
expect(query[1].filter).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [
|
||||
{ after: '2026-10-31T11:50:00.000Z' },
|
||||
{ operator: 'OR', conditions: [{ inMailbox: 'sent' }, { inMailbox: 'drafts' }] },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
+59
-9
@@ -21,6 +21,8 @@ import { withInlineBodyParts } from '../lib/email-inline-parts';
|
||||
import { stalwartStateWentBack } from './stalwart-state';
|
||||
import { isTransientNetworkError } from '../lib/network-error';
|
||||
import { LIST_AUTH_RESULTS_PROPERTY } from '../lib/email-headers';
|
||||
import { messagePriorityHeaders, type MessagePriority } from '../lib/message-priority';
|
||||
import { submissionEnvelopeParameters } from '../lib/submission-options';
|
||||
|
||||
const EMAIL_SUMMARY_PROPERTIES = [
|
||||
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
|
||||
@@ -1581,6 +1583,8 @@ export interface OutgoingEmail {
|
||||
// Pre-assigned Message-ID (e.g. when re-sending a draft); generated when absent.
|
||||
messageId?: string;
|
||||
requestReadReceipt?: boolean;
|
||||
/** Importance / X-Priority headers (webmail #970); normal writes none. */
|
||||
priority?: MessagePriority;
|
||||
/**
|
||||
* SMTP MAIL FROM when it must differ from the header From - a catch-all
|
||||
* alias on an owned domain is sent through the identity's envelope while
|
||||
@@ -1691,6 +1695,9 @@ function buildEmailCreate(email: OutgoingEmail): Record<string, unknown> {
|
||||
emailCreate['header:Disposition-Notification-To:asText'] = from[0].email;
|
||||
}
|
||||
|
||||
// Drafts carry it too, so a re-opened draft reads its priority back.
|
||||
Object.assign(emailCreate, messagePriorityHeaders(email.priority));
|
||||
|
||||
return emailCreate;
|
||||
}
|
||||
|
||||
@@ -1740,6 +1747,13 @@ export interface SendEmailOptions {
|
||||
draftAccountId?: string;
|
||||
/** Submitting account (shared/group account); defaults to the primary. */
|
||||
accountId?: string;
|
||||
/**
|
||||
* Ask for delivery status notifications (RFC 3461 RET/NOTIFY); only when
|
||||
* the account lists the DSN submission extension.
|
||||
*/
|
||||
requestDsn?: boolean;
|
||||
/** Refuse delivery over an unencrypted hop (RFC 8689 REQUIRETLS). */
|
||||
requireTls?: boolean;
|
||||
}
|
||||
|
||||
export async function sendEmail(
|
||||
@@ -1766,18 +1780,26 @@ export async function sendEmail(
|
||||
const submissionCreate: Record<string, unknown> = { emailId: '#draft', identityId };
|
||||
// For a deferred send the envelope must be set explicitly so the HOLDFOR
|
||||
// mail-from parameter rides along (JMAP §7.3: an omitted envelope makes the
|
||||
// server derive mailFrom from the Identity, dropping our parameter). An
|
||||
// explicit envelope sender (catch-all From override) needs it as well.
|
||||
// server derive mailFrom from the Identity, dropping our parameter). The
|
||||
// DSN and REQUIRETLS parameters ride the same way (webmail
|
||||
// submissionEnvelopeParameters), and an explicit envelope sender
|
||||
// (catch-all From override) needs it as well.
|
||||
const holdFor = holdForSeconds && holdForSeconds > 0 ? Math.ceil(holdForSeconds) : 0;
|
||||
if (holdFor > 0 || email.envelopeMailFrom) {
|
||||
const parameters = submissionEnvelopeParameters(
|
||||
{ requestDsn: opts?.requestDsn, requireTls: opts?.requireTls },
|
||||
holdFor,
|
||||
);
|
||||
const hasMailFromParams = Object.keys(parameters.mailFrom).length > 0;
|
||||
const hasRcptToParams = Object.keys(parameters.rcptTo).length > 0;
|
||||
if (hasMailFromParams || hasRcptToParams || email.envelopeMailFrom) {
|
||||
const rcptTo = [...email.to, ...(email.cc ?? []), ...(email.bcc ?? [])]
|
||||
.map((r) => r.email.trim())
|
||||
.filter(Boolean)
|
||||
.map((address) => ({ email: address }));
|
||||
.map((address) => (hasRcptToParams ? { email: address, parameters: parameters.rcptTo } : { email: address }));
|
||||
const mailFrom: Record<string, unknown> = {
|
||||
email: email.envelopeMailFrom || email.from[0]?.email,
|
||||
};
|
||||
if (holdFor > 0) mailFrom.parameters = { HOLDFOR: String(holdFor) };
|
||||
if (hasMailFromParams) mailFrom.parameters = parameters.mailFrom;
|
||||
submissionCreate.envelope = { mailFrom, rcptTo };
|
||||
}
|
||||
|
||||
@@ -2267,6 +2289,22 @@ export class RescheduleLostError extends Error {
|
||||
* and send time, and the recipients the server refused for the replacement
|
||||
* 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(
|
||||
scheduled: {
|
||||
emailSubmissionId: string;
|
||||
@@ -2299,8 +2337,9 @@ export async function rescheduleScheduledSend(
|
||||
],
|
||||
[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<{
|
||||
envelope?: { mailFrom?: { email?: string }; rcptTo?: Array<{ email?: string }> } | null;
|
||||
envelope?: { mailFrom?: EnvelopeAddress; rcptTo?: EnvelopeAddress[] } | null;
|
||||
sendAt?: string;
|
||||
identityId?: string;
|
||||
}> | undefined)?.[0];
|
||||
@@ -2313,7 +2352,15 @@ export async function rescheduleScheduledSend(
|
||||
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 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;
|
||||
// Without an answer the replacement is made without filing, and a message
|
||||
// that can't be put back stays where it is.
|
||||
@@ -2344,9 +2391,12 @@ export async function rescheduleScheduledSend(
|
||||
emailId: scheduled.emailId,
|
||||
identityId,
|
||||
envelope: {
|
||||
mailFrom: seconds === undefined
|
||||
mailFrom: seconds === undefined && !keptMailFromParams
|
||||
? { email: mailFrom }
|
||||
: { email: mailFrom, parameters: { HOLDFOR: String(seconds) } },
|
||||
: {
|
||||
email: mailFrom,
|
||||
parameters: { ...keptMailFromParams, ...(seconds === undefined ? {} : { HOLDFOR: String(seconds) }) },
|
||||
},
|
||||
rcptTo,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// Sending a message from the offline send queue (see lib/send-queue.ts for
|
||||
// the state machine and the rule against sending twice).
|
||||
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { destroyEmails, sendEmail, type SendEmailResult } from './email';
|
||||
import { requireMethodResult } from './jmap-result';
|
||||
import type { Mailbox } from './types';
|
||||
import { sendTargetFor } from '../lib/compose-send-account';
|
||||
import { supportsSubmissionExtension } from '../lib/submission-options';
|
||||
import { stripMessageIdBrackets } from '../lib/email-threading';
|
||||
import { holdForScheduled, verdictFromCopies, type FoundCopy, type QueuedSend } from '../lib/send-queue';
|
||||
|
||||
/** How far before the queueing a copy can be dated (the server's clock may differ). */
|
||||
const CLOCK_SKEW_MS = 10 * 60_000;
|
||||
/** Messages filed in Sent and Drafts since the queueing that are compared. */
|
||||
const LOOKUP_LIMIT = 200;
|
||||
|
||||
/**
|
||||
* The messages in the given folders of `accountId` whose Message-ID is
|
||||
* `messageId`, filed since `since` (ms). Not a header search: Stalwart
|
||||
* answers `{ header: ['Message-ID', …] }` from its full-text index, which
|
||||
* lags behind (0.16.27 and 1.0.1 found nothing seconds after the send), so
|
||||
* the folders' recent messages are read and compared instead.
|
||||
*/
|
||||
export async function findCopiesByMessageId(
|
||||
messageId: string,
|
||||
opts: { accountId?: string; mailboxIds: string[]; since: number },
|
||||
): Promise<FoundCopy[]> {
|
||||
const id = stripMessageIdBrackets(messageId);
|
||||
const mailboxIds = opts.mailboxIds.filter(Boolean);
|
||||
if (!id || mailboxIds.length === 0) return [];
|
||||
const account = opts.accountId ?? jmapClient.accountId;
|
||||
const inFolders = mailboxIds.length === 1
|
||||
? { inMailbox: mailboxIds[0] }
|
||||
: { operator: 'OR', conditions: mailboxIds.map((m) => ({ inMailbox: m })) };
|
||||
const filter = {
|
||||
operator: 'AND',
|
||||
conditions: [{ after: new Date(opts.since - CLOCK_SKEW_MS).toISOString() }, inFolders],
|
||||
};
|
||||
const res = await jmapClient.request([
|
||||
['Email/query', {
|
||||
accountId: account, filter, sort: [{ property: 'receivedAt', isAscending: false }], limit: LOOKUP_LIMIT,
|
||||
}, '0'],
|
||||
['Email/get', {
|
||||
accountId: account,
|
||||
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
|
||||
properties: ['id', 'keywords', 'messageId'],
|
||||
}, '1'],
|
||||
]);
|
||||
requireMethodResult(res, '0', 'Email/query');
|
||||
const list = (requireMethodResult(res, '1', 'Email/get').list ?? []) as Array<{
|
||||
id: string; keywords?: Record<string, boolean>; messageId?: string[] | null;
|
||||
}>;
|
||||
return list
|
||||
.filter((e) => (e.messageId ?? []).some((m) => stripMessageIdBrackets(m) === id))
|
||||
.map((e) => ({ id: e.id, keywords: e.keywords ?? {} }));
|
||||
}
|
||||
|
||||
export type QueuedSendOutcome =
|
||||
| { kind: 'sent'; result: SendEmailResult; accountId?: string }
|
||||
/** An earlier attempt went out; nothing was sent now. */
|
||||
| { kind: 'alreadySent'; accountId?: string };
|
||||
|
||||
/**
|
||||
* Send a queued message. The account, identity and folders are worked out
|
||||
* as the composer does (sendTargetFor, #1090). A message an earlier attempt
|
||||
* may have sent is looked up first, and is not sent again when a copy of it
|
||||
* went out; copies a refused attempt left in Drafts are removed before the
|
||||
* new attempt.
|
||||
*/
|
||||
export async function runQueuedSend(send: QueuedSend, mailboxes: Mailbox[], now = Date.now()): Promise<QueuedSendOutcome> {
|
||||
const p = send.payload;
|
||||
const target = await sendTargetFor(p.identity, p.fromEmail, mailboxes);
|
||||
const accountId = target.accountId ?? jmapClient.accountId;
|
||||
const draftAccountId = p.draftAccountId ?? jmapClient.accountId;
|
||||
|
||||
const sentMailboxId = target.sentMailboxId ?? p.sentMailboxId;
|
||||
const draftsMailboxId = target.accountId ? target.draftsMailboxId : p.draftsMailboxId;
|
||||
|
||||
if (send.maybeSent && p.outgoing.messageId) {
|
||||
const copies = await findCopiesByMessageId(p.outgoing.messageId, {
|
||||
accountId: target.accountId,
|
||||
mailboxIds: [sentMailboxId, draftsMailboxId ?? ''],
|
||||
since: send.createdAt,
|
||||
});
|
||||
// The autosaved draft lives in this account only when the draft did.
|
||||
const draftHere = p.draftId && draftAccountId === accountId ? p.draftId : undefined;
|
||||
const verdict = verdictFromCopies(copies, draftHere);
|
||||
if (verdict.kind === 'sent') {
|
||||
// The send went out but its answer was lost, so the old draft it would
|
||||
// have removed is still there.
|
||||
if (p.draftId && p.draftId !== verdict.copyId) {
|
||||
await destroyEmails([p.draftId], draftAccountId).catch((err) => {
|
||||
console.warn('[send-queue] could not remove the draft of a sent message', err);
|
||||
});
|
||||
}
|
||||
return { kind: 'alreadySent', accountId: target.accountId };
|
||||
}
|
||||
if (verdict.staleIds.length > 0) {
|
||||
await destroyEmails(verdict.staleIds, accountId);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await sendEmail(
|
||||
p.outgoing,
|
||||
target.identityId,
|
||||
sentMailboxId,
|
||||
holdForScheduled(p.scheduledAt, now),
|
||||
{
|
||||
draftsMailboxId,
|
||||
draftId: p.draftId,
|
||||
draftAccountId: p.draftId ? p.draftAccountId : undefined,
|
||||
accountId: target.accountId,
|
||||
// Only what the submitting account offers, as the composer does.
|
||||
requestDsn: !!p.requestDsn && supportsSubmissionExtension('DSN', target.accountId),
|
||||
requireTls: !!p.requireTls && supportsSubmissionExtension('REQUIRETLS', target.accountId),
|
||||
},
|
||||
);
|
||||
return { kind: 'sent', result, accountId: target.accountId };
|
||||
}
|
||||
Reference in New Issue
Block a user