mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
On 1.0 a vCard export sent every blob photo through the avatar download
queue, a burst of 40 and then 6 a second, two at a time: about 160 s for
1000 photos. Avatars and print (which gives up after 5 s) waited behind
it, the Export button showed nothing, and a second click started the
whole export again.
- Export fetches the blob media of all cards with Blob/get
(`data:asBase64`), asking for the sizes first and packing the requests
to about 4 MiB. `urn:ietf:params:jmap:blob` goes into `using` only
when the session advertises it; without it, or when a request is
refused, the rest is downloaded one by one behind avatars and print,
which keep half of the download burst for themselves.
- The export buttons in the settings, the bulk bar, the context menu and
the detail menu show that an export is running and ignore clicks until
it is done.
- When the photo changes, the form rebuilds `media` on the card as the
server has it now. A logo or sound blobId from the list's older copy
made 1.0 refuse the whole update ("The following blobIds do not exist
or are not accessible").
- A new photo takes the key, place, `pref`, `label` and `contexts` of the
one it replaces, so it stays the photo shown on cards with several.
Removing the photo removes every photo the card had instead of only the
shown one, which brought up the next; logos and sounds stay.
- A card whose login has no client downloads nothing, instead of asking
the active login's server and remembering the refusal for two minutes.
- Photo downloads run at half the server's `maxConcurrentRequests`
instead of a fixed two.
- `invalidProperties` naming `media/...` also counts as a refused blobId
in media, not only `media` itself.
320 lines
12 KiB
TypeScript
320 lines
12 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> = {}) {
|
|
return {
|
|
capabilities: { 'urn:ietf:params:jmap:core': core },
|
|
accounts: { 'acct-1': { name: 'test', isPersonal: true, 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: '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>): Promise<JMAPClient> {
|
|
fetchSpy.mockResolvedValueOnce(jsonResponse(makeSession(core)));
|
|
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('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]);
|
|
});
|
|
});
|
|
|
|
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(() => ({ methodResponses: [['Email/set', { destroyed: [] }, '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();
|
|
}
|
|
});
|
|
});
|
|
});
|