mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
807 lines
36 KiB
TypeScript
807 lines
36 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { JMAPClient, submissionEnvelopeParameters } from '../jmap/client';
|
|
import { isJmapSetError, jmapErrorType } from '../jmap/jmap-error';
|
|
import { stalwartExactState, stalwartIntermediateState } from './helpers/stalwart-state';
|
|
|
|
describe('submissionEnvelopeParameters', () => {
|
|
it('is empty without options', () => {
|
|
expect(submissionEnvelopeParameters(undefined)).toEqual({ mailFrom: {}, rcptTo: {} });
|
|
expect(submissionEnvelopeParameters({ requestReadReceipt: true })).toEqual({ mailFrom: {}, rcptTo: {} });
|
|
});
|
|
|
|
it('maps DSN, REQUIRETLS and FUTURERELEASE to envelope parameters', () => {
|
|
expect(submissionEnvelopeParameters({ requestDsn: true, requireTls: true }, 60)).toEqual({
|
|
mailFrom: { HOLDFOR: '60', REQUIRETLS: null, RET: 'HDRS' },
|
|
rcptTo: { NOTIFY: 'SUCCESS,FAILURE,DELAY' },
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Request shapes of the JMAP features layered on the core client: delta sync
|
|
* (Email/changes, Mailbox/changes), search snippets (SearchSnippet/get) and
|
|
* folder sharing (mail:share). Each asserts what goes over the wire and how
|
|
* the response is mapped back.
|
|
*/
|
|
|
|
function makeSession(accountCapabilities: Record<string, unknown> = {}) {
|
|
return {
|
|
capabilities: { 'urn:ietf:params:jmap:core': {} },
|
|
accounts: {
|
|
'acct-1': {
|
|
name: 'test',
|
|
isPersonal: true,
|
|
accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, ...accountCapabilities },
|
|
},
|
|
},
|
|
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1' },
|
|
apiUrl: 'https://mail.example.com/jmap/api',
|
|
downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
|
|
uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
|
|
eventSourceUrl: '',
|
|
};
|
|
}
|
|
|
|
function jsonResponse(body: unknown): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
type RequestBody = { using: string[]; methodCalls: [string, Record<string, unknown>, string][] };
|
|
|
|
describe('JMAP client extensions', () => {
|
|
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
|
let client: JMAPClient;
|
|
let requests: RequestBody[];
|
|
|
|
async function setup(accountCapabilities: Record<string, unknown> = {}) {
|
|
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
fetchSpy.mockResolvedValueOnce(jsonResponse(makeSession(accountCapabilities)));
|
|
client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123');
|
|
await client.connect();
|
|
fetchSpy.mockReset();
|
|
requests = [];
|
|
}
|
|
|
|
function answer(handler: (body: RequestBody) => unknown) {
|
|
fetchSpy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => {
|
|
const body = JSON.parse(String(init?.body ?? '{}')) as RequestBody;
|
|
requests.push(body);
|
|
return jsonResponse(handler(body));
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
client?.disconnect();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('delta sync', () => {
|
|
it('maps Email/changes and passes sinceState / maxChanges', async () => {
|
|
await setup();
|
|
answer(() => ({
|
|
methodResponses: [[
|
|
'Email/changes',
|
|
{ accountId: 'acct-1', oldState: 's1', newState: 's2', hasMoreChanges: false, created: ['a'], updated: ['b'], destroyed: ['c'] },
|
|
'0',
|
|
]],
|
|
}));
|
|
|
|
const delta = await client.getEmailChanges('s1', undefined, 200);
|
|
|
|
expect(requests[0].methodCalls[0]).toEqual(['Email/changes', { accountId: 'acct-1', sinceState: 's1', maxChanges: 200 }, '0']);
|
|
expect(delta).toEqual({
|
|
oldState: 's1', newState: 's2', hasMoreChanges: false,
|
|
created: ['a'], updated: ['b'], destroyed: ['c'], updatedProperties: null,
|
|
});
|
|
});
|
|
|
|
it('returns null when the server cannot calculate the delta', async () => {
|
|
await setup();
|
|
answer(() => ({ methodResponses: [['error', { type: 'cannotCalculateChanges' }, '0']] }));
|
|
expect(await client.getMailboxChanges('old')).toBeNull();
|
|
});
|
|
|
|
it('surfaces updatedProperties from Mailbox/changes', async () => {
|
|
await setup();
|
|
answer(() => ({
|
|
methodResponses: [[
|
|
'Mailbox/changes',
|
|
{ oldState: 'm1', newState: 'm2', hasMoreChanges: false, created: [], updated: ['inbox'], destroyed: [], updatedProperties: ['totalEmails', 'unreadEmails'] },
|
|
'0',
|
|
]],
|
|
}));
|
|
const delta = await client.getMailboxChanges('m1');
|
|
expect(delta?.updatedProperties).toEqual(['totalEmails', 'unreadEmails']);
|
|
});
|
|
|
|
it('reports the Mailbox state per account from getAllMailboxesWithState', async () => {
|
|
await setup();
|
|
answer(() => ({
|
|
methodResponses: [['Mailbox/get', { accountId: 'acct-1', state: 'm7', list: [{ id: 'inbox', name: 'Inbox', role: 'inbox' }] }, '0']],
|
|
}));
|
|
const { mailboxes, states } = await client.getAllMailboxesWithState();
|
|
expect(mailboxes.map((m) => m.id)).toEqual(['inbox']);
|
|
expect(states).toEqual({ 'acct-1': 'm7' });
|
|
});
|
|
|
|
it('reports the Email state a page was read at', async () => {
|
|
await setup();
|
|
answer(() => ({
|
|
methodResponses: [
|
|
['Email/query', { ids: ['e1'], total: 1 }, '0'],
|
|
['Email/get', { state: 'e9', list: [{ id: 'e1', threadId: 't1', receivedAt: '2026-08-29T00:00:00Z', mailboxIds: { inbox: true }, keywords: {} }] }, '1'],
|
|
],
|
|
}));
|
|
const page = await client.getEmails('inbox');
|
|
expect(page.state).toBe('e9');
|
|
expect(page.emails).toHaveLength(1);
|
|
});
|
|
|
|
// Stalwart lists urn:stalwart:jmap on every account (0.16.6 through 1.0).
|
|
const STALWART = { 'urn:stalwart:jmap': {} };
|
|
const changes = (type: string, oldState: string, newState: string) => ({
|
|
methodResponses: [[
|
|
`${type}/changes`,
|
|
{ accountId: 'acct-1', oldState, newState, hasMoreChanges: false, created: [], updated: [], destroyed: [] },
|
|
'0',
|
|
]],
|
|
});
|
|
|
|
it.each(['Email', 'Mailbox'])(
|
|
'falls back to a full fetch when Stalwart answers %s/changes with a lower state (upgrade with restarted counters)',
|
|
async (type) => {
|
|
await setup(STALWART);
|
|
// The old change log survived with its ids, the counter restarted
|
|
// lower: Stalwart accepts the old state and reports nothing since.
|
|
const since = stalwartExactState(12_345_678);
|
|
answer(() => changes(type, since, stalwartExactState(16_384)));
|
|
|
|
const delta = type === 'Email' ? await client.getEmailChanges(since) : await client.getMailboxChanges(since);
|
|
|
|
expect(delta).toBeNull();
|
|
},
|
|
);
|
|
|
|
it('trips on the states Stalwart 0.16.23 and 1.0 actually return', async () => {
|
|
await setup(STALWART);
|
|
// `sba` (8) from 0.16.23 answered with `sau` (5) from 1.0.
|
|
answer(() => changes('Email', 'sba', 'sau'));
|
|
expect(await client.getEmailChanges('sba')).toBeNull();
|
|
|
|
answer(() => changes('Email', 'sau', 'sba'));
|
|
expect(await client.getEmailChanges('sau')).toMatchObject({ oldState: 'sau', newState: 'sba' });
|
|
});
|
|
|
|
it('never trips while Stalwart states go up or stay put', async () => {
|
|
await setup(STALWART);
|
|
const big = (n: number | string) => BigInt(n);
|
|
// Every LEB128 byte boundary, and ids past 2^53.
|
|
const ids = [
|
|
big(0), big(1), big(126), big(127), big(128), big(129), big(16_383), big(16_384),
|
|
big(2_097_151), big(2_097_152), big(2) ** big(32) - big(1), big(2) ** big(32),
|
|
big(2) ** big(53), big(2) ** big(53) + big(1), big(2) ** big(64) - big(2), big(2) ** big(64) - big(1),
|
|
];
|
|
for (let i = 0; i < ids.length; i++) {
|
|
for (const next of [ids[i], ids[i + 1]]) {
|
|
if (next === undefined) continue;
|
|
const since = stalwartExactState(ids[i]);
|
|
const newState = stalwartExactState(next);
|
|
answer(() => changes('Email', since, newState));
|
|
expect(await client.getEmailChanges(since)).toMatchObject({ oldState: since, newState });
|
|
}
|
|
}
|
|
});
|
|
|
|
it('leaves states in any other format alone', async () => {
|
|
await setup(STALWART);
|
|
// An intermediate state (a delta cut short by maxChanges) on either side.
|
|
const intermediate = stalwartIntermediateState(1024, 2048, 100);
|
|
answer(() => changes('Email', stalwartExactState(5000), intermediate));
|
|
expect(await client.getEmailChanges(stalwartExactState(5000))).toMatchObject({ newState: intermediate });
|
|
|
|
answer(() => changes('Email', intermediate, stalwartExactState(1)));
|
|
expect(await client.getEmailChanges(intermediate)).toMatchObject({ newState: stalwartExactState(1) });
|
|
});
|
|
|
|
it('does not read states of servers other than Stalwart', async () => {
|
|
await setup();
|
|
// Same strings, but the session doesn't list urn:stalwart:jmap.
|
|
answer(() => changes('Email', 'sba', 'sau'));
|
|
expect(await client.getEmailChanges('sba')).toMatchObject({ newState: 'sau' });
|
|
});
|
|
});
|
|
|
|
describe('search snippets', () => {
|
|
it('chains SearchSnippet/get onto a text search and attaches the hits', async () => {
|
|
await setup();
|
|
answer(() => ({
|
|
methodResponses: [
|
|
['Email/query', { ids: ['e1', 'e2'], total: 2 }, '0'],
|
|
['Email/get', { list: [
|
|
{ id: 'e1', threadId: 't1', receivedAt: '2026-08-29T00:00:00Z', subject: 'Invoice', mailboxIds: {}, keywords: {} },
|
|
{ id: 'e2', threadId: 't2', receivedAt: '2026-08-28T00:00:00Z', subject: 'Other', mailboxIds: {}, keywords: {} },
|
|
] }, '1'],
|
|
['SearchSnippet/get', { list: [
|
|
{ emailId: 'e1', subject: '<mark>Invoice</mark>', preview: 'your <mark>invoice</mark> is attached' },
|
|
{ emailId: 'e2', subject: null, preview: null },
|
|
] }, 'snippets'],
|
|
],
|
|
}));
|
|
|
|
const result = await client.searchEmails('invoice', 'inbox');
|
|
|
|
const snippetCall = requests[0].methodCalls[2];
|
|
expect(snippetCall[0]).toBe('SearchSnippet/get');
|
|
expect(snippetCall[1]).toEqual({
|
|
accountId: 'acct-1',
|
|
// A plain query goes to the snippets as it went to Email/query.
|
|
filter: { operator: 'AND', conditions: [{ inMailbox: 'inbox' }, { text: 'invoice' }] },
|
|
'#emailIds': { resultOf: '0', name: 'Email/query', path: '/ids' },
|
|
});
|
|
expect(result.emails[0].searchSnippet).toEqual({ subject: '<mark>Invoice</mark>', preview: 'your <mark>invoice</mark> is attached' });
|
|
expect(result.emails[1].searchSnippet).toBeUndefined();
|
|
});
|
|
|
|
it('sends quoted phrases as conditions of their own and unquotes them for the snippets', async () => {
|
|
await setup();
|
|
answer(() => ({
|
|
methodResponses: [
|
|
['Email/query', { ids: [], total: 0 }, '0'],
|
|
['Email/get', { list: [] }, '1'],
|
|
['SearchSnippet/get', { list: [] }, 'snippets'],
|
|
],
|
|
}));
|
|
|
|
await client.searchEmails('"annual report" 2024 "draft"', 'inbox');
|
|
const [query, , snippets] = requests[0].methodCalls;
|
|
// One joined value would be one phrase on Stalwart 1.0.
|
|
expect(query[1].filter).toEqual({
|
|
operator: 'AND',
|
|
conditions: [{ inMailbox: 'inbox' }, { text: '"annual report"' }, { text: '2024' }, { text: '"draft"' }],
|
|
});
|
|
// A quoted term switches the whole snippet request to exact mode.
|
|
expect(snippets[1].filter).toEqual({
|
|
operator: 'AND',
|
|
conditions: [{ inMailbox: 'inbox' }, { text: 'annual report' }, { text: '2024' }, { text: 'draft' }],
|
|
});
|
|
|
|
await client.searchEmails('project budg*');
|
|
expect(requests[1].methodCalls[0][1].filter).toEqual({
|
|
operator: 'AND',
|
|
conditions: [{ text: 'project' }, { text: 'budg*' }],
|
|
});
|
|
});
|
|
|
|
it('sends a no-op instead of SearchSnippet/get for a structural filter', async () => {
|
|
await setup();
|
|
answer(() => ({
|
|
methodResponses: [
|
|
['Email/query', { ids: [], total: 0 }, '0'],
|
|
['Email/get', { list: [] }, '1'],
|
|
['Core/echo', {}, 'snippets'],
|
|
],
|
|
}));
|
|
await client.advancedSearchEmails({ operator: 'AND', conditions: [{ inMailbox: 'inbox' }, { hasKeyword: '$flagged' }] });
|
|
expect(requests[0].methodCalls[2][0]).toBe('Core/echo');
|
|
});
|
|
|
|
it('keeps the results when only the snippet call fails', async () => {
|
|
await setup();
|
|
answer(() => ({
|
|
methodResponses: [
|
|
['Email/query', { ids: ['e1'], total: 1 }, '0'],
|
|
['Email/get', { list: [{ id: 'e1', threadId: 't1', receivedAt: '2026-08-29T00:00:00Z', mailboxIds: {}, keywords: {} }] }, '1'],
|
|
['error', { type: 'requestTooLarge' }, 'snippets'],
|
|
],
|
|
}));
|
|
const result = await client.searchEmails('hello');
|
|
expect(result.emails).toHaveLength(1);
|
|
expect(result.emails[0].searchSnippet).toBeUndefined();
|
|
});
|
|
|
|
describe('deferred', () => {
|
|
const hits = [
|
|
{ id: 'e1', threadId: 't1', receivedAt: '2026-08-29T00:00:00Z', subject: 'Invoice', mailboxIds: {}, keywords: {} },
|
|
{ id: 'e2', threadId: 't2', receivedAt: '2026-08-28T00:00:00Z', subject: 'Other', mailboxIds: {}, keywords: {} },
|
|
];
|
|
const searchOrSnippets = (snippets: unknown) => (body: RequestBody) => body.methodCalls[0][0] === 'SearchSnippet/get'
|
|
? { methodResponses: [snippets] }
|
|
: { methodResponses: [['Email/query', { ids: ['e1', 'e2'], total: 2 }, '0'], ['Email/get', { list: hits.map((hit) => ({ ...hit })) }, '1']] };
|
|
|
|
it('lists the results without waiting for the snippets', async () => {
|
|
await setup();
|
|
answer(searchOrSnippets(['SearchSnippet/get', { list: [{ emailId: 'e1', subject: '<mark>Invoice</mark>', preview: null }] }, 'snippets']));
|
|
|
|
const result = await client.searchEmails('invoice', 'inbox', undefined, 50, 0, { deferSnippets: true });
|
|
|
|
// The search itself no longer carries the snippet call ...
|
|
expect(requests[0].methodCalls.map((call) => call[0])).toEqual(['Email/query', 'Email/get']);
|
|
expect(result.emails.map((email) => email.id)).toEqual(['e1', 'e2']);
|
|
expect(result.emails[0].searchSnippet).toBeUndefined();
|
|
// ... it follows on its own, for exactly the listed messages.
|
|
expect(await result.snippets).toEqual([{ emailId: 'e1', subject: '<mark>Invoice</mark>', preview: null }]);
|
|
expect(requests[1].methodCalls).toEqual([['SearchSnippet/get', {
|
|
accountId: 'acct-1',
|
|
filter: { operator: 'AND', conditions: [{ inMailbox: 'inbox' }, { text: 'invoice' }] },
|
|
emailIds: ['e1', 'e2'],
|
|
}, 'snippets']]);
|
|
});
|
|
|
|
it('asks for no snippets when nothing could be highlighted', async () => {
|
|
await setup();
|
|
answer(searchOrSnippets(['SearchSnippet/get', { list: [] }, 'snippets']));
|
|
|
|
const structural = await client.advancedSearchEmails({ hasKeyword: '$flagged' }, undefined, 50, 0, { deferSnippets: true });
|
|
expect(structural.snippets).toBeUndefined();
|
|
|
|
answer(() => ({ methodResponses: [['Email/query', { ids: [], total: 0 }, '0'], ['Email/get', { list: [] }, '1']] }));
|
|
const empty = await client.searchEmails('nothing', undefined, undefined, 50, 0, { deferSnippets: true });
|
|
expect(empty.snippets).toBeUndefined();
|
|
expect(requests.every((body) => body.methodCalls[0][0] !== 'SearchSnippet/get')).toBe(true);
|
|
});
|
|
|
|
it('resolves to nothing instead of rejecting when the snippet request fails', async () => {
|
|
await setup();
|
|
answer(searchOrSnippets(['error', { type: 'requestTooLarge' }, 'snippets']));
|
|
|
|
const result = await client.searchEmails('invoice', undefined, undefined, 50, 0, { deferSnippets: true });
|
|
expect(result.emails).toHaveLength(2);
|
|
await expect(result.snippets).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('deleteMailbox', () => {
|
|
it('only sends onDestroyRemoveEmails when asked to remove the messages', async () => {
|
|
await setup();
|
|
answer(() => ({ methodResponses: [['Mailbox/set', { destroyed: ['f1'] }, '0']] }));
|
|
|
|
await client.deleteMailbox('f1');
|
|
expect(requests[0].methodCalls[0]).toEqual(['Mailbox/set', { accountId: 'acct-1', destroy: ['f1'] }, '0']);
|
|
|
|
await client.deleteMailbox('f1', undefined, { removeEmails: true });
|
|
expect(requests[1].methodCalls[0]).toEqual(['Mailbox/set', { accountId: 'acct-1', destroy: ['f1'], onDestroyRemoveEmails: true }, '0']);
|
|
});
|
|
|
|
it('exposes the server error type for a non-empty folder', async () => {
|
|
await setup();
|
|
answer(() => ({ methodResponses: [['Mailbox/set', { notDestroyed: { f1: { type: 'mailboxHasEmail', description: 'not empty' } } }, '0']] }));
|
|
await expect(client.deleteMailbox('f1')).rejects.toMatchObject({ message: 'not empty', jmapType: 'mailboxHasEmail' });
|
|
});
|
|
});
|
|
|
|
describe('calendar event notifications', () => {
|
|
it('lists notifications with the event title and destroys acknowledged ones', async () => {
|
|
await setup({ 'urn:ietf:params:jmap:calendars': {} });
|
|
answer((body) => {
|
|
const [method] = body.methodCalls[0];
|
|
if (method === 'CalendarEventNotification/query') {
|
|
return { methodResponses: [
|
|
['CalendarEventNotification/query', { ids: ['n1'] }, '0'],
|
|
['CalendarEventNotification/get', { list: [
|
|
{ id: 'n1', created: '2026-08-29T10:00:00Z', type: 'created', changedBy: { name: 'Alice', email: 'alice@example.com' }, calendarEventId: 'e1', isDraft: false, event: { title: 'Sync' } },
|
|
] }, '1'],
|
|
] };
|
|
}
|
|
return { methodResponses: [['CalendarEventNotification/set', { destroyed: ['n1'] }, '0']] };
|
|
});
|
|
|
|
const list = await client.getCalendarEventNotifications();
|
|
expect(requests[0].methodCalls[1][1]).toMatchObject({ properties: expect.arrayContaining(['event', 'type', 'changedBy']) });
|
|
expect(list).toHaveLength(1);
|
|
expect(list[0].event?.title).toBe('Sync');
|
|
|
|
await client.destroyCalendarEventNotifications(['n1']);
|
|
expect(requests[1].methodCalls[0]).toEqual(['CalendarEventNotification/set', { accountId: 'acct-1', destroy: ['n1'] }, '0']);
|
|
});
|
|
});
|
|
|
|
describe('participant identities', () => {
|
|
it('maps ParticipantIdentity/get and sets the default via onSuccessSetIsDefault', async () => {
|
|
await setup({ 'urn:ietf:params:jmap:calendars': {} });
|
|
answer((body) => {
|
|
const [method] = body.methodCalls[0];
|
|
if (method === 'ParticipantIdentity/get') {
|
|
return { methodResponses: [['ParticipantIdentity/get', { list: [
|
|
{ id: 'i1', name: 'Me', calendarAddress: 'mailto:me@example.com', isDefault: true },
|
|
{ id: 'i2', name: '', calendarAddress: 'mailto:alias@example.com', isDefault: false },
|
|
] }, '0']] };
|
|
}
|
|
return { methodResponses: [['ParticipantIdentity/set', { updated: null }, '0']] };
|
|
});
|
|
|
|
const identities = await client.getParticipantIdentities();
|
|
expect(requests[0].using).toContain('urn:ietf:params:jmap:calendars');
|
|
expect(identities).toEqual([
|
|
{ id: 'i1', name: 'Me', calendarAddress: 'mailto:me@example.com', isDefault: true },
|
|
{ id: 'i2', name: '', calendarAddress: 'mailto:alias@example.com', isDefault: false },
|
|
]);
|
|
|
|
await client.setDefaultParticipantIdentity('i2');
|
|
expect(requests[1].methodCalls[0]).toEqual(['ParticipantIdentity/set', { accountId: 'acct-1', onSuccessSetIsDefault: 'i2' }, '0']);
|
|
});
|
|
});
|
|
|
|
describe('free/busy', () => {
|
|
it('needs both the principals and the availability capability', async () => {
|
|
await setup();
|
|
expect(client.supportsAvailability()).toBe(false);
|
|
expect(await client.getPrincipalAvailability('p1', new Date(), new Date())).toEqual([]);
|
|
});
|
|
|
|
it('queries Principal/getAvailability without event details', async () => {
|
|
client?.disconnect();
|
|
vi.restoreAllMocks();
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
const session = makeSession();
|
|
(session.capabilities as Record<string, unknown>)['urn:ietf:params:jmap:principals'] = {};
|
|
(session.capabilities as Record<string, unknown>)['urn:ietf:params:jmap:principals:availability'] = {};
|
|
fetchSpy.mockResolvedValueOnce(jsonResponse(session));
|
|
client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123');
|
|
await client.connect();
|
|
fetchSpy.mockReset();
|
|
requests = [];
|
|
answer(() => ({
|
|
methodResponses: [['Principal/getAvailability', { list: [
|
|
{ utcStart: '2026-09-01T10:00:00Z', utcEnd: '2026-09-01T11:00:00Z', busyStatus: 'tentative' },
|
|
{ utcStart: '2026-09-01T12:00:00Z', utcEnd: '2026-09-01T13:00:00Z' },
|
|
] }, '0']],
|
|
}));
|
|
|
|
const periods = await client.getPrincipalAvailability('p1', new Date('2026-09-01T09:00:00.000Z'), new Date('2026-09-01T17:00:00.000Z'));
|
|
|
|
expect(requests[0].using).toEqual(expect.arrayContaining(['urn:ietf:params:jmap:principals', 'urn:ietf:params:jmap:principals:availability']));
|
|
expect(requests[0].methodCalls[0]).toEqual(['Principal/getAvailability', {
|
|
accountId: 'acct-1', id: 'p1', utcStart: '2026-09-01T09:00:00Z', utcEnd: '2026-09-01T17:00:00Z', showDetails: false,
|
|
}, '0']);
|
|
expect(periods).toEqual([
|
|
{ utcStart: '2026-09-01T10:00:00Z', utcEnd: '2026-09-01T11:00:00Z', busyStatus: 'tentative' },
|
|
{ utcStart: '2026-09-01T12:00:00Z', utcEnd: '2026-09-01T13:00:00Z', busyStatus: null },
|
|
]);
|
|
});
|
|
|
|
async function setupAvailability() {
|
|
client?.disconnect();
|
|
vi.restoreAllMocks();
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
const session = makeSession({ 'urn:ietf:params:jmap:principals': { currentUserPrincipalId: 'p-me' } });
|
|
(session.capabilities as Record<string, unknown>)['urn:ietf:params:jmap:principals'] = {};
|
|
(session.capabilities as Record<string, unknown>)['urn:ietf:params:jmap:principals:availability'] = { maxAvailabilityDuration: 'P365D' };
|
|
fetchSpy.mockResolvedValueOnce(jsonResponse(session));
|
|
client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123');
|
|
await client.connect();
|
|
fetchSpy.mockReset();
|
|
requests = [];
|
|
}
|
|
|
|
it('reads mayGetAvailability from the own principal and is false on Stalwart 1.0 with directory queries off', async () => {
|
|
await setupAvailability();
|
|
answer(() => ({
|
|
methodResponses: [['Principal/get', { accountId: 'acct-1', state: 'n', list: [{
|
|
id: 'p-me',
|
|
capabilities: {
|
|
'urn:ietf:params:jmap:mail': {},
|
|
'urn:ietf:params:jmap:contacts': {},
|
|
'urn:ietf:params:jmap:filenode': {},
|
|
'urn:ietf:params:jmap:principals': {},
|
|
'urn:ietf:params:jmap:calendars': { accountId: 'p-me', mayGetAvailability: false, mayShareWith: false, calendarAddress: 'mailto:user@test.com' },
|
|
},
|
|
}], notFound: [] }, '0']],
|
|
}));
|
|
|
|
expect(await client.mayGetAvailability()).toBe(false);
|
|
// currentUserPrincipalId, not the account id, names the own principal.
|
|
expect(requests[0].methodCalls[0]).toEqual(['Principal/get', { accountId: 'acct-1', ids: ['p-me'], properties: ['id', 'capabilities'] }, '0']);
|
|
});
|
|
|
|
it('goes ahead on Stalwart 0.16, whose principal capabilities are empty objects', async () => {
|
|
await setupAvailability();
|
|
answer(() => ({
|
|
methodResponses: [['Principal/get', { accountId: 'acct-1', state: 'n', list: [{
|
|
id: 'p-me',
|
|
capabilities: {
|
|
'urn:ietf:params:jmap:mail': {},
|
|
'urn:ietf:params:jmap:contacts': {},
|
|
'urn:ietf:params:jmap:calendars': {},
|
|
'urn:ietf:params:jmap:filenode': {},
|
|
'urn:ietf:params:jmap:principals': {},
|
|
},
|
|
}], notFound: [] }, '0']],
|
|
}));
|
|
|
|
expect(await client.mayGetAvailability()).toBe(true);
|
|
});
|
|
|
|
it('says no when the role may not read principals and throws on other failures', async () => {
|
|
await setupAvailability();
|
|
answer(() => ({
|
|
methodResponses: [['error', { type: 'forbidden', description: 'You are not authorized to perform this action' }, '0']],
|
|
}));
|
|
expect(await client.mayGetAvailability()).toBe(false);
|
|
|
|
answer(() => ({
|
|
methodResponses: [['error', { type: 'serverUnavailable', description: 'This server is temporarily unavailable.' }, '0']],
|
|
}));
|
|
await expect(client.mayGetAvailability()).rejects.toMatchObject({ jmapType: 'serverUnavailable' });
|
|
});
|
|
|
|
it('throws getAvailability refusals with their JMAP type', async () => {
|
|
await setupAvailability();
|
|
const window = [new Date('2026-09-01T09:00:00Z'), new Date('2026-09-01T17:00:00Z')] as const;
|
|
|
|
answer(() => ({
|
|
methodResponses: [['error', { type: 'forbidden', description: 'The administrator has disabled directory queries.' }, '0']],
|
|
}));
|
|
await expect(client.getPrincipalAvailability('p1', ...window)).rejects.toMatchObject({
|
|
jmapType: 'forbidden',
|
|
message: 'The administrator has disabled directory queries.',
|
|
});
|
|
|
|
answer(() => ({
|
|
methodResponses: [['error', { type: 'rateLimit', description: 'Too many availability requests, please try again later' }, '0']],
|
|
}));
|
|
await expect(client.getPrincipalAvailability('p1', ...window)).rejects.toMatchObject({ jmapType: 'rateLimit' });
|
|
});
|
|
|
|
it('batches several principals per request up to maxCallsInRequest and keeps each answer apart', async () => {
|
|
await setupAvailability();
|
|
(client as unknown as { capabilities: Record<string, unknown> }).capabilities['urn:ietf:params:jmap:core'] = { maxCallsInRequest: 2 };
|
|
answer((body) => ({
|
|
methodResponses: body.methodCalls.map(([, args, callId]) => (args.id === 'p2'
|
|
// Stalwart 1.0 past 100 calls a minute; the calls before it still answer.
|
|
? ['error', { type: 'rateLimit', description: 'Too many availability requests, please try again later' }, callId]
|
|
: ['Principal/getAvailability', { list: args.id === 'p1'
|
|
? [{ utcStart: '2026-09-01T10:00:00Z', utcEnd: '2026-09-01T11:00:00Z', busyStatus: 'confirmed' }]
|
|
: [] }, callId])),
|
|
}));
|
|
|
|
const answers = await client.getPrincipalsAvailability(['p1', 'p2', 'p3'], new Date('2026-09-01T00:00:00Z'), new Date('2026-09-02T00:00:00Z'));
|
|
|
|
expect(requests.map((r) => r.methodCalls.map(([method, args, callId]) => [method, args.id, callId]))).toEqual([
|
|
[['Principal/getAvailability', 'p1', '0'], ['Principal/getAvailability', 'p2', '1']],
|
|
[['Principal/getAvailability', 'p3', '0']],
|
|
]);
|
|
expect(requests[0].methodCalls[0][1]).toEqual({
|
|
accountId: 'acct-1', id: 'p1', utcStart: '2026-09-01T00:00:00Z', utcEnd: '2026-09-02T00:00:00Z', showDetails: false,
|
|
});
|
|
expect(answers).toEqual({
|
|
p1: { periods: [{ utcStart: '2026-09-01T10:00:00Z', utcEnd: '2026-09-01T11:00:00Z', busyStatus: 'confirmed' }] },
|
|
p2: { error: 'rateLimit' },
|
|
p3: { periods: [] },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('mailbox sharing', () => {
|
|
it('is advertised only with the mail:share capability', async () => {
|
|
await setup();
|
|
expect(client.supportsMailboxSharing()).toBe(false);
|
|
client.disconnect();
|
|
vi.restoreAllMocks();
|
|
await setup({ 'urn:ietf:params:jmap:mail:share': {} });
|
|
expect(client.supportsMailboxSharing()).toBe(true);
|
|
});
|
|
|
|
it('reads shareWith with the extension in `using`', async () => {
|
|
await setup({ 'urn:ietf:params:jmap:mail:share': {} });
|
|
answer(() => ({
|
|
methodResponses: [['Mailbox/get', { list: [{ id: 'f1', shareWith: { p1: { mayReadItems: true } } }] }, '0']],
|
|
}));
|
|
|
|
const shareWith = await client.getMailboxShareWith('f1');
|
|
|
|
expect(requests[0].using).toContain('urn:ietf:params:jmap:mail:share');
|
|
expect(requests[0].methodCalls[0]).toEqual(['Mailbox/get', { accountId: 'acct-1', ids: ['f1'], properties: ['id', 'shareWith'] }, '0']);
|
|
expect(shareWith).toEqual({ p1: { mayReadItems: true } });
|
|
});
|
|
|
|
it('patches shareWith/{principal} on the owner account and checks the outcome', async () => {
|
|
await setup({ 'urn:ietf:params:jmap:mail:share': {} });
|
|
answer(() => ({ methodResponses: [['Mailbox/set', { updated: { f1: null } }, '0']] }));
|
|
|
|
await client.setMailboxShare('f1', 'p1', null, 'owner');
|
|
expect(requests[0].methodCalls[0]).toEqual(['Mailbox/set', { accountId: 'owner', update: { f1: { 'shareWith/p1': null } } }, '0']);
|
|
|
|
answer(() => ({ methodResponses: [['Mailbox/set', { notUpdated: { f1: { type: 'forbidden', description: 'no' } } }, '0']] }));
|
|
await expect(client.setMailboxShare('f1', 'p1', null)).rejects.toThrow('no');
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* PushSubscription/set refusals reach web-push as typed errors: a SetError
|
|
* (jmapSetError, the server refused this subscription) apart from a refused
|
|
* method call (plain jmapError). Bodies are Stalwart's (jmap/src/push/set.rs,
|
|
* identical on 0.16.16 to 0.16.23 and 1.0).
|
|
*/
|
|
describe('JMAP client push subscriptions', () => {
|
|
const EMAIL_PUSH = 'urn:ietf:params:jmap:emailpush';
|
|
const FORBIDDEN_MAP = { type: 'forbidden', description: 'No access to one of the accounts in the emailPush map.' };
|
|
const OVER_QUOTA = {
|
|
type: 'overQuota',
|
|
description: 'There are too many subscriptions, please delete some before adding a new one.',
|
|
};
|
|
const EMAIL_PUSH_MAP = {
|
|
'acct-1': { filter: { operator: 'AND', conditions: [{ notKeyword: '$junk' }] }, properties: ['id', 'threadId'] },
|
|
b: { filter: { operator: 'AND', conditions: [{ notKeyword: '$junk' }] }, properties: ['id', 'threadId'] },
|
|
};
|
|
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
|
let client: JMAPClient;
|
|
let requests: RequestBody[];
|
|
|
|
// 0.16.16 to 0.16.23 and 1.0 advertise emailpush in the session; 0.16.6
|
|
// to 0.16.15 don't. `b` is a mailbox shared by ACL as 1.0 lists it:
|
|
// read-only, without emailpush and the other owner-only capabilities.
|
|
async function setup(withEmailPush: boolean) {
|
|
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
const session = makeSession(withEmailPush ? { [EMAIL_PUSH]: {} } : {});
|
|
if (withEmailPush) (session.capabilities as Record<string, unknown>)[EMAIL_PUSH] = {};
|
|
(session.accounts as Record<string, unknown>).b = {
|
|
name: 'userb',
|
|
isPersonal: false,
|
|
isReadOnly: true,
|
|
accountCapabilities: {
|
|
'urn:ietf:params:jmap:mail': {},
|
|
'urn:ietf:params:jmap:calendars': { mayCreateCalendar: false },
|
|
},
|
|
};
|
|
fetchSpy.mockResolvedValueOnce(jsonResponse(session));
|
|
client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123');
|
|
await client.connect();
|
|
fetchSpy.mockReset();
|
|
requests = [];
|
|
}
|
|
|
|
function answer(handler: (body: RequestBody) => unknown) {
|
|
fetchSpy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => {
|
|
const body = JSON.parse(String(init?.body ?? '{}')) as RequestBody;
|
|
requests.push(body);
|
|
return jsonResponse(handler(body));
|
|
});
|
|
}
|
|
|
|
const newSubscription = {
|
|
deviceClientId: 'dcid',
|
|
url: 'https://relay.example/api/push/jmap/dcid',
|
|
types: ['EmailDelivery'],
|
|
emailPush: EMAIL_PUSH_MAP,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
client?.disconnect();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('throws a forbidden SetError when Stalwart refuses the emailPush map on create (0.16.16+ and 1.0)', async () => {
|
|
await setup(true);
|
|
answer(() => ({ methodResponses: [['PushSubscription/set', { notCreated: { new: FORBIDDEN_MAP } }, '0']] }));
|
|
|
|
const err = await client.createPushSubscription(newSubscription).catch((e) => e);
|
|
|
|
expect(requests[0].using).toEqual(['urn:ietf:params:jmap:core', EMAIL_PUSH]);
|
|
expect(requests[0].methodCalls[0][1]).toEqual({ create: { new: { ...newSubscription } } });
|
|
expect(jmapErrorType(err)).toBe('forbidden');
|
|
expect(isJmapSetError(err)).toBe(true);
|
|
expect(err.message).toContain('No access to one of the accounts in the emailPush map.');
|
|
expect(err.message).not.toContain('{');
|
|
});
|
|
|
|
it('throws an overQuota SetError past the subscription limit', async () => {
|
|
await setup(true);
|
|
answer(() => ({ methodResponses: [['PushSubscription/set', { notCreated: { new: OVER_QUOTA } }, '0']] }));
|
|
|
|
const err = await client.createPushSubscription(newSubscription).catch((e) => e);
|
|
|
|
expect(jmapErrorType(err)).toBe('overQuota');
|
|
expect(isJmapSetError(err)).toBe(true);
|
|
});
|
|
|
|
it('returns the id Stalwart assigns', async () => {
|
|
await setup(true);
|
|
answer(() => ({
|
|
methodResponses: [['PushSubscription/set', {
|
|
created: { new: { id: 'k', keys: null, expires: '2026-10-02T15:00:00Z' } },
|
|
}, '0']],
|
|
}));
|
|
|
|
expect(await client.createPushSubscription(newSubscription)).toBe('k');
|
|
});
|
|
|
|
it('never puts emailpush in `using` or sends the map on 0.16.6 to 0.16.15', async () => {
|
|
await setup(false);
|
|
answer(() => ({ methodResponses: [['PushSubscription/set', { created: { new: { id: 'k' } } }, '0']] }));
|
|
|
|
await client.createPushSubscription(newSubscription);
|
|
|
|
expect(requests[0].using).toEqual(['urn:ietf:params:jmap:core']);
|
|
expect(requests[0].methodCalls[0][1]).toEqual({
|
|
create: { new: { deviceClientId: 'dcid', url: newSubscription.url, types: ['EmailDelivery'] } },
|
|
});
|
|
});
|
|
|
|
it('throws a forbidden SetError when Stalwart refuses the emailPush map on update', async () => {
|
|
await setup(true);
|
|
answer(() => ({ methodResponses: [['PushSubscription/set', { notUpdated: { k: FORBIDDEN_MAP } }, '0']] }));
|
|
|
|
const err = await client
|
|
.updatePushSubscription('k', { expires: '2026-12-01T00:00:00Z', emailPush: EMAIL_PUSH_MAP })
|
|
.catch((e) => e);
|
|
|
|
expect(requests[0].methodCalls[0][1]).toEqual({
|
|
update: { k: { expires: '2026-12-01T00:00:00Z', emailPush: EMAIL_PUSH_MAP } },
|
|
});
|
|
expect(jmapErrorType(err)).toBe('forbidden');
|
|
expect(isJmapSetError(err)).toBe(true);
|
|
});
|
|
|
|
it('throws a notFound SetError for a subscription the server no longer has', async () => {
|
|
await setup(true);
|
|
answer(() => ({ methodResponses: [['PushSubscription/set', { notUpdated: { k: { type: 'notFound' } } }, '0']] }));
|
|
|
|
const err = await client.updatePushSubscription('k', { expires: '2026-12-01T00:00:00Z' }).catch((e) => e);
|
|
|
|
expect(jmapErrorType(err)).toBe('notFound');
|
|
expect(isJmapSetError(err)).toBe(true);
|
|
});
|
|
|
|
it('resolves when the update is applied', async () => {
|
|
await setup(true);
|
|
answer(() => ({ methodResponses: [['PushSubscription/set', { updated: { k: null } }, '0']] }));
|
|
|
|
await expect(client.updatePushSubscription('k', { expires: '2026-12-01T00:00:00Z' })).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('throws a plain error, not a SetError, when the whole method call is refused', async () => {
|
|
await setup(true);
|
|
answer(() => ({ methodResponses: [['error', { type: 'forbidden', description: 'Not allowed' }, '0']] }));
|
|
|
|
const createErr = await client.createPushSubscription(newSubscription).catch((e) => e);
|
|
const updateErr = await client.updatePushSubscription('k', { expires: '2026-12-01T00:00:00Z' }).catch((e) => e);
|
|
const verifyErr = await client.verifyPushSubscription('k', 'code').catch((e) => e);
|
|
|
|
for (const err of [createErr, updateErr, verifyErr]) {
|
|
expect(jmapErrorType(err)).toBe('forbidden');
|
|
expect(isJmapSetError(err)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('throws instead of listing nothing when PushSubscription/get fails', async () => {
|
|
await setup(true);
|
|
answer(() => ({ methodResponses: [['error', { type: 'serverFail' }, '0']] }));
|
|
|
|
const err = await client.listPushSubscriptions().catch((e) => e);
|
|
|
|
expect(err).toBeInstanceOf(Error);
|
|
expect(jmapErrorType(err)).toBe('serverFail');
|
|
});
|
|
|
|
it('describes each session account without flattening its capabilities', async () => {
|
|
await setup(true);
|
|
|
|
expect(client.getAccountInfo('b')).toEqual({
|
|
name: 'userb',
|
|
isPersonal: false,
|
|
isReadOnly: true,
|
|
accountCapabilities: {
|
|
'urn:ietf:params:jmap:mail': {},
|
|
'urn:ietf:params:jmap:calendars': { mayCreateCalendar: false },
|
|
},
|
|
});
|
|
expect(client.getAccountInfo('acct-1')?.isPersonal).toBe(true);
|
|
expect(client.getAccountInfo('nope')).toBeNull();
|
|
});
|
|
});
|