Files
webmail/lib/__tests__/jmap-send-threading.test.ts
T
Linus Rath 8a83cae6d9 fix: restore undone sends from group identities in the group account
Mail sent from a group or shared identity (team@example.org) is created
in that account, together with its submission, on Stalwart 0.16 and 1.0
alike. Undo send and "Cancel and edit" cancelled the submission there,
but then put the message back in Drafts, read it back and later removed
it through the user's own account. Email ids repeat across accounts - on
the test servers a new team message and a message in userb's inbox were
both `iaaaaac` - so an unrelated own message was moved to Drafts, opened
in the composer and destroyed on Discard, while the undone message stayed
in the group's Sent.

The account the message was sent from now travels with it:
- `restoreEmailToDraft` takes the account, the store looks up that
  account's Drafts by role, and the restored draft is read from there
- the composer keeps the draft's account (`draftAccountId`); saving,
  sending and discarding remove the old version in that account, and the
  first save moves the draft to the own Drafts, where drafts are saved
- discarding goes through the login the draft was saved with, as the
  send does (#461)
- "Edit draft" on a message in a shared or group folder, in the standard
  and the Pro views, opens it with that folder's account, so a draft left
  in the group's Drafts is not removed by id in the own account either
- S/MIME copies, and the unused `cancelScheduledEmail`, are deleted in
  the submission's account
2026-09-26 00:03:23 +02:00

375 lines
14 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
function createClient(): JMAPClient {
const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
Object.assign(client, {
apiUrl: 'https://jmap.example.com/api',
accountId: 'account-1',
username: 'user@example.com',
});
return client;
}
function enableDelayedSend(client: JMAPClient) {
Object.assign(client, {
capabilities: {
'urn:ietf:params:jmap:core': {},
'urn:ietf:params:jmap:mail': {},
'urn:ietf:params:jmap:submission': {},
},
session: {
primaryAccounts: {
'urn:ietf:params:jmap:mail': 'account-1',
'urn:ietf:params:jmap:submission': 'submission-account-1',
},
accounts: {
'account-1': {
accountCapabilities: {
'urn:ietf:params:jmap:mail': {},
},
},
'submission-account-1': {
accountCapabilities: {
'urn:ietf:params:jmap:submission': { maxDelayedSend: 3600, submissionExtensions: { FUTURERELEASE: true } },
},
},
},
},
});
}
interface JMAPMethodCall {
0: string;
1: Record<string, unknown>;
2: string;
}
interface CapturedRequest {
using?: string[];
methodCalls: JMAPMethodCall[];
}
/**
* Mock fetch to script three sequential JMAP requests sendEmail makes:
* Mailbox/get → Identity/get → Email/set + EmailSubmission/set.
* Returns the captured request bodies for assertions.
*/
function mockSendEmailFlow(draftsId = 'mb-drafts', sentId = 'mb-sent') {
const captured: CapturedRequest[] = [];
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockImplementation(async (_url, init) => {
const body = JSON.parse((init as { body: string }).body) as CapturedRequest;
captured.push(body);
const callIdx = captured.length - 1;
let payload: unknown;
if (callIdx === 0) {
payload = {
methodResponses: [[
'Mailbox/get',
{
list: [
{ id: draftsId, name: 'Drafts', role: 'drafts' },
{ id: sentId, name: 'Sent', role: 'sent' },
],
},
'0',
]],
};
} else if (callIdx === 1) {
payload = {
methodResponses: [[
'Identity/get',
{ list: [{ id: 'identity-1', email: 'user@example.com', mayDelete: false }] },
'0',
]],
};
} else {
payload = {
methodResponses: [
['Email/set', { created: { [Object.keys((captured[callIdx].methodCalls[0][1] as { create: Record<string, unknown> }).create)[0]]: { id: 'sent-id-1' } } }, '0'],
['EmailSubmission/set', { created: { '1': { id: 'sub-1', sendAt: '2026-05-08T18:00:00Z' } } }, '1'],
],
};
}
return {
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify(payload)),
json: () => Promise.resolve(payload),
} as Response;
});
return captured;
}
describe('JMAPClient.sendEmail threading headers', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('writes inReplyTo and references on the Email/set create when supplied', async () => {
const client = createClient();
const captured = mockSendEmailFlow();
await client.sendEmail(
['recipient@example.com'],
'Re: testmail',
'reply body',
undefined, undefined, 'identity-1', 'user@example.com',
undefined, undefined, undefined, undefined,
['<parent@example.com>'],
['<root@example.com>', '<parent@example.com>'],
);
// Third request is the Email/set + EmailSubmission/set batch.
const setCall = captured[2].methodCalls[0];
expect(setCall[0]).toBe('Email/set');
const create = setCall[1].create as Record<string, Record<string, unknown>>;
const draft = Object.values(create)[0];
// Bare msg-ids per RFC 8621 - angle brackets stripped.
expect(draft.inReplyTo).toEqual(['parent@example.com']);
expect(draft.references).toEqual(['root@example.com', 'parent@example.com']);
});
it('omits threading fields when no parent ids are supplied', async () => {
const client = createClient();
const captured = mockSendEmailFlow();
await client.sendEmail(
['recipient@example.com'],
'Fresh thread',
'body',
undefined, undefined, 'identity-1', 'user@example.com',
);
const setCall = captured[2].methodCalls[0];
const create = setCall[1].create as Record<string, Record<string, unknown>>;
const draft = Object.values(create)[0];
expect(draft.inReplyTo).toBeUndefined();
expect(draft.references).toBeUndefined();
});
it('omits cc/bcc when arrays are empty so the server does not emit a bare Cc: header', async () => {
const client = createClient();
const captured = mockSendEmailFlow();
await client.sendEmail(
['recipient@example.com'],
'No copies',
'body',
[], [], 'identity-1', 'user@example.com',
);
const setCall = captured[2].methodCalls[0];
const create = setCall[1].create as Record<string, Record<string, unknown>>;
const draft = Object.values(create)[0];
expect(draft.cc).toBeUndefined();
expect(draft.bcc).toBeUndefined();
});
it('drops empty / whitespace-only ids rather than sending blank entries', async () => {
const client = createClient();
const captured = mockSendEmailFlow();
await client.sendEmail(
['recipient@example.com'],
'Re: testmail',
'body',
undefined, undefined, 'identity-1', 'user@example.com',
undefined, undefined, undefined, undefined,
['<>', ' ', '<real@example.com>'],
[],
);
const setCall = captured[2].methodCalls[0];
const create = setCall[1].create as Record<string, Record<string, unknown>>;
const draft = Object.values(create)[0];
expect(draft.inReplyTo).toEqual(['real@example.com']);
expect(draft.references).toBeUndefined();
});
it('uses FUTURERELEASE envelope and submission capability for scheduled sends', async () => {
const client = createClient();
enableDelayedSend(client);
const captured = mockSendEmailFlow();
const delayedUntil = new Date(Date.now() + 60_000).toISOString();
const result = await client.sendEmail(
['recipient@example.com'],
'Scheduled test',
'body',
undefined, undefined, 'identity-1', 'user@example.com',
undefined, undefined, undefined, undefined,
undefined,
undefined,
delayedUntil,
);
const identityRequest = captured[1];
expect(identityRequest.using).toContain('urn:ietf:params:jmap:submission');
const submissionCall = captured[2].methodCalls.find(call => call[0] === 'EmailSubmission/set');
expect(submissionCall?.[1].accountId).toBe('submission-account-1');
expect(submissionCall?.[1].create).toEqual({
'1': {
emailId: expect.stringMatching(/^#send-/),
identityId: 'identity-1',
envelope: {
mailFrom: {
email: 'user@example.com',
parameters: { HOLDFOR: expect.stringMatching(/^\d+$/) },
},
rcptTo: [{ email: 'recipient@example.com' }],
},
},
});
expect(JSON.stringify(submissionCall?.[1].create)).not.toContain('sendAt');
expect(result).toMatchObject({ scheduled: true, emailSubmissionId: 'sub-1', sendAt: '2026-05-08T18:00:00Z' });
});
it('cleans up replacement submission if canceling the original fails during reschedule', async () => {
const client = createClient();
enableDelayedSend(client);
vi.spyOn(client, 'getMailboxes').mockResolvedValue([
{ id: 'mb-drafts', name: 'Drafts', role: 'drafts' },
{ id: 'mb-sent', name: 'Sent', role: 'sent' },
] as never);
vi.spyOn(client, 'getIdentities').mockResolvedValue([
{ id: 'identity-1', name: 'User', email: 'user@example.com', mayDelete: false },
]);
const requestSpy = vi.spyOn(client as unknown as { request: JMAPClient['request'] }, 'request')
.mockImplementation(async (methodCalls) => {
const args = methodCalls[0][1] as { create?: unknown; update?: Record<string, unknown> };
if (args.create) {
return { methodResponses: [['EmailSubmission/set', { created: { replacement: { id: 'sub-new' } } }, '0']] };
}
if (args.update?.['sub-old']) {
return { methodResponses: [['EmailSubmission/set', { notUpdated: { 'sub-old': { type: 'cannotUnsend' } } }, '0']] };
}
return { methodResponses: [['EmailSubmission/set', { updated: { 'sub-new': null } }, '0']] };
});
await expect(client.rescheduleEmailSubmission('sub-old', 'email-1', 'identity-1', new Date(Date.now() + 60_000).toISOString()))
.rejects.toThrow('could not cancel the original');
expect(requestSpy).toHaveBeenCalledWith(expect.arrayContaining([
expect.arrayContaining(['EmailSubmission/set', expect.objectContaining({ update: { 'sub-new': { undoStatus: 'canceled' } } })]),
]));
// The canceled replacement's record goes too, in a request after the
// cancel; the original is still pending and keeps its record.
const calls = requestSpy.mock.calls.map(([methodCalls]) => methodCalls[0][1] as { update?: object; destroy?: string[] });
const cancelIndex = calls.findIndex((args) => args.update && 'sub-new' in args.update);
const destroys = calls.map((args, index) => ({ index, ids: args.destroy })).filter((call) => call.ids);
expect(destroys).toEqual([{ index: expect.any(Number), ids: ['sub-new'] }]);
expect(destroys[0].index).toBeGreaterThan(cancelIndex);
});
});
describe('JMAPClient post-send mailbox filing', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
function sentFilingPatch(captured: CapturedRequest[]): Record<string, unknown> {
const submissionCall = captured[2].methodCalls.find(call => call[0] === 'EmailSubmission/set');
expect(submissionCall).toBeDefined();
const onSuccess = (submissionCall![1] as {
onSuccessUpdateEmail: Record<string, Record<string, unknown>>;
}).onSuccessUpdateEmail;
return Object.values(onSuccess)[0];
}
it('files the sent message via a full mailboxIds replacement, never mailboxIds/<id> pointers', async () => {
const client = createClient();
const captured = mockSendEmailFlow();
await client.sendEmail(
['recipient@example.com'], 'subject', 'body',
undefined, undefined, 'identity-1', 'user@example.com',
);
const patch = sentFilingPatch(captured);
// A `mailboxIds/<id>` JSON-pointer whose token is purely numeric (e.g. a
// Drafts folder whose JMAP id is "0") is rejected by Stalwart, silently
// stranding already-delivered mail in Drafts. The move must use a full
// `mailboxIds` replacement, which has no per-id pointer token.
expect(Object.keys(patch).some(key => key.startsWith('mailboxIds/'))).toBe(false);
expect(patch.mailboxIds).toEqual({ 'mb-sent': true });
expect(patch['keywords/$draft']).toBeNull();
});
it('files correctly when the Drafts mailbox id is a purely numeric string (Stalwart numeric-id bug)', async () => {
const client = createClient();
// Drafts id "0", Sent id "e": the old pointer form emitted `mailboxIds/0`,
// which Stalwart rejects with invalidProperties "Invalid patch value".
const captured = mockSendEmailFlow('0', 'e');
await client.sendEmail(
['recipient@example.com'], 'subject', 'body',
undefined, undefined, 'identity-1', 'user@example.com',
);
const patch = sentFilingPatch(captured);
expect(Object.keys(patch).some(key => key.startsWith('mailboxIds/'))).toBe(false);
expect(patch.mailboxIds).toEqual({ e: true });
});
it('restoreEmailToDraft places the message in Drafts only via a full mailboxIds replacement', async () => {
const client = createClient();
let capturedUpdate: Record<string, unknown> | undefined;
vi.spyOn(client as unknown as { request: JMAPClient['request'] }, 'request')
.mockImplementation(async (methodCalls) => {
const args = methodCalls[0][1] as { update?: Record<string, Record<string, unknown>> };
capturedUpdate = args.update?.['email-1'];
return { methodResponses: [['Email/set', { updated: { 'email-1': null } }, '0']] };
});
// Third arg (Sent mailbox id) is intentionally ignored — the message must
// end up in Drafts only, with no leftover Sent membership. Drafts id "0"
// also exercises the numeric-id path in the reverse direction.
await client.restoreEmailToDraft('email-1', '0', 'e');
expect(capturedUpdate).toBeDefined();
expect(Object.keys(capturedUpdate!).some(key => key.startsWith('mailboxIds/'))).toBe(false);
expect(capturedUpdate!.mailboxIds).toEqual({ '0': true });
expect(capturedUpdate!['keywords/$draft']).toBe(true);
});
// Mail sent from a group identity lives in the group's account (Stalwart 1.0
// and 0.16.23 alike), and ids repeat across accounts: restoring it in the own
// account moved an unrelated message there into Drafts.
it('restoreEmailToDraft updates the message in the account it was sent from', async () => {
const client = createClient();
const calls: Array<Record<string, unknown>> = [];
vi.spyOn(client as unknown as { request: JMAPClient['request'] }, 'request')
.mockImplementation(async (methodCalls) => {
calls.push(methodCalls[0][1] as Record<string, unknown>);
return { methodResponses: [['Email/set', { updated: { eaaaaab: null } }, '0']] };
});
await client.restoreEmailToDraft('eaaaaab', 'd', 'e', 'c');
await client.restoreEmailToDraft('eaaaaab', 'd', 'e');
expect(calls.map(call => call.accountId)).toEqual(['c', 'account-1']);
expect(calls[0].update).toEqual({ eaaaaab: { mailboxIds: { d: true }, 'keywords/$draft': true, 'keywords/$seen': true } });
});
it('restoreEmailToDraft fails when the server refuses the call or the message', async () => {
const client = createClient();
const request = vi.spyOn(client as unknown as { request: JMAPClient['request'] }, 'request');
// The whole call refused, e.g. access to the group account taken away.
request.mockResolvedValueOnce({ methodResponses: [['error', { type: 'forbidden', description: 'You do not have access to account c' }, '0']] });
request.mockResolvedValueOnce({ methodResponses: [['Email/set', { notUpdated: { eaaaaab: { type: 'notFound' } } }, '0']] });
await expect(client.restoreEmailToDraft('eaaaaab', 'd', 'e', 'c')).rejects.toMatchObject({ jmapType: 'forbidden' });
await expect(client.restoreEmailToDraft('eaaaaab', 'd', 'e', 'c')).rejects.toMatchObject({ jmapType: 'notFound', jmapSetError: true });
});
});