From 4bc5d4888801376874fe5bc2ffc2e30e5ff02e8b Mon Sep 17 00:00:00 2001 From: lucamzanon Date: Thu, 1 Oct 2026 12:44:14 +0200 Subject: [PATCH] fix(push): keep notification previews truthful when a lookup fails A JMAP method error comes back with HTTP 200. The preview route read a failed Mailbox/query or Email/query as an empty Inbox and answered "nothing unread", so the service worker dropped the push in silence. It now answers 502, and the worker shows its generic notification. When the server names the message that arrived and it cannot be read - deleted since, or filed where this login cannot see it - the route no longer substitutes the newest unread message in the Inbox, which was some other mail announced as if it had just arrived. It names no message; the unread total still comes back for the generic toast. --- app/api/push/preview/route.ts | 30 ++++++++- lib/__tests__/push-preview-route.test.ts | 77 +++++++++++++++++++++++- 2 files changed, 103 insertions(+), 4 deletions(-) diff --git a/app/api/push/preview/route.ts b/app/api/push/preview/route.ts index f58e2243e..8be988743 100644 --- a/app/api/push/preview/route.ts +++ b/app/api/push/preview/route.ts @@ -161,10 +161,16 @@ export async function GET(request: NextRequest) { }; const inboxBody = inboxData.methodResponses.find( - ([method]) => method === 'Mailbox/query', + ([method, , callId]) => method === 'Mailbox/query' && callId === 'mb', )?.[1] as { ids?: string[] } | undefined; - const inboxId = inboxBody?.ids?.[0]; + // A method-level JMAP error still has HTTP 200. Do not turn it into + // an empty Inbox: the service worker would silently discard the push. + if (!Array.isArray(inboxBody?.ids)) { + return NextResponse.json({ error: 'JMAP mailbox query failed' }, { status: 502 }); + } + + const inboxId = inboxBody.ids[0]; const emailProperties = ['id', 'threadId', 'from', 'subject', 'preview', 'receivedAt']; if (!inboxId && !requestedEmailId) { @@ -239,6 +245,19 @@ export async function GET(request: NextRequest) { methodResponses: [string, Record, string][]; }; + // 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 }); + } + } + type EmailLite = { id: string; threadId: string; @@ -270,6 +289,13 @@ export async function GET(request: NextRequest) { unreadTotal = Math.max(unreadTotal, 1); } email = delivered; + } else if (requestedEmailId) { + // The server named the message that arrived and it could not be read - + // deleted since, or filed where this login cannot see it. "Newest unread + // in the Inbox" is then some other message, and announcing it would be + // confidently wrong. Name no message: the unread total still comes back, + // so the SW posts its generic toast instead of staying silent. + email = null; } // Some servers build the preview from HTML without skipping