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 = {}) { 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][] }; describe('JMAP client extensions', () => { let fetchSpy: ReturnType; let client: JMAPClient; let requests: RequestBody[]; async function setup(accountCapabilities: Record = {}) { 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: 'Invoice', preview: 'your invoice 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: 'Invoice', preview: 'your invoice 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: 'Invoice', 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: 'Invoice', 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)['urn:ietf:params:jmap:principals'] = {}; (session.capabilities as Record)['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)['urn:ietf:params:jmap:principals'] = {}; (session.capabilities as Record)['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 }).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; 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)[EMAIL_PUSH] = {}; (session.accounts as Record).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(); }); });