diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index b39f9448..423fb0e8 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -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 { diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index b3aa1078..e85b42b0 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -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); diff --git a/lib/__tests__/dev-jmap-mock.test.ts b/lib/__tests__/dev-jmap-mock.test.ts index 75ba078b..0de022dd 100644 --- a/lib/__tests__/dev-jmap-mock.test.ts +++ b/lib/__tests__/dev-jmap-mock.test.ts @@ -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/', { diff --git a/lib/__tests__/jmap-request-limits.test.ts b/lib/__tests__/jmap-request-limits.test.ts new file mode 100644 index 00000000..61dc2170 --- /dev/null +++ b/lib/__tests__/jmap-request-limits.test.ts @@ -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 = {}) { + 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; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + vi.restoreAllMocks(); + }); + + async function connectedClient(core?: Record): Promise { + 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) { + const sent: Array, 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); + }); +}); diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index f066c7fe..e7b42fc5 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -56,7 +56,7 @@ export class DemoJMAPClient implements IJMAPClient { getCapabilities(): Record { 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; } diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 35e6f1ad..2693f3d8 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -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; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index cacef5eb..63e368fa 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -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> { 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 = {}; + + 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 = {}; - - 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> { if (tabs.length === 0) return {}; const targetAccountId = accountId || this.accountId; - try { - const methodCalls: JMAPMethodCall[] = tabs.map((tab, i) => { - const conditions: Record[] = [ - { 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 = {}; - const response = await this.request(methodCalls); - const result: Record = {}; - 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[] = [ + { 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 { @@ -1464,10 +1475,12 @@ export class JMAPClient implements IJMAPClient { async batchMarkAsRead(emailIds: string[], read: boolean = true, accountId?: string): Promise { 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 { @@ -1529,10 +1542,12 @@ export class JMAPClient implements IJMAPClient { */ async batchUpdateKeywords(emailIds: string[], patch: Record, accountId?: string): Promise { 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 { @@ -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> = {}; for (const id of batch) { update[id] = { @@ -1608,12 +1621,14 @@ export class JMAPClient implements IJMAPClient { async batchDeleteEmails(emailIds: string[], accountId?: string): Promise { 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 { @@ -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 "#" 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 = {}; - const response = await this.request(methodCalls); + for (let i = 0; i < updateBatches.length; i++) { + const batch: Array<[string, { mailboxIds: Record }]> = 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 } : patch]; + }); - if (hasCreates) { - const mailboxResult = response.methodResponses?.[0]?.[1]; - const notCreated = mailboxResult?.notCreated as Record | 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 | 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 | 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; + 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 | 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 { 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 { 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 { 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> = {}; - 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); - 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> = {}; + 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); + 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>>): Promise<{ updated: string[]; notUpdated: Record }> { - 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 = result[1].updated || {}; - const notUpdatedMap: Record = result[1].notUpdated || {}; + const updated: string[] = []; const notUpdated: Record = {}; - 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 = result[1].updated || {}; + const notUpdatedMap: Record = 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 = 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 = 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 { diff --git a/lib/jmap/request-limits.ts b/lib/jmap/request-limits.ts new file mode 100644 index 00000000..92fd1d61 --- /dev/null +++ b/lib/jmap/request-limits.ts @@ -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(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)); +}