diff --git a/lib/__tests__/jmap-empty-mailbox.test.ts b/lib/__tests__/jmap-empty-mailbox.test.ts new file mode 100644 index 00000000..a41d90c1 --- /dev/null +++ b/lib/__tests__/jmap-empty-mailbox.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { JMAPClient } from '../jmap/client'; + +function makeSession() { + return { + capabilities: { 'urn:ietf:params:jmap: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' }, + }); +} + +/** + * Stand-in for a Stalwart mailbox: Email/query returns one page of remaining + * ids, Email/set destroys them. `includeTotal` mirrors the server's freedom to + * omit `total` when the query did not ask for `calculateTotal` (RFC 8620 5.5). + */ +function makeMailboxServer(opts: { + count: number; + includeTotal?: boolean; + destroyFails?: boolean; +}) { + let remaining = Array.from({ length: opts.count }, (_, i) => `email-${i}`); + const requests: number[] = []; + + const handler = async (_url: string, init: RequestInit): Promise => { + const body = JSON.parse(init.body as string); + const [, queryArgs] = body.methodCalls[0]; + const limit: number = queryArgs.limit; + const page = remaining.slice(0, limit); + requests.push(page.length); + + const destroyed = opts.destroyFails ? [] : page; + remaining = remaining.slice(destroyed.length); + + return jsonResponse({ + methodResponses: [ + ['Email/query', { ids: page, ...(opts.includeTotal ? { total: page.length } : {}) }, '0'], + ['Email/set', { destroyed, notDestroyed: {} }, '1'], + ], + }); + }; + + return { handler, requests, remainingCount: () => remaining.length }; +} + +describe('JMAPClient.emptyMailbox', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + async function connectedClient(): Promise { + fetchSpy.mockResolvedValueOnce(jsonResponse(makeSession())); + const client = JMAPClient.withBearer('https://mail.example.com', 'token123', 'user@test.com'); + await client.connect(); + fetchSpy.mockReset(); + return client; + } + + it('destroys every email in a mailbox larger than one batch', async () => { + const client = await connectedClient(); + const server = makeMailboxServer({ count: 1200 }); + fetchSpy.mockImplementation(server.handler as never); + + const destroyed = await client.emptyMailbox('mailbox-1'); + + expect(destroyed).toBe(1200); + expect(server.remainingCount()).toBe(0); + expect(server.requests).toEqual([500, 500, 200]); + }); + + // Regression for #711: the loop used to stop after one batch when the server + // omitted `total`, leaving folders with thousands of emails nearly full. + it('keeps paging when the server omits Email/query total', async () => { + const client = await connectedClient(); + const server = makeMailboxServer({ count: 2300, includeTotal: false }); + fetchSpy.mockImplementation(server.handler as never); + + const destroyed = await client.emptyMailbox('mailbox-1'); + + expect(destroyed).toBe(2300); + expect(server.remainingCount()).toBe(0); + }); + + it('issues a final confirming query when the count is an exact multiple of the batch size', async () => { + const client = await connectedClient(); + const server = makeMailboxServer({ count: 1000 }); + fetchSpy.mockImplementation(server.handler as never); + + const destroyed = await client.emptyMailbox('mailbox-1'); + + expect(destroyed).toBe(1000); + expect(server.requests).toEqual([500, 500, 0]); + }); + + it('stops instead of looping forever when the server refuses to destroy', async () => { + const client = await connectedClient(); + const server = makeMailboxServer({ count: 1200, destroyFails: true }); + fetchSpy.mockImplementation(server.handler as never); + + const destroyed = await client.emptyMailbox('mailbox-1'); + + expect(destroyed).toBe(0); + expect(server.requests).toEqual([500]); + }); + + it('returns zero without extra requests for an already empty mailbox', async () => { + const client = await connectedClient(); + const server = makeMailboxServer({ count: 0 }); + fetchSpy.mockImplementation(server.handler as never); + + const destroyed = await client.emptyMailbox('mailbox-1'); + + expect(destroyed).toBe(0); + expect(server.requests).toEqual([0]); + }); +}); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 771eaed2..d97ab234 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1758,15 +1758,19 @@ export class JMAPClient implements IJMAPClient { async emptyMailbox(mailboxId: string, accountId?: string): Promise { const targetAccountId = accountId || this.accountId; + const batchSize = 500; let totalDestroyed = 0; - let hasMore = true; - while (hasMore) { + // Destroy in batches until the mailbox is empty. Never gate the loop on + // Email/query's `total`: it is only guaranteed when `calculateTotal` is + // requested, and Stalwart omits it otherwise, which used to stop the loop + // after the first batch and leave folders with >500 emails mostly intact. + while (true) { const response = await this.request([ ["Email/query", { accountId: targetAccountId, filter: { inMailbox: mailboxId }, - limit: 500, + limit: batchSize, }, "0"], ["Email/set", { accountId: targetAccountId, @@ -1776,10 +1780,16 @@ export class JMAPClient implements IJMAPClient { const queryResult = response.methodResponses?.[0]?.[1]; const setResult = response.methodResponses?.[1]?.[1]; + const found: string[] = queryResult?.ids || []; const destroyed = setResult?.destroyed?.length || 0; totalDestroyed += destroyed; - hasMore = destroyed > 0 && (queryResult?.total || 0) > destroyed; + // Nothing left, or the server refused everything in this batch (missing + // permission, immutable mail) — stop instead of looping forever on the + // same ids. + if (found.length === 0 || destroyed === 0) break; + // A short page means we just handled the tail of the mailbox. + if (found.length < batchSize) break; } return totalDestroyed;