fix: make bulwark respect server limits
If you have a large number of tags, getTagCounts would not be able to get the unread counts because it did not respect maxCallsInRequest, even though the value was actually read out, it was just ignored. There are also other places where the limits were not respected. Batching is now generalized in a helper that also takes maxObjectsInSet, which also was ignored, into account and is applied to all functions. In addition, the dev mock now also advertises and enforces the limits, so these issues can get picked up during development. Possible closes #699 Possibly closes #399
This commit is contained in:
@@ -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/', {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user