Merge pull request #714 from MathyV/respect-max-calls

fix: make bulwark respect server limits
This commit is contained in:
Linus Rath
2026-07-31 05:43:26 +02:00
committed by GitHub
8 changed files with 685 additions and 261 deletions
+45 -4
View File
@@ -2038,6 +2038,35 @@ function resolveBackReferences(
});
}
// ---------------------------------------------------------------------------
// Request limits
// ---------------------------------------------------------------------------
// Stalwart's defaults. Too many method calls fails the request whole
// (RFC 8620 §3.6.1), an over-sized /get or /set fails that call
// (`requestTooLarge`, §5.1 and §5.3). The mock enforces what it advertises so a
// client that sends an unsplit batch fails here the way it fails in production.
const MAX_CALLS_IN_REQUEST = 16;
const MAX_OBJECTS_IN_GET = 500;
const MAX_OBJECTS_IN_SET = 500;
/** Objects a /set call touches, across all three of its maps (RFC 8620 §5.3). */
function setObjectCount(args: MethodArgs): number {
const size = (value: unknown) => (Array.isArray(value) ? value.length : Object.keys(value || {}).length);
return size(args.create) + size(args.update) + size(args.destroy);
}
/** The method-level error a server returns for an over-sized /get or /set. */
function tooLargeFor(method: string, args: MethodArgs, callId: string): MethodResult | null {
if (method.endsWith('/get') && Array.isArray(args.ids) && args.ids.length > MAX_OBJECTS_IN_GET) {
return ['error', { type: 'requestTooLarge', description: `More than ${MAX_OBJECTS_IN_GET} ids in ${method}` }, callId];
}
if (method.endsWith('/set') && setObjectCount(args) > MAX_OBJECTS_IN_SET) {
return ['error', { type: 'requestTooLarge', description: `More than ${MAX_OBJECTS_IN_SET} objects in ${method}` }, callId];
}
return null;
}
// ---------------------------------------------------------------------------
// Route handlers
// ---------------------------------------------------------------------------
@@ -2070,9 +2099,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
maxConcurrentUpload: 4,
maxSizeRequest: 10000000,
maxConcurrentRequests: 4,
maxCallsInRequest: 16,
maxObjectsInGet: 500,
maxObjectsInSet: 500,
maxCallsInRequest: MAX_CALLS_IN_REQUEST,
maxObjectsInGet: MAX_OBJECTS_IN_GET,
maxObjectsInSet: MAX_OBJECTS_IN_SET,
collationAlgorithms: ['i;ascii-casemap', 'i;ascii-numeric', 'i;unicode-casemap'],
},
'urn:ietf:params:jmap:mail': {},
@@ -2218,6 +2247,15 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Invalid request: missing methodCalls' }, { status: 400 });
}
if (methodCalls.length > MAX_CALLS_IN_REQUEST) {
return NextResponse.json({
type: 'urn:ietf:params:jmap:error:limit',
status: 400,
limit: 'maxCallsInRequest',
detail: `This request contains ${methodCalls.length} method calls, the maximum is ${MAX_CALLS_IN_REQUEST}.`,
}, { status: 400 });
}
const responses: MethodResult[] = [];
// Process method calls sequentially (to support back-references)
@@ -2227,8 +2265,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
// Use resolved args if available, otherwise original
const args = i < resolved.length ? resolved[i][1] : methodCalls[i][1];
const tooLarge = tooLargeFor(method, args, callId);
const handler = METHOD_HANDLERS[method];
if (handler) {
if (tooLarge) {
responses.push(tooLarge);
} else if (handler) {
const result = handler(args, callId);
responses.push(result);
} else {
+8 -5
View File
@@ -949,15 +949,18 @@ export function Sidebar({
? buildKeywordTree(emailKeywords)
: emailKeywords.map((kw) => ({ ...kw, children: [], depth: 0 }));
// Counts arrive from a separate JMAP round trip; until they land, treat every
// "show if unread" tag as visible rather than blanking the section and
// filling it back in.
const tagCountsLoaded = Object.keys(tagCounts).length > 0;
// Counts arrive from a separate JMAP round trip, one batch per group of tags;
// a tag with no count yet is treated as visible rather than blanking it and
// filling it back in. A tag the server answered for with zero unread hides,
// which is the point of the setting.
const isTagVisible = (node: KeywordNode) => {
if (showAllTags || node.id === selectedKeyword) return true;
const visibility = getKeywordVisibility(node);
if (visibility === 'hide') return false;
if (visibility === 'unread') return !tagCountsLoaded || (tagCounts[node.id]?.unread ?? 0) > 0;
if (visibility === 'unread') {
const count = tagCounts[node.id];
return !count || count.unread > 0;
}
return true;
};
const visibleTagTree = filterKeywordTree(tagTree, isTagVisible);
+47
View File
@@ -232,6 +232,53 @@ describe('dev-jmap mock server', () => {
});
});
describe('POST /api - request limits', () => {
it('should refuse a request with more method calls than it advertises', async () => {
const methodCalls = Array.from({ length: 17 }, (_, i) => [
'Email/query',
{ accountId: 'dev-account-001', limit: 0, calculateTotal: true },
`c${i}`,
]);
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
body: JSON.stringify({ methodCalls }),
});
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
const data = await res.json();
expect(res.status).toBe(400);
expect(data.type).toBe('urn:ietf:params:jmap:error:limit');
expect(data.limit).toBe('maxCallsInRequest');
});
it('should reject an over-sized /set with requestTooLarge', async () => {
const destroy = Array.from({ length: 501 }, (_, i) => `email-${i}`);
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
body: JSON.stringify({ methodCalls: [['Email/set', { accountId: 'dev-account-001', destroy }, '0']] }),
});
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
const data = await res.json();
expect(res.status).toBe(200);
expect(data.methodResponses[0][0]).toBe('error');
expect(data.methodResponses[0][1].type).toBe('requestTooLarge');
});
it('should reject an over-sized /get with requestTooLarge', async () => {
const ids = Array.from({ length: 501 }, (_, i) => `email-${i}`);
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
body: JSON.stringify({ methodCalls: [['Email/get', { accountId: 'dev-account-001', ids }, '0']] }),
});
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
const data = await res.json();
expect(data.methodResponses[0][0]).toBe('error');
expect(data.methodResponses[0][1].type).toBe('requestTooLarge');
});
});
describe('POST /upload', () => {
it('should return a fake blob response', async () => {
const req = makeRequest('http://localhost:3000/api/dev-jmap/upload/dev-account-001/', {
+231
View File
@@ -0,0 +1,231 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
import { batched, 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('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);
});
});
+2 -1
View File
@@ -56,7 +56,7 @@ export class DemoJMAPClient implements IJMAPClient {
getCapabilities(): Record<string, unknown> {
return {
'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500 },
'urn:ietf:params:jmap:core': { maxSizeUpload: 50_000_000, maxCallsInRequest: 16, maxObjectsInGet: 500, maxObjectsInSet: 500 },
'urn:ietf:params:jmap:mail': {},
'urn:ietf:params:jmap:submission': { maxDelayedSend: 30 * 24 * 60 * 60, submissionExtensions: { FUTURERELEASE: true } },
'urn:ietf:params:jmap:vacationresponse': {},
@@ -71,6 +71,7 @@ export class DemoJMAPClient implements IJMAPClient {
getMaxSizeUpload(): number { return 50_000_000; }
getMaxCallsInRequest(): number { return 16; }
getMaxObjectsInGet(): number { return 500; }
getMaxObjectsInSet(): number { return 500; }
getMaxDelayedSend(): number { return 30 * 24 * 60 * 60; }
hasDelayedSend(): boolean { return true; }
getEventSourceUrl(): string | null { return null; }
+1
View File
@@ -31,6 +31,7 @@ export interface IJMAPClient {
getMaxSizeUpload(): number;
getMaxCallsInRequest(): number;
getMaxObjectsInGet(): number;
getMaxObjectsInSet(): number;
getMaxDelayedSend(accountId?: string): number;
hasDelayedSend(accountId?: string): boolean;
getEventSourceUrl(): string | null;
+325 -251
View File
@@ -2,6 +2,7 @@ import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, Emai
import type { SieveScript, SieveCapabilities } from "./sieve-types";
import type { IJMAPClient } from "./client-interface";
import { toWildcardQuery } from "./search-utils";
import { batched, itemsPerRequest } from "./request-limits";
import { debug } from "@/lib/debug";
import { normalizeCalendarEventLike } from "@/lib/calendar-event-normalization";
@@ -606,31 +607,32 @@ export class JMAPClient implements IJMAPClient {
return [];
}
const response = await this.request([
["Email/get", {
accountId: targetAccountId,
ids: emailsId,
properties: [...EMAIL_LIST_PROPERTIES],
}, "0"],
]);
const emails: Email[] = [];
const getResponse = response.methodResponses?.[0]?.[1];
for (const batchIds of batched(emailsId, this.getMaxObjectsInGet())) {
const response = await this.request([
["Email/get", {
accountId: targetAccountId,
ids: batchIds,
properties: [...EMAIL_LIST_PROPERTIES],
}, "0"],
]);
if (response.methodResponses?.[0]?.[0] === "Email/get" && getResponse) {
const emails = (getResponse.list || []) as Email[];
emails.sort((a: Email, b: Email) =>
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
);
if (accountId && accountId !== this.accountId) {
namespaceMailboxIds(emails, accountId);
const getResponse = response.methodResponses?.[0]?.[1];
if (response.methodResponses?.[0]?.[0] === "Email/get" && getResponse) {
emails.push(...((getResponse.list || []) as Email[]));
}
return emails;
}
return [];
emails.sort((a: Email, b: Email) =>
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
);
if (accountId && accountId !== this.accountId) {
namespaceMailboxIds(emails, accountId);
}
return emails;
} catch (error) {
console.error('Failed to get specific emails:', error);
return [];
@@ -1266,56 +1268,62 @@ export class JMAPClient implements IJMAPClient {
async getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>> {
if (tagIds.length === 0) return {};
try {
const methodCalls: JMAPMethodCall[] = [];
for (let i = 0; i < tagIds.length; i++) {
const keyword = `$label:${tagIds[i]}`;
// Total count for this tag
methodCalls.push(["Email/query", {
accountId: this.accountId,
filter: { hasKeyword: keyword },
limit: 0,
calculateTotal: true,
}, `total_${i}`]);
// Unread count for this tag
methodCalls.push(["Email/query", {
accountId: this.accountId,
filter: {
operator: "AND",
conditions: [
{ hasKeyword: keyword },
{ notKeyword: "$seen" },
],
},
limit: 0,
calculateTotal: true,
}, `unread_${i}`]);
const result: Record<string, { total: number; unread: number }> = {};
const CALLS_PER_TAG = 2;
const perRequest = itemsPerRequest(this.getMaxCallsInRequest(), CALLS_PER_TAG);
for (const batch of batched(tagIds, perRequest)) {
try {
const methodCalls: JMAPMethodCall[] = [];
for (let i = 0; i < batch.length; i++) {
const keyword = `$label:${batch[i]}`;
// Total count for this tag
methodCalls.push(["Email/query", {
accountId: this.accountId,
filter: { hasKeyword: keyword },
limit: 0,
calculateTotal: true,
}, `total_${i}`]);
// Unread count for this tag
methodCalls.push(["Email/query", {
accountId: this.accountId,
filter: {
operator: "AND",
conditions: [
{ hasKeyword: keyword },
{ notKeyword: "$seen" },
],
},
limit: 0,
calculateTotal: true,
}, `unread_${i}`]);
}
const response = await this.request(methodCalls);
for (let i = 0; i < batch.length; i++) {
const totalResp = response.methodResponses?.[i * 2]?.[1];
const unreadResp = response.methodResponses?.[i * 2 + 1]?.[1];
result[batch[i]] = {
total: totalResp?.total ?? 0,
unread: unreadResp?.total ?? 0,
};
}
} catch (error) {
console.error('Failed to get tag counts:', error);
}
const response = await this.request(methodCalls);
const result: Record<string, { total: number; unread: number }> = {};
for (let i = 0; i < tagIds.length; i++) {
const totalResp = response.methodResponses?.[i * 2]?.[1];
const unreadResp = response.methodResponses?.[i * 2 + 1]?.[1];
result[tagIds[i]] = {
total: totalResp?.total ?? 0,
unread: unreadResp?.total ?? 0,
};
}
return result;
} catch (error) {
console.error('Failed to get tag counts:', error);
return {};
}
return result;
}
/**
* Per-tab unread counts for message-list category tabs. One Email/query
* (limit 0, calculateTotal) per tab, batched in a single request. Each
* entry's `filter` is the tab's resolved FilterCondition/FilterOperator
* (null = no extra condition, i.e. all unread in the mailbox).
* (limit 0, calculateTotal) per tab, batched into as few requests as the
* server's method-call ceiling allows. Each entry's `filter` is the tab's
* resolved FilterCondition/FilterOperator (null = no extra condition, i.e.
* all unread in the mailbox).
*/
async getCategoryUnreadCounts(
mailboxId: string,
@@ -1324,31 +1332,34 @@ export class JMAPClient implements IJMAPClient {
): Promise<Record<string, number>> {
if (tabs.length === 0) return {};
const targetAccountId = accountId || this.accountId;
try {
const methodCalls: JMAPMethodCall[] = tabs.map((tab, i) => {
const conditions: Record<string, unknown>[] = [
{ inMailbox: mailboxId },
{ notKeyword: "$seen" },
];
if (tab.filter) conditions.push(tab.filter);
return ["Email/query", {
accountId: targetAccountId,
filter: { operator: "AND", conditions },
limit: 0,
calculateTotal: true,
}, `tab_${i}`];
});
const result: Record<string, number> = {};
const response = await this.request(methodCalls);
const result: Record<string, number> = {};
for (let i = 0; i < tabs.length; i++) {
result[tabs[i].id] = response.methodResponses?.[i]?.[1]?.total ?? 0;
for (const batch of batched(tabs, this.getMaxCallsInRequest())) {
try {
const methodCalls: JMAPMethodCall[] = batch.map((tab, i) => {
const conditions: Record<string, unknown>[] = [
{ inMailbox: mailboxId },
{ notKeyword: "$seen" },
];
if (tab.filter) conditions.push(tab.filter);
return ["Email/query", {
accountId: targetAccountId,
filter: { operator: "AND", conditions },
limit: 0,
calculateTotal: true,
}, `tab_${i}`];
});
const response = await this.request(methodCalls);
for (let i = 0; i < batch.length; i++) {
result[batch[i].id] = response.methodResponses?.[i]?.[1]?.total ?? 0;
}
} catch (error) {
console.error('Failed to get category tab counts:', error);
}
return result;
} catch (error) {
console.error('Failed to get category tab counts:', error);
return {};
}
return result;
}
async getEmail(emailId: string, accountId?: string): Promise<Email | null> {
@@ -1464,10 +1475,12 @@ export class JMAPClient implements IJMAPClient {
async batchMarkAsRead(emailIds: string[], read: boolean = true, accountId?: string): Promise<void> {
if (emailIds.length === 0) return;
const updates = Object.fromEntries(emailIds.map(id => [id, { "keywords/$seen": read }]));
await this.request([
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
]);
for (const batch of batched(emailIds, this.getMaxObjectsInSet())) {
const updates = Object.fromEntries(batch.map(id => [id, { "keywords/$seen": read }]));
await this.request([
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
]);
}
}
async toggleStar(emailId: string, starred: boolean, accountId?: string): Promise<void> {
@@ -1529,10 +1542,12 @@ export class JMAPClient implements IJMAPClient {
*/
async batchUpdateKeywords(emailIds: string[], patch: Record<string, boolean | null>, accountId?: string): Promise<void> {
if (emailIds.length === 0 || Object.keys(patch).length === 0) return;
const update = Object.fromEntries(emailIds.map(id => [id, { ...patch }]));
await this.request([
["Email/set", { accountId: accountId || this.accountId, update }, "0"],
]);
for (const batch of batched(emailIds, this.getMaxObjectsInSet())) {
const update = Object.fromEntries(batch.map(id => [id, { ...patch }]));
await this.request([
["Email/set", { accountId: accountId || this.accountId, update }, "0"],
]);
}
}
async migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number> {
@@ -1562,9 +1577,7 @@ export class JMAPClient implements IJMAPClient {
if (allIds.length === 0) return 0;
// Batch update: remove old keyword, add new keyword using per-property patches
const updateBatchSize = 50;
for (let i = 0; i < allIds.length; i += updateBatchSize) {
const batch = allIds.slice(i, i + updateBatchSize);
for (const batch of batched(allIds, this.getMaxObjectsInSet())) {
const update: Record<string, Record<string, boolean | null>> = {};
for (const id of batch) {
update[id] = {
@@ -1608,12 +1621,14 @@ export class JMAPClient implements IJMAPClient {
async batchDeleteEmails(emailIds: string[], accountId?: string): Promise<void> {
if (emailIds.length === 0) return;
await this.request([
["Email/set", {
accountId: accountId || this.accountId,
destroy: emailIds,
}, "0"],
]);
for (const batch of batched(emailIds, this.getMaxObjectsInSet())) {
await this.request([
["Email/set", {
accountId: accountId || this.accountId,
destroy: batch,
}, "0"],
]);
}
}
async batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void> {
@@ -1624,10 +1639,12 @@ export class JMAPClient implements IJMAPClient {
if (markAsRead) patch["keywords/$seen"] = true;
return patch;
};
const updates = Object.fromEntries(emailIds.map(id => [id, buildPatch()]));
await this.request([
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
]);
for (const batch of batched(emailIds, this.getMaxObjectsInSet())) {
const updates = Object.fromEntries(batch.map(id => [id, buildPatch()]));
await this.request([
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
]);
}
}
async batchArchiveEmails(
@@ -1705,35 +1722,59 @@ export class JMAPClient implements IJMAPClient {
updates[emailId] = { mailboxIds: { [destId]: true } };
}
const methodCalls: JMAPMethodCall[] = [];
// Creation ids are scoped to the request that introduced them (RFC 8620
// §3.3), so "#<cid>" only resolves in the request carrying the Mailbox/set:
// the folders are created alongside the first batch of messages, and the
// ids they were assigned are substituted into every later batch.
const updateBatches = batched(Object.entries(updates), this.getMaxObjectsInSet());
const hasCreates = Object.keys(createEntries).length > 0;
if (hasCreates) {
methodCalls.push(['Mailbox/set', { accountId: targetAccountId, create: createEntries }, '0']);
}
methodCalls.push(['Email/set', { accountId: targetAccountId, update: updates }, String(methodCalls.length)]);
let createdIdFor: Record<string, string> = {};
const response = await this.request(methodCalls);
for (let i = 0; i < updateBatches.length; i++) {
const batch: Array<[string, { mailboxIds: Record<string, true> }]> = i === 0
? updateBatches[i]
: updateBatches[i].map(([emailId, patch]) => {
const [destId] = Object.keys(patch.mailboxIds);
const resolved = createdIdFor[destId];
return [emailId, resolved ? { mailboxIds: { [resolved]: true } as Record<string, true> } : patch];
});
if (hasCreates) {
const mailboxResult = response.methodResponses?.[0]?.[1];
const notCreated = mailboxResult?.notCreated as Record<string, { type?: string; properties?: string[]; description?: string }> | undefined;
const failures = notCreated ? Object.entries(notCreated) : [];
if (failures.length > 0) {
const [cid, err] = failures[0];
const parts = [err.type || 'unknown'];
if (err.properties?.length) parts.push(`properties=[${err.properties.join(', ')}]`);
if (err.description) parts.push(err.description);
throw new Error(`Failed to create archive folder '${cid}': ${parts.join(' ')}`);
const methodCalls: JMAPMethodCall[] = [];
const withCreates = hasCreates && i === 0;
if (withCreates) {
methodCalls.push(['Mailbox/set', { accountId: targetAccountId, create: createEntries }, '0']);
}
}
methodCalls.push(['Email/set', { accountId: targetAccountId, update: Object.fromEntries(batch) }, String(methodCalls.length)]);
const emailIdx = hasCreates ? 1 : 0;
const emailResult = response.methodResponses?.[emailIdx]?.[1];
const notUpdated = emailResult?.notUpdated as Record<string, { type?: string; description?: string }> | undefined;
const emailFailures = notUpdated ? Object.entries(notUpdated) : [];
if (emailFailures.length > 0) {
const [id, err] = emailFailures[0];
throw new Error(`Failed to move ${emailFailures.length} email(s), first: ${id} ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`);
const response = await this.request(methodCalls);
if (withCreates) {
const mailboxResult = response.methodResponses?.[0]?.[1];
const notCreated = mailboxResult?.notCreated as Record<string, { type?: string; properties?: string[]; description?: string }> | undefined;
const failures = notCreated ? Object.entries(notCreated) : [];
if (failures.length > 0) {
const [cid, err] = failures[0];
const parts = [err.type || 'unknown'];
if (err.properties?.length) parts.push(`properties=[${err.properties.join(', ')}]`);
if (err.description) parts.push(err.description);
throw new Error(`Failed to create archive folder '${cid}': ${parts.join(' ')}`);
}
const created = (mailboxResult?.created || {}) as Record<string, { id?: string }>;
createdIdFor = Object.fromEntries(
Object.entries(created)
.filter(([, mailbox]) => !!mailbox?.id)
.map(([cid, mailbox]) => [`#${cid}`, mailbox.id!]),
);
}
const emailIdx = withCreates ? 1 : 0;
const emailResult = response.methodResponses?.[emailIdx]?.[1];
const notUpdated = emailResult?.notUpdated as Record<string, { type?: string; description?: string }> | undefined;
const emailFailures = notUpdated ? Object.entries(notUpdated) : [];
if (emailFailures.length > 0) {
const [id, err] = emailFailures[0];
throw new Error(`Failed to move ${emailFailures.length} email(s), first: ${id} ${err.type || 'unknown'}${err.description ? ` (${err.description})` : ''}`);
}
}
}
@@ -1758,7 +1799,7 @@ export class JMAPClient implements IJMAPClient {
async emptyMailbox(mailboxId: string, accountId?: string): Promise<number> {
const targetAccountId = accountId || this.accountId;
const batchSize = 500;
const batchSize = Math.min(500, this.getMaxObjectsInSet());
let totalDestroyed = 0;
// Destroy in batches until the mailbox is empty. Never gate the loop on
@@ -1797,6 +1838,7 @@ export class JMAPClient implements IJMAPClient {
async markMailboxAsRead(mailboxId: string, accountId?: string): Promise<number> {
const targetAccountId = accountId || this.accountId;
const pageSize = Math.min(500, this.getMaxObjectsInSet());
let totalMarked = 0;
let hasMore = true;
@@ -1811,7 +1853,7 @@ export class JMAPClient implements IJMAPClient {
{ notKeyword: "$seen" },
],
},
limit: 500,
limit: pageSize,
}, "0"],
]);
@@ -1827,7 +1869,7 @@ export class JMAPClient implements IJMAPClient {
]);
totalMarked += ids.length;
hasMore = ids.length === 500;
hasMore = ids.length === pageSize;
}
return totalMarked;
@@ -1836,6 +1878,7 @@ export class JMAPClient implements IJMAPClient {
async markAllAsRead(excludeMailboxIds: string[] = [], accountId?: string): Promise<number> {
const targetAccountId = accountId || this.accountId;
const excludeSet = new Set(excludeMailboxIds);
const pageSize = Math.min(500, this.getMaxObjectsInGet(), this.getMaxObjectsInSet());
let totalMarked = 0;
let hasMore = true;
let position = 0;
@@ -1845,7 +1888,7 @@ export class JMAPClient implements IJMAPClient {
["Email/query", {
accountId: targetAccountId,
filter: { notKeyword: "$seen" },
limit: 500,
limit: pageSize,
position,
}, "0"],
["Email/get", {
@@ -1881,7 +1924,7 @@ export class JMAPClient implements IJMAPClient {
totalMarked += targetIds.length;
}
hasMore = ids.length === 500;
hasMore = ids.length === pageSize;
position += ids.length;
}
@@ -2193,14 +2236,19 @@ export class JMAPClient implements IJMAPClient {
if (threadIds.length === 0) return [];
try {
const targetAccountId = accountId || this.accountId;
const response = await this.request([
["Thread/get", { accountId: targetAccountId, ids: threadIds }, "0"],
]);
const threads: Thread[] = [];
if (response.methodResponses?.[0]?.[0] === "Thread/get") {
return (response.methodResponses[0][1].list || []) as Thread[];
for (const batchIds of batched(threadIds, this.getMaxObjectsInGet())) {
const response = await this.request([
["Thread/get", { accountId: targetAccountId, ids: batchIds }, "0"],
]);
if (response.methodResponses?.[0]?.[0] === "Thread/get") {
threads.push(...((response.methodResponses[0][1].list || []) as Thread[]));
}
}
return [];
return threads;
} catch (error) {
console.error('Failed to get threads:', error);
return [];
@@ -2215,26 +2263,32 @@ export class JMAPClient implements IJMAPClient {
return [];
}
const response = await this.request([
["Email/get", {
accountId: targetAccountId,
ids: thread.emailIds,
properties: [
...EMAIL_LIST_PROPERTIES,
"textBody", "htmlBody", "bodyValues",
"attachments", "blobId", "sentAt", "bcc", "replyTo",
"messageId", "inReplyTo", "references", "headers", "bodyStructure",
],
fetchTextBodyValues: true,
fetchHTMLBodyValues: true,
fetchAllBodyValues: true,
maxBodyValueBytes: 256000,
}, "0"],
]);
const emails: Email[] = [];
if (response.methodResponses?.[0]?.[0] === "Email/get") {
const emails = response.methodResponses[0][1].list || [];
for (const batchIds of batched(thread.emailIds, this.getMaxObjectsInGet())) {
const response = await this.request([
["Email/get", {
accountId: targetAccountId,
ids: batchIds,
properties: [
...EMAIL_LIST_PROPERTIES,
"textBody", "htmlBody", "bodyValues",
"attachments", "blobId", "sentAt", "bcc", "replyTo",
"messageId", "inReplyTo", "references", "headers", "bodyStructure",
],
fetchTextBodyValues: true,
fetchHTMLBodyValues: true,
fetchAllBodyValues: true,
maxBodyValueBytes: 256000,
}, "0"],
]);
if (response.methodResponses?.[0]?.[0] === "Email/get") {
emails.push(...(response.methodResponses[0][1].list || []));
}
}
if (emails.length > 0) {
if (accountId && accountId !== this.accountId) {
namespaceMailboxIds(emails, accountId);
}
@@ -3606,6 +3660,11 @@ export class JMAPClient implements IJMAPClient {
return coreCapability?.maxObjectsInGet || 500;
}
getMaxObjectsInSet(): number {
const coreCapability = this.capabilities["urn:ietf:params:jmap:core"] as { maxObjectsInSet?: number } | undefined;
return coreCapability?.maxObjectsInSet || 500;
}
getMaxDelayedSend(accountId?: string): number {
const maxDelayedSend = this.getSubmissionCapability(accountId)?.maxDelayedSend;
return typeof maxDelayedSend === 'number' ? maxDelayedSend : 0;
@@ -5060,38 +5119,41 @@ export class JMAPClient implements IJMAPClient {
const accountId = targetAccountId || this.getCalendarsAccountId();
// Build the create map: { "new-0": event0, "new-1": event1, ... }
const createMap: Record<string, Partial<CalendarEvent>> = {};
for (let i = 0; i < events.length; i++) {
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...clean } = events[i] as CalendarEvent;
cleanRecurrenceRules(clean as unknown as Record<string, unknown>);
createMap[`new-${i}`] = clean;
}
debug.log('calendar', 'CalendarEvent/batchCreate', { count: events.length, accountId });
// Never emit iMIP scheduling messages when importing. Imported events often
// carry an organizer/participants where the current user is the organizer;
// without this, Stalwart tries to send invitation emails to every attendee
// synchronously during CalendarEvent/set, which is both wrong (importing a
// calendar should not spam invites) and can block the request indefinitely,
// leaving the import spinner spinning forever (#411).
const response = await this.request([
["CalendarEvent/set", { accountId, sendSchedulingMessages: false, create: createMap }, "0"]
], this.calendarUsing());
const createdIds: string[] = [];
const failed: string[] = [];
const indexed = events.map((event, index) => ({ event, index }));
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
const result = response.methodResponses[0][1];
for (let i = 0; i < events.length; i++) {
const key = `new-${i}`;
if (result.created?.[key]?.id) {
createdIds.push(result.created[key].id);
} else if (result.notCreated?.[key]) {
debug.warn('calendar', `CalendarEvent/batchCreate failed for ${key}`, result.notCreated[key]);
failed.push(key);
for (const batch of batched(indexed, this.getMaxObjectsInSet())) {
// Build the create map: { "new-0": event0, "new-1": event1, ... }
const createMap: Record<string, Partial<CalendarEvent>> = {};
for (const { event, index } of batch) {
const { originalId: _oi, originalCalendarIds: _oc, accountId: _ai, accountName: _an, isShared: _is, ...clean } = event as CalendarEvent;
cleanRecurrenceRules(clean as unknown as Record<string, unknown>);
createMap[`new-${index}`] = clean;
}
// Never emit iMIP scheduling messages when importing. Imported events often
// carry an organizer/participants where the current user is the organizer;
// without this, Stalwart tries to send invitation emails to every attendee
// synchronously during CalendarEvent/set, which is both wrong (importing a
// calendar should not spam invites) and can block the request indefinitely,
// leaving the import spinner spinning forever (#411).
const response = await this.request([
["CalendarEvent/set", { accountId, sendSchedulingMessages: false, create: createMap }, "0"]
], this.calendarUsing());
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
const result = response.methodResponses[0][1];
for (const { index } of batch) {
const key = `new-${index}`;
if (result.created?.[key]?.id) {
createdIds.push(result.created[key].id);
} else if (result.notCreated?.[key]) {
debug.warn('calendar', `CalendarEvent/batchCreate failed for ${key}`, result.notCreated[key]);
failed.push(key);
}
}
}
}
@@ -5100,21 +5162,24 @@ export class JMAPClient implements IJMAPClient {
return { created: [], failed };
}
// Fetch all created events in a single CalendarEvent/get
// Fetch the created events back for their server-assigned properties
const refetchTimeZone = getUserTimeZone();
const getResponse = await this.request([
["CalendarEvent/get", {
accountId,
properties: [...CALENDAR_EVENT_PROPERTIES],
ids: createdIds,
...(refetchTimeZone ? { timeZone: refetchTimeZone } : {}),
}, "0"]
], this.calendarUsing());
const createdEvents: CalendarEvent[] = [];
let createdEvents: CalendarEvent[] = [];
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
const list = getResponse.methodResponses[0][1].list || [];
createdEvents = list.map((e: CalendarEvent) => normalizeCalendarEventLike(e));
for (const batchIds of batched(createdIds, this.getMaxObjectsInGet())) {
const getResponse = await this.request([
["CalendarEvent/get", {
accountId,
properties: [...CALENDAR_EVENT_PROPERTIES],
ids: batchIds,
...(refetchTimeZone ? { timeZone: refetchTimeZone } : {}),
}, "0"]
], this.calendarUsing());
if (getResponse.methodResponses?.[0]?.[0] === "CalendarEvent/get") {
const list = getResponse.methodResponses[0][1].list || [];
createdEvents.push(...list.map((e: CalendarEvent) => normalizeCalendarEventLike(e)));
}
}
debug.log('calendar', 'CalendarEvent/batchCreate result', {
@@ -5265,17 +5330,19 @@ export class JMAPClient implements IJMAPClient {
if (eventIds.length === 0) return { destroyed: [], notDestroyed: [] };
const accountId = targetAccountId || this.getCalendarsAccountId();
const response = await this.request([
["CalendarEvent/set", { accountId, destroy: eventIds }, "0"]
], this.calendarUsing());
const destroyed: string[] = [];
const notDestroyed: string[] = [];
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
const result = response.methodResponses[0][1];
if (result.destroyed) destroyed.push(...result.destroyed);
if (result.notDestroyed) notDestroyed.push(...Object.keys(result.notDestroyed));
for (const batch of batched(eventIds, this.getMaxObjectsInSet())) {
const response = await this.request([
["CalendarEvent/set", { accountId, destroy: batch }, "0"]
], this.calendarUsing());
if (response.methodResponses?.[0]?.[0] === "CalendarEvent/set") {
const result = response.methodResponses[0][1];
if (result.destroyed) destroyed.push(...result.destroyed);
if (result.notDestroyed) notDestroyed.push(...Object.keys(result.notDestroyed));
}
}
return { destroyed, notDestroyed };
@@ -5788,62 +5855,69 @@ export class JMAPClient implements IJMAPClient {
* throws for per-node failures (only for a whole-method error).
*/
async updateFileNodes(updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>): Promise<{ updated: string[]; notUpdated: Record<string, string> }> {
const ids = Object.keys(updates);
if (ids.length === 0) return { updated: [], notUpdated: {} };
const entries = Object.entries(updates);
if (entries.length === 0) return { updated: [], notUpdated: {} };
const accountId = this.getFilesAccountId();
const response = await this.request(
[["FileNode/set", { accountId, update: updates }, "fns0"]],
this.fileUsing(),
);
const result = response.methodResponses?.[0];
if (!result || result[0] === "error") {
throw new Error(result?.[1]?.description || "FileNode/set update failed");
}
const updatedMap: Record<string, unknown> = result[1].updated || {};
const notUpdatedMap: Record<string, { description?: string }> = result[1].notUpdated || {};
const updated: string[] = [];
const notUpdated: Record<string, string> = {};
for (const id of Object.keys(notUpdatedMap)) {
notUpdated[id] = notUpdatedMap[id]?.description || 'not updated';
for (const batch of batched(entries, this.getMaxObjectsInSet())) {
const response = await this.request(
[["FileNode/set", { accountId, update: Object.fromEntries(batch) }, "fns0"]],
this.fileUsing(),
);
const result = response.methodResponses?.[0];
if (!result || result[0] === "error") {
throw new Error(result?.[1]?.description || "FileNode/set update failed");
}
const updatedMap: Record<string, unknown> = result[1].updated || {};
const notUpdatedMap: Record<string, { description?: string }> = result[1].notUpdated || {};
for (const id of Object.keys(notUpdatedMap)) {
notUpdated[id] = notUpdatedMap[id]?.description || 'not updated';
}
// Servers may omit the `updated` map; treat anything not rejected as updated.
updated.push(...(Object.keys(updatedMap).length > 0
? Object.keys(updatedMap)
: batch.map(([id]) => id).filter(id => !(id in notUpdated))));
}
// Servers may omit the `updated` map; treat anything not rejected as updated.
const updated = Object.keys(updatedMap).length > 0
? Object.keys(updatedMap)
: ids.filter(id => !(id in notUpdated));
return { updated, notUpdated };
}
async destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }> {
const accountId = this.getFilesAccountId();
const destroyed: string[] = [];
const response = await this.request(
[["FileNode/set", {
accountId,
destroy: ids,
onDestroyRemoveChildren: true,
}, "fns0"]],
this.fileUsing(),
);
for (const batch of batched(ids, this.getMaxObjectsInSet())) {
const response = await this.request(
[["FileNode/set", {
accountId,
destroy: batch,
onDestroyRemoveChildren: true,
}, "fns0"]],
this.fileUsing(),
);
const result = response.methodResponses?.[0];
if (!result || result[0] === "error") {
throw new Error(result?.[1]?.description || "FileNode/set destroy failed");
const result = response.methodResponses?.[0];
if (!result || result[0] === "error") {
throw new Error(result?.[1]?.description || "FileNode/set destroy failed");
}
const notDestroyedMap: Record<string, { type?: string; description?: string }> = result[1].notDestroyed || {};
const notDestroyedIds = Object.keys(notDestroyedMap);
if (notDestroyedIds.length > 0) {
const firstError = notDestroyedMap[notDestroyedIds[0]];
throw new Error(firstError?.description || `Failed to delete ${notDestroyedIds.length} file(s)`);
}
destroyed.push(...(result[1].destroyed || []));
}
const notDestroyedMap: Record<string, { type?: string; description?: string }> = result[1].notDestroyed || {};
const notDestroyedIds = Object.keys(notDestroyedMap);
if (notDestroyedIds.length > 0) {
const firstError = notDestroyedMap[notDestroyedIds[0]];
throw new Error(firstError?.description || `Failed to delete ${notDestroyedIds.length} file(s)`);
}
return {
destroyed: result[1].destroyed || [],
notDestroyed: [],
};
return { destroyed, notDestroyed: [] };
}
async copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode> {
+26
View File
@@ -0,0 +1,26 @@
/**
* A JMAP session advertises hard ceilings on what one request may carry: how
* many method calls it holds (`maxCallsInRequest`) and how many objects a
* single /get or /set may touch (`maxObjectsInGet`, `maxObjectsInSet`). Going
* over any of them fails the *whole* request, not the surplus, so a batch built
* from a list the user controls - tags, category tabs, a multi-select, an
* import - is split against the advertised limit before it is sent.
*
* Stalwart defaults to 16 method calls and 500 objects, so the ceilings are low
* enough to reach with ordinary use: nine tags is already 18 calls.
*/
/** Split `items` into consecutive batches of at most `size` entries. */
export function batched<T>(items: T[], size: number): T[][] {
const step = Math.max(1, Math.floor(size));
const result: T[][] = [];
for (let i = 0; i < items.length; i += step) {
result.push(items.slice(i, i + step));
}
return result;
}
/** How many items fit in one request when each item costs `callsPerItem` method calls. */
export function itemsPerRequest(maxCalls: number, callsPerItem: number): number {
return Math.max(1, Math.floor(maxCalls / callsPerItem));
}