mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
379 lines
15 KiB
TypeScript
379 lines
15 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import { JMAPClient } from '../jmap/client';
|
|
import { batched, batchedBySize, itemsPerRequest } from '../jmap/request-limits';
|
|
|
|
// Stalwart allows 16 method calls and 500 objects per request by default. A
|
|
// batch built from a list the user controls - tags, a multi-select, an import -
|
|
// reaches those ceilings with ordinary use, and going over fails the *whole*
|
|
// request: nine tags used to blank every tag badge in the sidebar.
|
|
|
|
function makeSession(core: Record<string, number> = {}, sharedAccounts = 0) {
|
|
const shared = Object.fromEntries(Array.from({ length: sharedAccounts }, (_, i) => [
|
|
`shared-${i}`, { name: `shared${i}@test.com`, isPersonal: false, accountCapabilities: {} },
|
|
]));
|
|
return {
|
|
capabilities: { 'urn:ietf:params:jmap:core': core },
|
|
accounts: { 'acct-1': { name: 'test', isPersonal: true, accountCapabilities: {} }, ...shared },
|
|
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: 'https://mail.example.com/jmap/eventsource',
|
|
};
|
|
}
|
|
|
|
function jsonResponse(body: unknown): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
/** RFC 8620 §3.6.1: an over-sized request is refused whole, before any method runs. */
|
|
function limitErrorResponse(limit: string): Response {
|
|
return new Response(
|
|
JSON.stringify({ type: 'urn:ietf:params:jmap:error:limit', status: 400, limit }),
|
|
{ status: 400, headers: { 'Content-Type': 'application/json' } },
|
|
);
|
|
}
|
|
|
|
describe('batched', () => {
|
|
it('returns one batch when everything fits', () => {
|
|
expect(batched([1, 2, 3], 5)).toEqual([[1, 2, 3]]);
|
|
});
|
|
|
|
it('splits into consecutive batches of at most `size`', () => {
|
|
expect(batched([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
|
|
});
|
|
|
|
it('returns nothing for an empty list', () => {
|
|
expect(batched([], 10)).toEqual([]);
|
|
});
|
|
|
|
it('never produces an empty batch for a nonsensical size', () => {
|
|
expect(batched([1, 2], 0)).toEqual([[1], [2]]);
|
|
expect(batched([1, 2], -5)).toEqual([[1], [2]]);
|
|
});
|
|
});
|
|
|
|
describe('batchedBySize', () => {
|
|
const size = (n: number) => n;
|
|
|
|
it('fills each batch up to the budget', () => {
|
|
expect(batchedBySize([3, 2, 2, 1, 4], size, 4, 10)).toEqual([[3], [2, 2], [1], [4]]);
|
|
});
|
|
|
|
it('gives an item over the budget a batch of its own', () => {
|
|
expect(batchedBySize([1, 9, 1], size, 4, 10)).toEqual([[1], [9], [1]]);
|
|
});
|
|
|
|
it('counts an item without a size as the whole budget', () => {
|
|
expect(batchedBySize(['a', 'b', 'c'], (item) => (item === 'b' ? undefined : 1), 4, 10)).toEqual([['a'], ['b'], ['c']]);
|
|
});
|
|
|
|
it('never puts more than maxCount items in a batch', () => {
|
|
expect(batchedBySize([1, 1, 1, 1, 1], size, 100, 2)).toEqual([[1, 1], [1, 1], [1]]);
|
|
});
|
|
|
|
it('returns nothing for an empty list', () => {
|
|
expect(batchedBySize([], size, 4, 10)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('itemsPerRequest', () => {
|
|
it('divides the call budget by the cost of one item', () => {
|
|
expect(itemsPerRequest(16, 2)).toBe(8);
|
|
expect(itemsPerRequest(16, 1)).toBe(16);
|
|
expect(itemsPerRequest(50, 3)).toBe(16);
|
|
});
|
|
|
|
it('always allows at least one item, however expensive', () => {
|
|
expect(itemsPerRequest(1, 2)).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('JMAPClient request limits', () => {
|
|
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
fetchSpy.mockRestore();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
async function connectedClient(core?: Record<string, number>, sharedAccounts = 0): Promise<JMAPClient> {
|
|
fetchSpy.mockResolvedValueOnce(jsonResponse(makeSession(core, sharedAccounts)));
|
|
const client = JMAPClient.withBearer('https://mail.example.com', 'token123', 'user@test.com');
|
|
await client.connect();
|
|
fetchSpy.mockReset();
|
|
return client;
|
|
}
|
|
|
|
/** Records the method calls of every request the client makes. */
|
|
function recordRequests(reply: (methodCalls: Array<[string, Record<string, unknown>, string]>) => unknown) {
|
|
const sent: Array<Array<[string, Record<string, unknown>, string]>> = [];
|
|
fetchSpy.mockImplementation((async (_url: string, init: RequestInit) => {
|
|
const body = JSON.parse(init.body as string);
|
|
sent.push(body.methodCalls);
|
|
return jsonResponse(reply(body.methodCalls));
|
|
}) as never);
|
|
return sent;
|
|
}
|
|
|
|
describe('getAllMailboxesWithState', () => {
|
|
// One Mailbox/get request per account, sent one after the other, made the
|
|
// folder tree of a user with 300 shared accounts take ~14 s (#1409).
|
|
it('packs the per-account Mailbox/get calls and runs the requests in parallel', async () => {
|
|
const client = await connectedClient({ maxCallsInRequest: 16, maxConcurrentRequests: 4 }, 40);
|
|
let inFlight = 0;
|
|
let maxInFlight = 0;
|
|
const sent: Array<Array<[string, Record<string, unknown>, string]>> = [];
|
|
fetchSpy.mockImplementation((async (_url: string, init: RequestInit) => {
|
|
const { methodCalls } = JSON.parse(init.body as string) as { methodCalls: Array<[string, Record<string, unknown>, string]> };
|
|
sent.push(methodCalls);
|
|
maxInFlight = Math.max(maxInFlight, ++inFlight);
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
inFlight--;
|
|
return jsonResponse({
|
|
methodResponses: methodCalls.map(([, args, id]) => args.accountId === 'shared-7'
|
|
? ['error', { type: 'forbidden' }, id]
|
|
: ['Mailbox/get', { accountId: args.accountId, state: `s-${args.accountId}`, list: [{ id: 'inbox', name: 'Inbox', role: 'inbox' }] }, id]),
|
|
});
|
|
}) as never);
|
|
|
|
const { mailboxes, states } = await client.getAllMailboxesWithState();
|
|
|
|
expect(sent.map((calls) => calls.length)).toEqual([16, 16, 9]);
|
|
expect(maxInFlight).toBe(2);
|
|
// Session order, without the account whose call failed.
|
|
const expected = ['acct-1', ...Array.from({ length: 40 }, (_, i) => `shared-${i}`)].filter((id) => id !== 'shared-7');
|
|
expect(mailboxes.map((m) => m.accountId)).toEqual(expected);
|
|
expect(mailboxes[0].id).toBe('inbox');
|
|
expect(mailboxes[1].id).toBe('shared-0:inbox');
|
|
expect(Object.keys(states)).toEqual(expect.arrayContaining(expected));
|
|
expect(states['shared-7']).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('getTagCounts', () => {
|
|
// Two Email/query calls per tag: nine tags is 18 calls against a ceiling of 16.
|
|
const tags = Array.from({ length: 9 }, (_, i) => `tag-${i}`);
|
|
|
|
it('splits the tags so no request exceeds maxCallsInRequest', async () => {
|
|
const client = await connectedClient({ maxCallsInRequest: 16 });
|
|
const sent = recordRequests((methodCalls) => ({
|
|
methodResponses: methodCalls.map(([, , callId], i) => [
|
|
'Email/query',
|
|
{ total: i + 1 },
|
|
callId,
|
|
]),
|
|
}));
|
|
|
|
const counts = await client.getTagCounts(tags);
|
|
|
|
expect(sent.map(calls => calls.length)).toEqual([16, 2]);
|
|
expect(Object.keys(counts)).toEqual(tags);
|
|
expect(counts['tag-8']).toEqual({ total: 1, unread: 2 });
|
|
});
|
|
|
|
it('keeps the tags of the batches that did succeed when one is refused', async () => {
|
|
const client = await connectedClient({ maxCallsInRequest: 16 });
|
|
let call = 0;
|
|
fetchSpy.mockImplementation((async (_url: string, init: RequestInit) => {
|
|
const body = JSON.parse(init.body as string);
|
|
if (call++ === 0) return limitErrorResponse('maxCallsInRequest');
|
|
return jsonResponse({
|
|
methodResponses: body.methodCalls.map(([, , callId]: [string, unknown, string]) => [
|
|
'Email/query', { total: 7 }, callId,
|
|
]),
|
|
});
|
|
}) as never);
|
|
|
|
const counts = await client.getTagCounts(tags);
|
|
|
|
expect(Object.keys(counts)).toEqual(['tag-8']);
|
|
expect(counts['tag-8']).toEqual({ total: 7, unread: 7 });
|
|
});
|
|
|
|
it('honours a lower ceiling advertised by the server', async () => {
|
|
const client = await connectedClient({ maxCallsInRequest: 4 });
|
|
const sent = recordRequests((methodCalls) => ({
|
|
methodResponses: methodCalls.map(([, , callId]) => ['Email/query', { total: 0 }, callId]),
|
|
}));
|
|
|
|
await client.getTagCounts(tags);
|
|
|
|
expect(sent.map(calls => calls.length)).toEqual([4, 4, 4, 4, 2]);
|
|
});
|
|
|
|
it('leaves the excluded folders out of both counts (#1156)', async () => {
|
|
const client = await connectedClient({ maxCallsInRequest: 16 });
|
|
const sent = recordRequests((methodCalls) => ({
|
|
methodResponses: methodCalls.map(([, , callId]) => ['Email/query', { total: 0 }, callId]),
|
|
}));
|
|
|
|
await client.getTagCounts(['work'], undefined, ['trash', 'junk']);
|
|
|
|
const [total, unread] = sent[0].map(([, args]) => (args as { filter: unknown }).filter);
|
|
expect(total).toEqual({
|
|
operator: 'AND',
|
|
conditions: [{ hasKeyword: '$label:work' }, { inMailboxOtherThan: ['trash', 'junk'] }],
|
|
});
|
|
expect(unread).toEqual({
|
|
operator: 'AND',
|
|
conditions: [
|
|
{ hasKeyword: '$label:work' }, { notKeyword: '$seen' }, { inMailboxOtherThan: ['trash', 'junk'] },
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('getCategoryUnreadCounts', () => {
|
|
it('splits the tabs across requests and keeps every tab id', async () => {
|
|
const client = await connectedClient({ maxCallsInRequest: 16 });
|
|
const tabs = Array.from({ length: 20 }, (_, i) => ({ id: `tab-${i}`, filter: null }));
|
|
const sent = recordRequests((methodCalls) => ({
|
|
methodResponses: methodCalls.map(([, , callId]) => ['Email/query', { total: 3 }, callId]),
|
|
}));
|
|
|
|
const counts = await client.getCategoryUnreadCounts('inbox', tabs);
|
|
|
|
expect(sent.map(calls => calls.length)).toEqual([16, 4]);
|
|
expect(Object.keys(counts)).toHaveLength(20);
|
|
expect(counts['tab-19']).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe('Email/set batches', () => {
|
|
const ids = Array.from({ length: 1200 }, (_, i) => `email-${i}`);
|
|
|
|
it('splits batchDeleteEmails at maxObjectsInSet', async () => {
|
|
const client = await connectedClient({ maxObjectsInSet: 500 });
|
|
const sent = recordRequests(calls => ({ methodResponses: [['Email/set', { destroyed: calls[0][1].destroy }, '0']] }));
|
|
|
|
await client.batchDeleteEmails(ids);
|
|
|
|
expect(sent.map(calls => (calls[0][1].destroy as string[]).length)).toEqual([500, 500, 200]);
|
|
});
|
|
|
|
it('splits batchMarkAsRead at maxObjectsInSet', async () => {
|
|
const client = await connectedClient({ maxObjectsInSet: 500 });
|
|
const sent = recordRequests(() => ({ methodResponses: [['Email/set', { updated: {} }, '0']] }));
|
|
|
|
await client.batchMarkAsRead(ids, true);
|
|
|
|
const updated = sent.flatMap(calls => Object.keys(calls[0][1].update as object));
|
|
expect(sent).toHaveLength(3);
|
|
expect(updated).toEqual(ids);
|
|
});
|
|
|
|
it('splits batchMoveEmails at a ceiling the server lowered', async () => {
|
|
const client = await connectedClient({ maxObjectsInSet: 100 });
|
|
const sent = recordRequests(() => ({ methodResponses: [['Email/set', { updated: {} }, '0']] }));
|
|
|
|
await client.batchMoveEmails(ids, 'mailbox-2');
|
|
|
|
expect(sent).toHaveLength(12);
|
|
expect(Object.keys(sent[0][0][1].update as object)).toHaveLength(100);
|
|
});
|
|
});
|
|
|
|
describe('Email/get batches', () => {
|
|
it('splits getSomeEmails at maxObjectsInGet and returns every message', async () => {
|
|
const client = await connectedClient({ maxObjectsInGet: 500 });
|
|
const sent = recordRequests((methodCalls) => ({
|
|
methodResponses: [[
|
|
'Email/get',
|
|
{
|
|
list: (methodCalls[0][1].ids as string[]).map(id => ({
|
|
id,
|
|
receivedAt: '2026-03-14T10:00:00Z',
|
|
})),
|
|
},
|
|
'0',
|
|
]],
|
|
}));
|
|
|
|
const emails = await client.getSomeEmails(Array.from({ length: 1100 }, (_, i) => `email-${i}`));
|
|
|
|
expect(sent.map(calls => (calls[0][1].ids as string[]).length)).toEqual([500, 500, 100]);
|
|
expect(emails).toHaveLength(1100);
|
|
});
|
|
});
|
|
|
|
it('falls back to the documented defaults when the session advertises no limits', async () => {
|
|
const client = await connectedClient();
|
|
|
|
expect(client.getMaxObjectsInGet()).toBe(500);
|
|
expect(client.getMaxObjectsInSet()).toBe(500);
|
|
});
|
|
|
|
// Stalwart also caps how many requests one user may have in flight
|
|
// (maxConcurrentRequests, default 4) and refuses the surplus with the same
|
|
// 400 jmap:error:limit shape - BEFORE running any method. A push event fans
|
|
// out several refreshes at once, so the ceiling is reached in ordinary use;
|
|
// a refused Mailbox/get used to be answered with a fake lone "Inbox" that
|
|
// then replaced the real folder tree in the sidebar (#780).
|
|
describe('maxConcurrentRequests', () => {
|
|
const inboxList = {
|
|
methodResponses: [['Mailbox/get', { list: [{ id: 'mb-inbox', name: 'Inbox', role: 'inbox' }] }, '0']],
|
|
};
|
|
|
|
it('backs off and replays a refused request instead of failing it', async () => {
|
|
const client = await connectedClient();
|
|
let call = 0;
|
|
fetchSpy.mockImplementation((async () => {
|
|
if (call++ === 0) return limitErrorResponse('maxConcurrentRequests');
|
|
return jsonResponse(inboxList);
|
|
}) as never);
|
|
|
|
const mailboxes = await client.getMailboxes();
|
|
|
|
expect(call).toBe(2);
|
|
expect(mailboxes.map(m => m.id)).toEqual(['mb-inbox']);
|
|
});
|
|
|
|
it('does not replay other limit refusals', async () => {
|
|
const client = await connectedClient();
|
|
fetchSpy.mockImplementation((async () => limitErrorResponse('maxCallsInRequest')) as never);
|
|
|
|
await expect(client.getMailboxes()).rejects.toThrow('Request failed: 400');
|
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('gives up after a few attempts and reports the failure - never a placeholder Inbox', async () => {
|
|
const client = await connectedClient();
|
|
fetchSpy.mockImplementation((async () => limitErrorResponse('maxConcurrentRequests')) as never);
|
|
vi.useFakeTimers();
|
|
try {
|
|
const outcome = client.getMailboxes().then(() => 'resolved', (e: Error) => e.message);
|
|
await vi.advanceTimersByTimeAsync(10_000);
|
|
|
|
expect(await outcome).toMatch(/Request failed: 400/);
|
|
expect(fetchSpy.mock.calls.length).toBeGreaterThan(1);
|
|
expect(fetchSpy.mock.calls.length).toBeLessThanOrEqual(4);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('getAllMailboxes surfaces the failure rather than substituting a placeholder', async () => {
|
|
const client = await connectedClient();
|
|
fetchSpy.mockImplementation((async () => limitErrorResponse('maxConcurrentRequests')) as never);
|
|
vi.useFakeTimers();
|
|
try {
|
|
const outcome = client.getAllMailboxes().then(() => null, (e: Error) => e);
|
|
await vi.advanceTimersByTimeAsync(10_000);
|
|
|
|
expect(await outcome).toBeInstanceOf(Error);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
});
|