From 1fc44b4813ab5e9834032c79e1d0aeb64622edc8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:08:20 +0100 Subject: [PATCH] fix: show the push notification for mail in an account shared into another signed-in login --- app/api/push/preview/route.ts | 14 ++- .../push-preview-route-owner.test.ts | 111 ++++++++++++++++++ 2 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 lib/__tests__/push-preview-route-owner.test.ts diff --git a/app/api/push/preview/route.ts b/app/api/push/preview/route.ts index bf19ccdcc..5a0578a36 100644 --- a/app/api/push/preview/route.ts +++ b/app/api/push/preview/route.ts @@ -31,10 +31,13 @@ interface ResolvedTarget { // When the SW passes ?accountId=, we need the slot whose JMAP session owns // that account - not just "the first signed-in slot", which is what // getStalwartCredentials() defaults to. Probe each candidate's session in -// parallel and return the first match. +// parallel and return the first match, the login whose own account it is +// ahead of the others: an account with a folder shared out is listed in the +// session of everyone it is shared with, who sees only that folder - not its +// Inbox, nor the message that just arrived there. async function resolveTargetForAccount(accountId: string): Promise { const cookieStore = await cookies(); - const probes: Promise[] = []; + const probes: Promise<{ target: ResolvedTarget; own: boolean } | null>[] = []; for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) { const ctx = readStalwartAuthContextFromStore(cookieStore, slot); if (!ctx) continue; @@ -61,7 +64,10 @@ async function resolveTargetForAccount(accountId: string): Promise r !== null) ?? null; + return (results.find((r) => r?.own) ?? results.find((r) => r !== null))?.target ?? null; } async function resolveDefaultTarget(creds: StalwartCredentials): Promise { diff --git a/lib/__tests__/push-preview-route-owner.test.ts b/lib/__tests__/push-preview-route-owner.test.ts new file mode 100644 index 000000000..6805caf9f --- /dev/null +++ b/lib/__tests__/push-preview-route-owner.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Two logins in one browser: slot 0 is Bob, slot 1 is Alice, and Alice shares +// a folder with Bob. Alice's account is then listed in Bob's session too, but +// Bob sees only the shared folder - not her Inbox, nor the message that just +// arrived there. + +vi.mock('next/server', () => ({ + NextResponse: { + json: (data: unknown, init?: { status?: number }) => ({ + json: async () => data, + status: init?.status ?? 200, + }), + }, +})); + +vi.mock('next/headers', () => ({ + cookies: async () => ({ get: () => undefined }), +})); + +vi.mock('@/lib/logger', () => ({ + logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() }, +})); + +vi.mock('@/lib/account-utils', () => ({ MAX_ACCOUNT_SLOTS: 2 })); + +const AUTH = ['Basic bob', 'Basic alice']; +vi.mock('@/lib/stalwart/auth-context', () => ({ + readStalwartAuthContextFromStore: (_store: unknown, slot: number) => + slot < 2 ? { serverUrl: 'https://mail.example.com', authHeader: AUTH[slot] } : null, +})); + +vi.mock('@/lib/stalwart/credentials', () => ({ + getStalwartCredentials: vi.fn(), +})); + +const SESSIONS: Record = { + 'Basic bob': { + apiUrl: 'https://mail.example.com/jmap/', + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'n' }, + accounts: { n: { name: 'bob@example.com' }, m: { name: 'alice@example.com' } }, + }, + 'Basic alice': { + apiUrl: 'https://mail.example.com/jmap/', + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'm' }, + accounts: { m: { name: 'alice@example.com' } }, + }, +}; + +const NEW_MAIL = { id: 'e9', threadId: 't9', subject: 'For Alice', from: [{ email: 'carol@example.com' }] }; + +const fetchJmapServer = vi.fn(async (url: string, init?: { headers?: Record; body?: string }) => { + const auth = init?.headers?.Authorization ?? ''; + const json = (data: unknown) => ({ ok: true, json: async () => data }); + if (url.endsWith('/.well-known/jmap')) return json(SESSIONS[auth]); + const isOwner = auth === 'Basic alice'; + const calls = (JSON.parse(init?.body ?? '{}') as { methodCalls: [string, Record, string][] }).methodCalls; + return json({ + methodResponses: calls.map(([name, , id]) => { + if (name === 'Mailbox/query') return [name, { ids: isOwner ? ['inbox'] : [] }, id]; + if (name === 'Email/query') return [name, { ids: isOwner ? ['e9'] : [], total: isOwner ? 1 : 0 }, id]; + return [name, { list: isOwner ? [NEW_MAIL] : [], notFound: isOwner ? [] : ['e9'] }, id]; + }), + }); +}); +vi.mock('@/lib/stalwart/server-fetch', () => ({ + fetchJmapServer: (...args: Parameters) => fetchJmapServer(...args), + isTrustedJmapServerUrl: async () => true, +})); + +vi.mock('@/lib/security/url-guard', () => ({ + DisallowedUrlError: class DisallowedUrlError extends Error {}, +})); + +async function callRoute(accountId: string, emailId: string | null) { + const { GET } = await import('@/app/api/push/preview/route'); + const request = { + nextUrl: { searchParams: { get: (k: string) => (k === 'accountId' ? accountId : k === 'emailId' ? emailId : null) } }, + }; + const res = (await GET(request as unknown as Parameters[0])) as unknown as { + status: number; + json: () => Promise>; + }; + return { status: res.status, body: await res.json() }; +} + +describe('push preview for an account shared into another login', () => { + beforeEach(() => { + fetchJmapServer.mockClear(); + }); + + it.each([ + ['the delivered message', 'e9'], + ['the newest unread Inbox message', null], + ])('reads %s through the login that owns the account', async (_label, emailId) => { + const { status, body } = await callRoute('m', emailId); + + expect(status).toBe(200); + // Through Bob's login the preview came back empty, and the service worker + // drops a push whose preview reports no mail: Alice got no notification. + expect(body.email).toMatchObject({ id: 'e9' }); + expect(body.unreadTotal).toBe(1); + expect(body.slot).toBe(1); + }); + + it('still resolves an account only another login shares in', async () => { + const { status, body } = await callRoute('n', 'e9'); + expect(status).toBe(200); + expect(body.slot).toBe(0); + }); +});