From 0e026cab2e983cf1f38f6f034209fb5270516a9a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:20:07 +0100 Subject: [PATCH] fix: list files on Stalwart 1.0 when the account holds more nodes than one get allows #1385 --- lib/__tests__/jmap-filenode-client.test.ts | 19 +++++++++++++++++++ lib/jmap/client.ts | 11 ++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/lib/__tests__/jmap-filenode-client.test.ts b/lib/__tests__/jmap-filenode-client.test.ts index a4dad6625..18370b2e1 100644 --- a/lib/__tests__/jmap-filenode-client.test.ts +++ b/lib/__tests__/jmap-filenode-client.test.ts @@ -44,6 +44,8 @@ function fakeFilesServer(nodes: FakeNode[], opts: { // Stalwart before 0.16.6 returns leaf files only. queryOmitsFolders?: boolean; queryFails?: boolean; + // Stalwart 1.0 refuses `ids: null` past maxObjectsInGet instead of cutting it short. + getAllRefused?: boolean; }) { const sent: MethodCall[][] = []; vi.spyOn(globalThis, 'fetch').mockImplementation((async (_url: string, init: RequestInit) => { @@ -52,6 +54,7 @@ function fakeFilesServer(nodes: FakeNode[], opts: { const methodResponses = methodCalls.map(([method, args, callId]) => { if (method === 'FileNode/get') { const ids = args.ids as string[] | null; + if (ids === null && opts.getAllRefused && nodes.length > opts.maxObjectsInGet) return ['error', { type: 'requestTooLarge' }, callId]; if (ids === null) return [method, { list: nodes.slice(0, opts.maxObjectsInGet), notFound: [] }, callId]; if (ids.length > opts.maxObjectsInGet) return ['error', { type: 'requestTooLarge' }, callId]; return [method, { list: nodes.filter(n => ids.includes(n.id)), notFound: ids.filter(id => !nodes.some(n => n.id === id)) }, callId]; @@ -147,6 +150,22 @@ describe('JMAPClient FileNode listing past maxObjectsInGet (#1069)', () => { expect(sortedIds(nodes)).toEqual(['f0', 'f1']); }); + it('lists everything from the query when Stalwart 1.0 refuses the return-all /get (#1385)', async () => { + const all = [folder('d1'), file('f0', 'd1'), file('f1'), file('f2', 'd1')]; + const client = createLimitedClient({ maxObjectsInGet: 2 }); + fakeFilesServer(all, { maxObjectsInGet: 2, getAllRefused: true }); + + const nodes = await client.listAllFileNodes(); + expect(sortedIds(nodes)).toEqual(sortedIds(all)); + }); + + it('reports the failure when the return-all /get is refused and the query fails too', async () => { + const client = createLimitedClient({ maxObjectsInGet: 2 }); + fakeFilesServer([file('f0'), file('f1'), file('f2')], { maxObjectsInGet: 2, getAllRefused: true, queryFails: true }); + + await expect(client.listAllFileNodes()).rejects.toThrow(); + }); + it('lists shared accounts past the limit too', async () => { const all = [file('f0'), file('f1'), file('f2'), file('f3')]; const client = createLimitedClient({ maxObjectsInGet: 2 }); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 73d016625..1dee46c38 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -9272,13 +9272,16 @@ export class JMAPClient implements IJMAPClient { ); const getResult = response.methodResponses?.find(r => r[0] === "FileNode/get" || (r[0] === "error" && r[2] === "fng0")); - if (!getResult || getResult[0] === "error") { + // Stalwart 1.0 refuses the return-all /get instead of cutting it short + // (#1385); then every node comes from the query below. + const tooMany = getResult?.[0] === "error" && getResult[1]?.type === "requestTooLarge"; + if (!getResult || (getResult[0] === "error" && !tooMany)) { console.error('[Files] FileNode/get error:', getResult?.[1]); throw new Error(getResult?.[1]?.description || "FileNode list failed"); } - const firstPage = ((getResult[1].list || []) as FileNode[]).map(fromDraftFileNode); + const firstPage = tooMany ? [] : ((getResult[1].list || []) as FileNode[]).map(fromDraftFileNode); const maxObjects = this.getMaxObjectsInGet(); - if (firstPage.length < maxObjects) return firstPage; + if (!tooMany && firstPage.length < maxObjects) return firstPage; const known = new Map(firstPage.map(node => [node.id, node])); // Several /get calls share one request, so a large account costs a few @@ -9334,6 +9337,8 @@ export class JMAPClient implements IJMAPClient { } } catch (error) { // A partial tree beats none: keep what was read, as before #1069. + // With nothing read there is no tree to show, so say why. + if (known.size === 0) throw error; console.warn(`[Files] Listing for account ${accountId} is incomplete past ${maxObjects} nodes:`, error); }