From b4f12b6786d9e57fbcf59986e85cd1838f3f56a2 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Mon, 7 Sep 2026 22:34:47 +0200 Subject: [PATCH] fix: resolve push previews for shared and group mailboxes #839 --- app/api/push/preview/route.ts | 11 ++- lib/__tests__/push-preview-route.test.ts | 111 +++++++++++++++++++++++ 2 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 lib/__tests__/push-preview-route.test.ts diff --git a/app/api/push/preview/route.ts b/app/api/push/preview/route.ts index 80e5c752a..8273df184 100644 --- a/app/api/push/preview/route.ts +++ b/app/api/push/preview/route.ts @@ -43,11 +43,18 @@ async function resolveTargetForAccount(accountId: string): Promise; + accounts?: Record; }; const mailAccountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail']; if (!session.apiUrl || !mailAccountId) return null; - if (mailAccountId !== accountId) return null; - return { authHeader: ctx.authHeader, apiUrl: session.apiUrl, accountId: mailAccountId, trusted }; + // Shared and group mailboxes are never the primary mail account - + // they only appear in session.accounts. Accept those too, or the + // SW falls back to a generic "New mail" notification for them. (#839) + const isSharedAccount = + accountId !== mailAccountId && + Object.prototype.hasOwnProperty.call(session.accounts ?? {}, accountId); + if (mailAccountId !== accountId && !isSharedAccount) return null; + return { authHeader: ctx.authHeader, apiUrl: session.apiUrl, accountId, trusted }; } catch { return null; } diff --git a/lib/__tests__/push-preview-route.test.ts b/lib/__tests__/push-preview-route.test.ts new file mode 100644 index 000000000..569da2a0c --- /dev/null +++ b/lib/__tests__/push-preview-route.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +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 })); + +vi.mock('@/lib/stalwart/auth-context', () => ({ + readStalwartAuthContextFromStore: (_store: unknown, slot: number) => + slot === 0 ? { serverUrl: 'https://mail.example.com/', authHeader: 'Bearer tok' } : null, +})); + +vi.mock('@/lib/stalwart/credentials', () => ({ + getStalwartCredentials: vi.fn(), +})); + +const fetchJmapServer = vi.fn(); +vi.mock('@/lib/stalwart/server-fetch', () => ({ + fetchJmapServer: (...args: unknown[]) => fetchJmapServer(...args), + isTrustedJmapServerUrl: async () => true, +})); + +vi.mock('@/lib/security/url-guard', () => ({ + DisallowedUrlError: class DisallowedUrlError extends Error {}, +})); + +function jsonResponse(data: unknown) { + return { ok: true, json: async () => data }; +} + +const SESSION = { + apiUrl: 'https://mail.example.com/jmap', + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'a' }, + accounts: { a: { name: 'me@example.com' }, g: { name: 'group@example.com' } }, +}; + +function mockJmap() { + fetchJmapServer.mockImplementation(async (url: string, init?: { body?: string }) => { + if (url.endsWith('/.well-known/jmap')) return jsonResponse(SESSION); + const body = JSON.parse(init?.body ?? '{}') as { methodCalls: [string, Record, string][] }; + const first = body.methodCalls[0]; + if (first[0] === 'Mailbox/query') { + return jsonResponse({ methodResponses: [['Mailbox/query', { ids: ['inbox'] }, 'mb']] }); + } + return jsonResponse({ + methodResponses: [ + ['Email/query', { ids: ['e1'], total: 1 }, 'eq'], + ['Email/get', { list: [{ id: 'e1', threadId: 't1', subject: 'Hi' }] }, 'eg'], + ], + }); + }); +} + +async function callRoute(accountId: string) { + const { GET } = await import('@/app/api/push/preview/route'); + const request = { + nextUrl: { searchParams: { get: (k: string) => (k === 'accountId' ? accountId : 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 route account resolution', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockJmap(); + }); + + it('resolves the primary mail account', async () => { + const { status } = await callRoute('a'); + expect(status).toBe(200); + }); + + it('resolves a shared/group account listed in session.accounts', async () => { + const { status, body } = await callRoute('g'); + + expect(status).not.toBe(401); + expect(status).toBe(200); + expect(body.email).toMatchObject({ id: 'e1' }); + + // The Inbox lookup must be scoped to the shared account, not the primary. + const mailboxQuery = fetchJmapServer.mock.calls + .map(([, init]) => (init as { body?: string })?.body) + .filter((b): b is string => typeof b === 'string') + .map((b) => JSON.parse(b) as { methodCalls: [string, Record, string][] }) + .find((b) => b.methodCalls[0][0] === 'Mailbox/query'); + expect(mailboxQuery?.methodCalls[0][1].accountId).toBe('g'); + }); + + it('rejects an account the session does not know', async () => { + const { status } = await callRoute('stranger'); + expect(status).toBe(401); + }); +});