From 39efd8af3c9845cfdd2422e713bf495ed0bbb098 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:32:22 +0200 Subject: [PATCH] fix: preview the named message when the inbox lookups beside it fail #1041 Any failed method in the preview request answered 502, so when the server named the delivered message and it was read fine, a failing Inbox query still turned the notification into a generic "New mail". The named message is now a complete preview on its own. A response without the optional `total` was also treated as a failure; it now counts the returned ids. --- app/api/push/preview/route.ts | 24 ++++++++++++--------- lib/__tests__/push-preview-route.test.ts | 27 +++++++++++++++++++++++- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/app/api/push/preview/route.ts b/app/api/push/preview/route.ts index 8be988743..bf19ccdcc 100644 --- a/app/api/push/preview/route.ts +++ b/app/api/push/preview/route.ts @@ -247,15 +247,17 @@ export async function GET(request: NextRequest) { // Missing/failed method responses are preview failures, not proof that // there is no unread mail. A non-2xx response lets the SW show its fallback. - for (const call of methodCalls as [string, unknown, string][]) { - const response = data.methodResponses.find(([method, , callId]) => - method === call[0] && callId === call[2]); - const result = response?.[1]; - if (!result || (call[0] === 'Email/query' - ? !Array.isArray(result.ids) || typeof result.total !== 'number' - : !Array.isArray(result.list))) { - return NextResponse.json({ error: 'JMAP email query failed' }, { status: 502 }); - } + // The message the server named, once read, is a complete preview on its + // own, whatever became of the Inbox lookups next to it. + const resultOf = (name: string, id: string) => + data.methodResponses.find(([method, , callId]) => method === name && callId === id)?.[1]; + const failed = (methodCalls as [string, unknown, string][]).some(([name, , id]) => { + const result = resultOf(name, id); + return !result || (name === 'Email/query' ? !Array.isArray(result.ids) : !Array.isArray(result.list)); + }); + const deliveredList = resultOf('Email/get', 'delivered')?.list; + if (failed && !(Array.isArray(deliveredList) && deliveredList.length > 0)) { + return NextResponse.json({ error: 'JMAP email query failed' }, { status: 502 }); } type EmailLite = { @@ -272,7 +274,9 @@ export async function GET(request: NextRequest) { let unreadTotal = 0; for (const [method, body, callId] of data.methodResponses) { if (method === 'Email/query') { - unreadTotal = ((body as { total?: number }).total) ?? 0; + // A server that leaves out the optional total still lists the ids. + const query = body as { total?: number; ids?: unknown[] }; + unreadTotal = typeof query.total === 'number' ? query.total : (query.ids?.length ?? 0); } if (method === 'Email/get') { const list = (body as { list?: EmailLite[] }).list ?? []; diff --git a/lib/__tests__/push-preview-route.test.ts b/lib/__tests__/push-preview-route.test.ts index fb465f83a..f6d846494 100644 --- a/lib/__tests__/push-preview-route.test.ts +++ b/lib/__tests__/push-preview-route.test.ts @@ -137,7 +137,6 @@ describe('push preview JMAP failures', () => { it.each([ [['error', { type: 'serverFail' }, 'eq'], ['error', { type: 'resultReference' }, 'eg']], [['Email/query', { ids: ['e1'], total: 1 }, 'eq'], ['error', { type: 'serverFail' }, 'eg']], - [['Email/query', { ids: [] }, 'eq'], ['Email/get', { list: [] }, 'eg']], ])('does not report zero unread on an email method failure', async (query, get) => { fetchJmapServer.mockReset() .mockResolvedValueOnce(jsonResponse(SESSION)) @@ -156,6 +155,32 @@ describe('push preview JMAP failures', () => { ] })); expect(await callRoute('a')).toEqual({ status: 200, body: { email: null, unreadTotal: 0, slot: 0 } }); }); + + // `total` is optional in a JMAP Email/query response. + it('counts the ids when the server leaves out the total', async () => { + fetchJmapServer.mockReset() + .mockResolvedValueOnce(jsonResponse(SESSION)) + .mockResolvedValueOnce(jsonResponse({ methodResponses: [['Mailbox/query', { ids: ['inbox'] }, 'mb']] })) + .mockResolvedValueOnce(jsonResponse({ methodResponses: [ + ['Email/query', { ids: [] }, 'eq'], + ['Email/get', { list: [] }, 'eg'], + ] })); + expect(await callRoute('a')).toEqual({ status: 200, body: { email: null, unreadTotal: 0, slot: 0 } }); + }); + + it('previews the named message even when the Inbox lookups fail', async () => { + fetchJmapServer.mockReset() + .mockResolvedValueOnce(jsonResponse(SESSION)) + .mockResolvedValueOnce(jsonResponse({ methodResponses: [['Mailbox/query', { ids: ['inbox'] }, 'mb']] })) + .mockResolvedValueOnce(jsonResponse({ methodResponses: [ + ['error', { type: 'serverFail' }, 'eq'], + ['error', { type: 'resultReference' }, 'eg'], + ['Email/get', { list: [{ id: 'd', threadId: 'td' }] }, 'delivered'], + ] })); + expect(await callRoute('a', 'd')).toEqual({ + status: 200, body: { email: { id: 'd', threadId: 'td' }, unreadTotal: 1, slot: 0 }, + }); + }); });