From 2bb07979962e008d05bc0cd004b8393585e9b772 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 05:26:43 +0100 Subject: [PATCH] feat: archive, mark read or reply right from a new mail notification A notification for one message gets buttons where the browser has room for them, and a click on its body works as before. The service worker has no login, so an open tab takes the action first, through the store actions. With no tab open a new same-origin route, /api/push/action, does Mark read and Archive with the context cookie. When the cookie is gone, the token has expired or the login can't be told apart, the message opens in the app instead. Reply opens the composer through a `?reply=1` link, with any text typed on the notification. - `public/sw.js`: buttons, asking open tabs, the route, `sync` handling - `app/api/push/action`: same-origin, checks the login and the account, honours the admin's Archive gate, files by the mirrored archive mode - `lib/pwa-notify`: the page's side, the mirror for the worker, and `requestOutboxSync`, which the offline queue calls - Background Sync, thin version: a `sync` event tells open tabs to flush Two push-preview-route assertions change: they match the whole answer for a named message, which now also carries `login`, what the action route checks so a stale notification can't act on whoever holds the cookie slot now. --- app/(main)/[locale]/layout.tsx | 2 + app/api/push/action/route.ts | 273 ++++++++++ app/api/push/preview/route.ts | 4 + components/mail/mail-app.tsx | 37 +- .../notification-actions-host.test.tsx | 124 +++++ .../providers/notification-actions-host.tsx | 56 ++ integration/tests/23-do-pwa-notify.spec.ts | 480 ++++++++++++++++++ lib/__tests__/deep-links.test.ts | 10 + lib/__tests__/push-action-route.test.ts | 313 ++++++++++++ lib/__tests__/push-preview-route.test.ts | 10 +- lib/__tests__/sw-harness.ts | 33 +- lib/__tests__/sw-notification-actions.test.ts | 363 +++++++++++++ lib/deep-links.ts | 7 +- lib/offline/__tests__/queue-sync.test.ts | 35 ++ lib/offline/queue.ts | 6 +- lib/pwa-notify/__tests__/outbox-sync.test.ts | 63 +++ lib/pwa-notify/__tests__/page-actions.test.ts | 175 +++++++ lib/pwa-notify/__tests__/worker-state.test.ts | 70 +++ lib/pwa-notify/login-fingerprint.ts | 13 + lib/pwa-notify/outbox-sync.ts | 24 + lib/pwa-notify/page-actions.ts | 121 +++++ lib/pwa-notify/worker-state.ts | 58 +++ locales/ar/common.json | 6 + locales/ca/common.json | 6 + locales/cs/common.json | 6 + locales/da/common.json | 6 + locales/de/common.json | 6 + locales/en/common.json | 6 + locales/es/common.json | 6 + locales/et/common.json | 6 + locales/fa/common.json | 6 + locales/fr/common.json | 6 + locales/he/common.json | 6 + locales/hu/common.json | 6 + locales/it/common.json | 6 + locales/ja/common.json | 6 + locales/ko/common.json | 6 + locales/lv/common.json | 6 + locales/mn/common.json | 6 + locales/nb/common.json | 6 + locales/nl/common.json | 6 + locales/pl/common.json | 6 + locales/pt/common.json | 6 + locales/ro/common.json | 6 + locales/ru/common.json | 6 + locales/sk/common.json | 6 + locales/th/common.json | 6 + locales/tr/common.json | 6 + locales/uk/common.json | 6 + locales/vi/common.json | 6 + locales/zh-TW/common.json | 6 + locales/zh/common.json | 6 + public/sw.js | 267 +++++++++- 53 files changed, 2707 insertions(+), 17 deletions(-) create mode 100644 app/api/push/action/route.ts create mode 100644 components/providers/__tests__/notification-actions-host.test.tsx create mode 100644 components/providers/notification-actions-host.tsx create mode 100644 integration/tests/23-do-pwa-notify.spec.ts create mode 100644 lib/__tests__/push-action-route.test.ts create mode 100644 lib/__tests__/sw-notification-actions.test.ts create mode 100644 lib/offline/__tests__/queue-sync.test.ts create mode 100644 lib/pwa-notify/__tests__/outbox-sync.test.ts create mode 100644 lib/pwa-notify/__tests__/page-actions.test.ts create mode 100644 lib/pwa-notify/__tests__/worker-state.test.ts create mode 100644 lib/pwa-notify/login-fingerprint.ts create mode 100644 lib/pwa-notify/outbox-sync.ts create mode 100644 lib/pwa-notify/page-actions.ts create mode 100644 lib/pwa-notify/worker-state.ts diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 9164a715f..764ae5547 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -27,6 +27,7 @@ import { PluginOAuthCallbackListener } from "@/components/providers/plugin-oauth import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; import { AppUpdatePrompt } from "@/components/app-update-prompt"; import { OfflineHost } from "@/components/offline/offline-host"; +import { NotificationActionsHost } from "@/components/providers/notification-actions-host"; import { DevicePolicyHost } from "@/components/device/device-policy-host"; import { PushNotificationPrompt } from "@/components/push-notification-prompt"; import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt"; @@ -103,6 +104,7 @@ export default async function LocaleLayout({ {/* Offline mode isn't in Lite (no service worker there). */} {!IS_LITE && } + {!IS_LITE && } {/* Lite is a static bundle: no server keeps an archive there. */} diff --git a/app/api/push/action/route.ts b/app/api/push/action/route.ts new file mode 100644 index 000000000..169b7f7e8 --- /dev/null +++ b/app/api/push/action/route.ts @@ -0,0 +1,273 @@ +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; +import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; +import { configManager } from '@/lib/admin/config-manager'; +import { resolvePolicy } from '@/lib/admin/policy-scopes'; +import { strictDeviceSubject } from '@/lib/admin/server-device-policy'; +import { forwardedClientHeaders, loginClientAddress } from '@/lib/auth/client-address'; +import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; +import { UPSTREAM_JSON_MAX_BYTES, UpstreamBodyTooLargeError, readBodyCapped, readJsonCapped } from '@/lib/security/capped-body'; +import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; +import { DisallowedUrlError } from '@/lib/security/url-guard'; +import { readStalwartAuthContextFromStore, type StalwartAuthContext } from '@/lib/stalwart/auth-context'; +import { rebaseApiUrl } from '@/lib/stalwart/jmap-api'; +import { fetchJmapServer, isTrustedJmapServerUrl } from '@/lib/stalwart/server-fetch'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const USING = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail']; +const MAIL_CAPABILITY = 'urn:ietf:params:jmap:mail'; +const MAX_BODY_BYTES = 4096; +/** RFC 8620 ids, which is all an email or account id can be. */ +const JMAP_ID = /^[A-Za-z0-9_-]{1,255}$/; + +type ArchiveMode = 'single' | 'year' | 'month'; + +interface ActionRequest { + action: 'mark-read' | 'archive'; + accountId: string; + emailId: string; + login: string; + archiveMode: ArchiveMode; + timeZone?: string; +} + +interface JmapMailbox { + id: string; + name: string; + parentId?: string | null; + role?: string | null; + myRights?: { mayAddItems?: boolean }; +} + +type MethodResponse = [string, Record, string]; + +const fail = (status: number, error: string) => + NextResponse.json({ error }, { status, headers: { 'Cache-Control': 'no-store' } }); + +function validTimeZone(value: unknown): string | undefined { + if (typeof value !== 'string' || value.length > 64) return undefined; + try { + new Intl.DateTimeFormat('en-US', { timeZone: value }); + return value; + } catch { + return undefined; + } +} + +async function readActionRequest(request: Request): Promise { + const bytes = await readBodyCapped(request, MAX_BODY_BYTES); + if (!bytes) return null; + let body: Record; + try { + body = JSON.parse(new TextDecoder().decode(bytes)) as Record; + } catch { + return null; + } + if (!body || typeof body !== 'object') return null; + const { action, accountId, emailId, login, archiveMode } = body; + if (action !== 'mark-read' && action !== 'archive') return null; + if (typeof accountId !== 'string' || !JMAP_ID.test(accountId)) return null; + if (typeof emailId !== 'string' || !JMAP_ID.test(emailId)) return null; + if (typeof login !== 'string' || !login) return null; + return { + action, + accountId, + emailId, + login, + archiveMode: archiveMode === 'year' || archiveMode === 'month' ? archiveMode : 'single', + timeZone: validTimeZone(body.timeZone), + }; +} + +/** The signed-in logins this browser's cookies name as `login`: the notification's own, or none. */ +async function loginsNamed(login: string): Promise { + const cookieStore = await cookies(); + const found: StalwartAuthContext[] = []; + for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) { + const ctx = readStalwartAuthContextFromStore(cookieStore, slot); + if (ctx && (await loginFingerprint(ctx.serverUrl, ctx.username)) === login) found.push(ctx); + } + return found; +} + +/** + * The Archive gate for this login's scopes (#1257). The worker's copy of the + * setting decides whether a button shows; this decides whether it works. When + * the login's scopes can't be worked out, the action is refused. + */ +async function archiveAllowed(ctx: StalwartAuthContext, trusted: boolean): Promise { + await configManager.ensureLoaded(); + const subject = await strictDeviceSubject({ ...ctx, trusted }); + if (!subject) return false; + return resolvePolicy(configManager.getPolicy(), subject).policy.features?.archiveActionEnabled !== false; +} + +/** The year and month a message falls in where the user is. */ +function dateParts(receivedAt: string, timeZone: string | undefined): { year: string; month: string } { + const parts = new Intl.DateTimeFormat('en-CA', { timeZone: timeZone ?? 'UTC', year: 'numeric', month: '2-digit' }) + .formatToParts(new Date(receivedAt)); + return { + year: parts.find((part) => part.type === 'year')?.value ?? '', + month: parts.find((part) => part.type === 'month')?.value ?? '', + }; +} + +/** + * Where Archive files a message, as the app's own Archive does + * (JMAPClient.archiveEmails, ensureArchiveMailbox): the Archive folder, or a + * year or year/month folder in it, creating what isn't there. Creation ids + * resolve in the request that carries the Mailbox/set. + */ +function archiveDestination( + mailboxes: JmapMailbox[], + mode: ArchiveMode, + receivedAt: string, + timeZone: string | undefined, +): { destination: string; create: Record> } { + const create: Record> = {}; + const archive = mailboxes.find((m) => m.role === 'archive') + ?? mailboxes.find((m) => m.name.toLowerCase() === 'archive'); + let destination = archive?.id ?? '#archive'; + if (!archive) create.archive = { name: 'Archive', parentId: null, role: 'archive' }; + + const descend = (name: string, cid: string) => { + const existing = destination.startsWith('#') + ? undefined + : mailboxes.find((m) => m.parentId === destination && m.name === name); + if (existing) { + destination = existing.id; + return; + } + create[cid] = { name, parentId: destination }; + destination = `#${cid}`; + }; + if (mode !== 'single') { + const { year, month } = dateParts(receivedAt, timeZone); + descend(year, 'year'); + if (mode === 'month') descend(month, 'month'); + } + return { destination, create }; +} + +/** + * POST /api/push/action + * + * A notification's Mark read or Archive when no tab is open to do it. The + * service worker has no login of its own, only the context cookie, whose + * token a page refreshes and which ends with the browser session. Every + * refusal here means the same to the worker: open the message in the app. + * + * Like /api/push/preview it never hands the worker a credential. The cookie + * is sent because the request is same-origin; the proxy and this handler both + * refuse any other. Plugins run in pages, so their move and read hooks don't + * see an action taken here. + */ +export async function POST(request: NextRequest) { + const rejected = rejectCrossOriginRequest(request); + if (rejected) return rejected; + try { + const body = await readActionRequest(request); + if (!body) return fail(400, 'Invalid request'); + + const logins = await loginsNamed(body.login); + if (logins.length === 0) return fail(401, 'Not authenticated'); + if (logins.length > 1) return fail(409, 'Login is in more than one cookie slot'); + const ctx = logins[0]; + + const serverUrl = ctx.serverUrl.replace(/\/+$/, ''); + const trusted = await isTrustedJmapServerUrl(serverUrl); + const forwarded = forwardedClientHeaders(loginClientAddress(request)); + const sessionRes = await fetchJmapServer(`${serverUrl}/.well-known/jmap`, { + headers: { Authorization: ctx.authHeader, ...forwarded }, + }, trusted); + // An expired token (only a page refreshes it), or a password changed since. + if (sessionRes.status === 401 || sessionRes.status === 403) return fail(401, 'Not authenticated'); + if (!sessionRes.ok) return fail(502, 'JMAP session failed'); + const session = (await readJsonCapped(sessionRes, UPSTREAM_JSON_MAX_BYTES)) as { + apiUrl?: string; + primaryAccounts?: Record; + accounts?: Record }>; + }; + const apiUrl = rebaseApiUrl(session, serverUrl); + if (!apiUrl) return fail(502, 'JMAP session failed'); + // The worker named this account; it has to be one this login can reach. + const { accountId, emailId } = body; + const account = session.accounts && Object.prototype.hasOwnProperty.call(session.accounts, accountId) + ? session.accounts[accountId] + : undefined; + if (!account && session.primaryAccounts?.[MAIL_CAPABILITY] !== accountId) { + return fail(403, 'Account is not in this login'); + } + + const jmap = async (methodCalls: unknown[]): Promise => { + const res = await fetchJmapServer(apiUrl, { + method: 'POST', + headers: { Authorization: ctx.authHeader, 'Content-Type': 'application/json', ...forwarded }, + body: JSON.stringify({ using: USING, methodCalls }), + }, trusted); + if (!res.ok) return null; + const data = (await readJsonCapped(res, UPSTREAM_JSON_MAX_BYTES)) as { methodResponses?: MethodResponse[] }; + return Array.isArray(data.methodResponses) ? data.methodResponses : null; + }; + const result = (responses: MethodResponse[], name: string, id: string) => + responses.find(([method, , callId]) => method === name && callId === id)?.[1]; + + let update: Record; + let create: Record> = {}; + if (body.action === 'mark-read') { + update = { 'keywords/$seen': true }; + } else { + if (!(await archiveAllowed(ctx, trusted))) return fail(403, 'Archive is turned off'); + const read = await jmap([ + ['Mailbox/get', { accountId, ids: null, properties: ['id', 'name', 'parentId', 'role', 'myRights'] }, 'mailboxes'], + ['Email/get', { accountId, ids: [emailId], properties: ['id', 'receivedAt'] }, 'email'], + ]); + if (!read) return fail(502, 'JMAP request failed'); + const mailboxes = result(read, 'Mailbox/get', 'mailboxes')?.list as JmapMailbox[] | undefined; + const emails = result(read, 'Email/get', 'email')?.list as { receivedAt?: string }[] | undefined; + if (!Array.isArray(mailboxes) || !Array.isArray(emails)) return fail(502, 'JMAP request failed'); + const receivedAt = emails[0]?.receivedAt; + if (!receivedAt) return fail(404, 'Message not found'); + const planned = archiveDestination(mailboxes, body.archiveMode, receivedAt, body.timeZone); + // Only an explicit refusal stops it, as in the app. + if (planned.create.archive && account?.accountCapabilities?.[MAIL_CAPABILITY]?.mayCreateTopLevelMailbox === false) { + return fail(403, 'Archive folder is missing'); + } + if (mailboxes.find((m) => m.id === planned.destination)?.myRights?.mayAddItems === false) { + return fail(403, 'Archive folder is read-only'); + } + create = planned.create; + update = { mailboxIds: { [planned.destination]: true } }; + } + + const written = await jmap([ + ...(Object.keys(create).length > 0 ? [['Mailbox/set', { accountId, create }, 'folders']] : []), + ['Email/set', { accountId, update: { [emailId]: update } }, 'write'], + ]); + if (!written) return fail(502, 'JMAP request failed'); + const set = result(written, 'Email/set', 'write') as { + updated?: Record | null; + notUpdated?: Record | null; + } | undefined; + if (set?.updated && Object.prototype.hasOwnProperty.call(set.updated, emailId)) { + return NextResponse.json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } }); + } + if (set?.notUpdated?.[emailId]?.type === 'notFound') return fail(404, 'Message not found'); + return fail(502, 'JMAP server refused the change'); + } catch (error) { + if (error instanceof DisallowedUrlError) { + logger.warn('push action refused non-public server address', { error: error.message }); + return fail(502, 'JMAP server address is not allowed'); + } + if (error instanceof UpstreamBodyTooLargeError) { + logger.warn('push action: JMAP response too large', { maxBytes: error.maxBytes }); + return fail(502, 'JMAP server response is too large'); + } + const err = error as Error & { cause?: { code?: string; message?: string } }; + logger.error('push action failed', { error: err?.message ?? 'Unknown error', causeCode: err?.cause?.code }); + return fail(500, 'Internal error'); + } +} diff --git a/app/api/push/preview/route.ts b/app/api/push/preview/route.ts index 8e48073ba..c862eeb99 100644 --- a/app/api/push/preview/route.ts +++ b/app/api/push/preview/route.ts @@ -14,6 +14,7 @@ import { forwardedClientHeaders, loginClientAddress } from '@/lib/auth/client-ad import { DisallowedUrlError } from '@/lib/security/url-guard'; import { UPSTREAM_JSON_MAX_BYTES, UpstreamBodyTooLargeError, readJsonCapped } from '@/lib/security/capped-body'; import { serverDevicePolicy } from '@/lib/admin/server-device-policy'; +import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -359,6 +360,9 @@ export async function GET(request: NextRequest) { unreadTotal, ...(redacted ? { redacted: true } : {}), ...(target.slot !== undefined ? { slot: target.slot } : {}), + // For the notification's buttons, which must not land on whoever signs + // into this slot later (app/api/push/action). + ...(target.slot !== undefined && email ? { login: await loginFingerprint(target.serverUrl, target.username) } : {}), }, { headers: { // SW already gates on its own logic - don't let push events get diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index c69d7c2c7..3f6cf4e1d 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -142,6 +142,8 @@ import { usePluginStore } from "@/stores/plugin-store"; import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot"; import { canDownload } from "@/lib/device-policy"; import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session"; +import { setNotificationReplyOpener } from "@/lib/pwa-notify/page-actions"; +import { consumePendingReplyText } from "@/lib/pwa-notify/worker-state"; import { INTERNAL_MAILTO_EVENT, type ParsedMailto } from "@/lib/protocol-handlers/mailto"; import { PLUGIN_REPLY_EVENT, type PluginReplyRequest } from "@/lib/plugin-sandbox/host-api-compose"; import { onOpenEmail, type OpenEmailHandler, type OpenEmailOutcome } from "@/lib/plugin-sandbox/open-email"; @@ -257,6 +259,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const [showComposer, setShowComposer] = useState(false); const [composerMode, setComposerMode] = useState<'compose' | 'reply' | 'replyAll' | 'forward'>('compose'); const [composerDraftText, setComposerDraftText] = useState(""); + // A notification's Reply, waiting for its message to be the open one. + const [pendingNotificationReply, setPendingNotificationReply] = useState<{ emailId: string; text: string } | null>(null); const [pendingDraft, setPendingDraft] = useState(null); const [composerSessionId, setComposerSessionId] = useState(0); // Fields a plugin filled in through onBeforeCompose for the composer about @@ -1768,7 +1772,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } } }; - const applyMailDeepLink = async (link: MailDeepLink, opts?: { onLoad?: boolean }) => { + const applyMailDeepLink = async (link: MailDeepLink, opts?: { onLoad?: boolean; replyText?: string }) => { // The folder list before an account switch, if one happened: until the // new mailbox's folders arrive the store still holds these (or nothing). let switchedFrom: Mailbox[] | null = null; @@ -1867,6 +1871,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } if (isMobile) setActiveView('viewer'); if (isTablet) setTabletListVisible(false); + if (link.reply) { + // A notification's Reply. The branch above may have skipped + // selecting, and the reply has to wait for the message to be open. + selectEmail(email); + setPendingNotificationReply({ emailId: email.id, text: opts?.replyText ?? await consumePendingReplyText(email.id) }); + } } finally { setLoadingEmail(false); } @@ -2764,6 +2774,31 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { return () => window.removeEventListener(PLUGIN_REPLY_EVENT, onPluginReply); }, []); + // A notification's Reply, from a `?reply=1` link or from the service worker + // when this tab is open. Both come through applyMailDeepLink and end here, + // once the message is the open one. + useEffect(() => { + if (!pendingNotificationReply || selectedEmail?.id !== pendingNotificationReply.emailId) return; + setPendingNotificationReply(null); + void pluginReplyRef.current.handleReply(pendingNotificationReply.text); + }, [pendingNotificationReply, selectedEmail?.id]); + // The Pro shell opens messages in tabs of its own, so it takes the link instead. + const composerOpenRef = useRef(showComposer); + composerOpenRef.current = showComposer; + useEffect(() => { + if (isEmbedded) return; + return setNotificationReplyOpener(({ emailId, accountId, slot, text }) => { + const open = () => void applyMailDeepLinkRef.current( + { kind: 'message', id: emailId, slot, ownerAccountId: accountId, reply: true }, + { replyText: text ?? '' }, + ); + // A live composer's text goes through its own "Save or discard?" first. + const requestClose = composerRequestCloseRef.current; + if (composerOpenRef.current && requestClose) requestClose(open); + else open(); + }); + }, [isEmbedded]); + // A plugin opening a message (api.ui.openEmail), as a click on a search // result entry naming one does: handleEmailSelect, set once it is defined. // Not inside the Pro shell, which keeps this tab mounted while hidden and diff --git a/components/providers/__tests__/notification-actions-host.test.tsx b/components/providers/__tests__/notification-actions-host.test.tsx new file mode 100644 index 000000000..99fa14b6f --- /dev/null +++ b/components/providers/__tests__/notification-actions-host.test.tsx @@ -0,0 +1,124 @@ +import { act, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NotificationActionsHost } from '../notification-actions-host'; + +// The tab's end of the service worker: it keeps the worker's copy of the +// labels and archive settings, runs notification buttons and flushes the +// outbox when the worker says so. + +const writeNotificationActionPrefs = vi.fn(async (..._args: unknown[]) => {}); +const runNotificationAction = vi.fn(async (_request: unknown, answer: (status: string) => void) => answer('done')); +const scheduleFlushAll = vi.fn(); +let state = { isAuthenticated: true, archiveMode: 'month', archiveEnabled: true }; + +vi.mock('@/lib/pwa-notify/worker-state', () => ({ + writeNotificationActionPrefs: (...args: unknown[]) => writeNotificationActionPrefs(...args), +})); +vi.mock('@/lib/pwa-notify/page-actions', async () => { + const actual = await vi.importActual('@/lib/pwa-notify/page-actions'); + return { isNotificationActionRequest: actual.isNotificationActionRequest, runNotificationAction: (...args: Parameters) => runNotificationAction(...args) }; +}); +vi.mock('@/lib/offline/offline-mode', () => ({ scheduleFlushAll: (...args: unknown[]) => scheduleFlushAll(...args) })); +vi.mock('@/lib/archive-gate', () => ({ useArchiveActionEnabled: () => state.archiveEnabled })); +vi.mock('@/stores/auth-store', () => ({ + useAuthStore: (select: (s: { isAuthenticated: boolean }) => unknown) => select({ isAuthenticated: state.isAuthenticated }), +})); +vi.mock('@/stores/settings-store', () => ({ + useSettingsStore: (select: (s: { archiveMode: string }) => unknown) => select({ archiveMode: state.archiveMode }), +})); +vi.mock('@/stores/email-store', () => ({ useEmailStore: { getState: () => ({}) } })); +vi.mock('@/stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts: [] }) } })); + +const worker = new EventTarget(); +const fromWorker = (data: unknown, ports: unknown[] = []) => act(async () => { + const event = new Event('message'); + Object.assign(event, { data, ports }); + worker.dispatchEvent(event); +}); + +beforeEach(() => { + vi.clearAllMocks(); + state = { isAuthenticated: true, archiveMode: 'month', archiveEnabled: true }; + Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: worker }); +}); + +afterEach(() => { + Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: undefined }); +}); + +describe('the mirror for the worker', () => { + it('writes the labels and archive settings once signed in', () => { + render(); + expect(writeNotificationActionPrefs).toHaveBeenCalledWith({ + archiveEnabled: true, + archiveMode: 'month', + labels: { archive: 'action_archive', markRead: 'action_mark_read', reply: 'action_reply', replyPlaceholder: 'reply_placeholder' }, + }); + }); + + it('writes nothing while signed out', () => { + state.isAuthenticated = false; + render(); + expect(writeNotificationActionPrefs).not.toHaveBeenCalled(); + }); + + it('writes again on signing in, since signing out deleted the store', () => { + state.isAuthenticated = false; + const { rerender } = render(); + state.isAuthenticated = true; + rerender(); + expect(writeNotificationActionPrefs).toHaveBeenCalledTimes(1); + }); + + it('writes again when the archive mode or the gate changes, and not otherwise', () => { + const { rerender } = render(); + rerender(); + expect(writeNotificationActionPrefs).toHaveBeenCalledTimes(1); + state.archiveMode = 'year'; + rerender(); + expect(writeNotificationActionPrefs).toHaveBeenCalledTimes(2); + state.archiveEnabled = false; + rerender(); + expect(writeNotificationActionPrefs).toHaveBeenCalledTimes(3); + expect(writeNotificationActionPrefs.mock.calls[2][0]).toMatchObject({ archiveEnabled: false, archiveMode: 'year' }); + }); +}); + +describe('messages from the worker', () => { + const request = { kind: 'notification-action', action: 'mark-read', emailId: 'e1', accountId: 'a', slot: 0, login: 'fp' }; + + it('runs a notification action and answers on its port', async () => { + render(); + const port = { postMessage: vi.fn() }; + await fromWorker(request, [port]); + expect(runNotificationAction.mock.calls[0][0]).toMatchObject({ action: 'mark-read', emailId: 'e1' }); + expect(port.postMessage).toHaveBeenCalledWith({ status: 'done' }); + }); + + it('ignores anything else', async () => { + render(); + await fromWorker({ kind: 'notificationclick', tag: 't' }); + await fromWorker({ ...request, action: 'delete' }); + await fromWorker(null); + expect(runNotificationAction).not.toHaveBeenCalled(); + expect(scheduleFlushAll).not.toHaveBeenCalled(); + }); + + it('flushes the outbox on the word of a sync event', async () => { + render(); + await fromWorker({ kind: 'outbox-flush' }); + expect(scheduleFlushAll).toHaveBeenCalledWith(0); + }); + + it('stops listening when it goes', async () => { + const { unmount } = render(); + unmount(); + await fromWorker({ kind: 'outbox-flush' }); + expect(scheduleFlushAll).not.toHaveBeenCalled(); + }); + + it('does nothing in a browser without service workers', () => { + Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: undefined }); + expect(() => render()).not.toThrow(); + }); +}); diff --git a/components/providers/notification-actions-host.tsx b/components/providers/notification-actions-host.tsx new file mode 100644 index 000000000..6231869f9 --- /dev/null +++ b/components/providers/notification-actions-host.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { useEffect } from 'react'; +import { useTranslations } from 'next-intl'; +import { useArchiveActionEnabled } from '@/lib/archive-gate'; +import { scheduleFlushAll } from '@/lib/offline/offline-mode'; +import { isNotificationActionRequest, runNotificationAction } from '@/lib/pwa-notify/page-actions'; +import { writeNotificationActionPrefs } from '@/lib/pwa-notify/worker-state'; +import { useAuthStore } from '@/stores/auth-store'; +import { useSettingsStore } from '@/stores/settings-store'; + +/** + * This tab's end of what the service worker can't do alone: it keeps the + * worker's copy of the labels and archive settings current, runs a + * notification's buttons and flushes the outbox on the worker's word. Not + * mounted in Lite (no service worker there). + */ +export function NotificationActionsHost() { + const t = useTranslations('pwaNotify'); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const archiveMode = useSettingsStore((s) => s.archiveMode); + const archiveEnabled = useArchiveActionEnabled(); + const archive = t('action_archive'); + const markRead = t('action_mark_read'); + const reply = t('action_reply'); + const replyPlaceholder = t('reply_placeholder'); + + // Signing out deletes the worker's store, so signing in writes it again. + useEffect(() => { + if (!isAuthenticated) return; + void writeNotificationActionPrefs({ + archiveEnabled, + archiveMode, + labels: { archive, markRead, reply, replyPlaceholder }, + }); + }, [isAuthenticated, archiveEnabled, archiveMode, archive, markRead, reply, replyPlaceholder]); + + useEffect(() => { + const worker = navigator.serviceWorker; + if (!worker) return; + const onMessage = (event: MessageEvent) => { + const message = event.data as { kind?: string } | null; + if (message?.kind === 'outbox-flush') { + scheduleFlushAll(0); + return; + } + if (message?.kind !== 'notification-action' || !isNotificationActionRequest(message)) return; + const port = event.ports[0]; + void runNotificationAction(message, (status) => port?.postMessage({ status })); + }; + worker.addEventListener('message', onMessage); + return () => worker.removeEventListener('message', onMessage); + }, []); + + return null; +} diff --git a/integration/tests/23-do-pwa-notify.spec.ts b/integration/tests/23-do-pwa-notify.spec.ts new file mode 100644 index 000000000..99298571c --- /dev/null +++ b/integration/tests/23-do-pwa-notify.spec.ts @@ -0,0 +1,480 @@ +import { test, expect, type BrowserContext, type Page, type Worker } from '@playwright/test'; +import { JMAP_URL } from './helpers/config'; +import { ENT, deliver, jmapAs, requireEntEnv, userPage } from './helpers/ent'; + +/** + * A notification's Archive, Mark read and Reply, and Background Sync, in + * Chromium with the real service worker of a production build, the real + * /api/push/preview and /api/push/action, and a VM 106 test Stalwart. + * + * Headless Chromium has no toasts to click, and a page can't make a browser + * send a trusted notificationclick. So the spec dispatches the event inside + * the real worker, on its real listener: an Event carrying `action`, the + * `notification` the browser really shows, and a waitUntil of its own + * (ExtendableEvent.waitUntil refuses a synthetic event). Everything after + * that is real: matchAll, postMessage to the page, the cookies on the route. + * What can't run this way is openWindow, focus and navigate, which want the + * browser's user activation, so those are recorded on the worker's side and + * the spec then opens the recorded URL itself. + * + * The account is ent-dave, whom the other specs leave alone: the one-message + * notification needs an Inbox with exactly one unread message. + */ + +test.describe.configure({ mode: 'serial' }); +test.setTimeout(180_000); +// The headless shell denies notifications whatever is granted; full Chromium +// in headless mode really shows them, buttons and all, to getNotifications. +test.use({ channel: 'chromium' }); + +const RUN = Date.now().toString(36); +let serial = 0; +const USER = ENT.dave; + +interface ShownNotification { + title: string; + data: Record; + actions: Array<{ action: string; title: string; type?: string }>; +} + +async function workerOf(context: BrowserContext, page: Page): Promise { + // A production build registers the worker; the dev server removes it. + await page.evaluate(() => navigator.serviceWorker.ready.then(() => undefined)); + await expect.poll(() => context.serviceWorkers().length, { timeout: 30_000 }).toBeGreaterThan(0); + const sw = context.serviceWorkers().at(-1)!; + // What needs the user activation a dispatched click doesn't have. + await sw.evaluate(() => { + const self_ = globalThis as unknown as { __opened: string[]; __posts: string[]; clients: Record; fetch: typeof fetch }; + if (self_.__opened) return; + self_.__opened = []; + self_.__posts = []; + self_.clients.openWindow = async (url: string) => { self_.__opened.push(url); return null; }; + const realFetch = self_.fetch.bind(globalThis); + self_.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === 'POST') self_.__posts.push(String(input)); + return realFetch(input, init); + }) as typeof fetch; + }); + return sw; +} + +/** The real listener, with what the browser would pass. Resolves when its waitUntil settled. */ +async function click(sw: Worker, action: string, data: Record, reply?: string): Promise { + await sw.evaluate(async ({ action: a, data: d, reply: r }) => { + const waits: Promise[] = []; + const event = new Event('notificationclick') as Event & Record; + Object.assign(event, { + action: a, + reply: r ?? null, + notification: { data: d, tag: 'spec', close() {} }, + waitUntil: (p: Promise) => waits.push(p), + }); + self.dispatchEvent(event); + await Promise.all(waits); + }, { action, data, reply }); +} + +async function pushed(sw: Worker, accountId: string, emailId: string): Promise { + return sw.evaluate(async ({ accountId: account, emailId: id }) => { + const waits: Promise[] = []; + const event = new Event('push') as Event & Record; + Object.assign(event, { + data: { json: () => ({ kind: 'jmap-email-push', accountId: account, emailIds: [id] }) }, + waitUntil: (p: Promise) => waits.push(p), + }); + self.dispatchEvent(event); + await Promise.all(waits); + const registration = (self as unknown as { registration: ServiceWorkerRegistration }).registration; + const [shown] = await registration.getNotifications({ tag: `bulwark-mail:${account}` }); + return { title: shown.title, data: shown.data, actions: [...(shown as unknown as { actions: ShownNotification['actions'] }).actions] }; + }, { accountId, emailId }); +} + +async function opened(sw: Worker): Promise { + return sw.evaluate(() => (globalThis as unknown as { __opened: string[] }).__opened.splice(0)); +} + +async function routePosts(sw: Worker): Promise { + return sw.evaluate(() => (globalThis as unknown as { __posts: string[] }).__posts.splice(0)); +} + +/** Everything unread in the Inbox read, then one new message: what makes a notification name it. */ +async function oneUnread(): Promise<{ id: string; subject: string; accountId: string }> { + const client = await jmapAs(USER); + const inbox = await client.mailboxByRole('inbox'); + const query = await client.request([ + ['Email/query', { accountId: client.accountId, filter: { inMailbox: inbox!.id, notKeyword: '$seen' } }, 'q'], + ]); + const unread: string[] = query.methodResponses[0][1].ids; + if (unread.length > 0) { + await client.request([['Email/set', { + accountId: client.accountId, + update: Object.fromEntries(unread.map((id) => [id, { 'keywords/$seen': true }])), + }, 's']]); + } + const subject = `Notify ${RUN} ${++serial}`; + await deliver({ from: ENT.bob, to: [USER], subject, text: 'Please answer.' }); + const email = await client.waitForEmail(subject, { mailboxId: inbox!.id, timeoutMs: 30_000 }); + return { id: email.id, subject, accountId: client.accountId }; +} + +async function serverCopy(emailId: string): Promise<{ keywords: Record; mailboxIds: Record; receivedAt: string }> { + const client = await jmapAs(USER); + const res = await client.request([['Email/get', { accountId: client.accountId, ids: [emailId], properties: ['keywords', 'mailboxIds', 'receivedAt'] }, 'g']]); + return res.methodResponses[0][1].list[0]; +} + +async function archiveFolderId(): Promise { + const client = await jmapAs(USER); + return (await client.mailboxByRole('archive'))?.id; +} + +test.beforeAll(() => requireEntEnv()); + +test.describe('with the app open', () => { + let page: Page; + let context: BrowserContext; + let sw: Worker; + + test.beforeAll(async ({ browser }) => { + page = await userPage(browser, USER); + context = page.context(); + await context.grantPermissions(['notifications']); + sw = await workerOf(context, page); + // The mirror reaches the worker's store after the app has started. + await expect.poll(() => sw.evaluate(async () => { + const cache = await caches.open('bulwark-push-state-v1'); + const hit = await cache.match(`${self.location.origin}/__push-state/actions`); + return hit ? (await hit.json()).labels?.archive : null; + }), { timeout: 30_000 }).toBe('Archive'); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test('a push for one message shows the buttons the browser has room for', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + expect(shown.title).toContain(ENT.bob.split('@')[0]); + expect(shown.data).toMatchObject({ kind: 'email', emailId: mail.id, accountId: mail.accountId }); + expect(typeof shown.data.login).toBe('string'); + const max = await sw.evaluate(() => (Notification as unknown as { maxActions: number }).maxActions); + const names = shown.actions.map((a) => a.action); + expect(names.length).toBe(Math.min(3, max)); + expect(names).toContain('reply'); + expect(names).toContain('archive'); + expect(shown.actions.find((a) => a.action === 'archive')?.title).toBe('Archive'); + // Chromium has `reply` on the event: the text field. + expect(shown.actions.find((a) => a.action === 'reply')?.type).toBe('text'); + }); + + test('a group of messages gets no buttons', async () => { + const client = await jmapAs(USER); + const first = await oneUnread(); + const subject = `Notify ${RUN} second`; + await deliver({ from: ENT.bob, to: [USER], subject, text: 'Another.' }); + const second = await client.waitForEmail(subject, { timeoutMs: 30_000 }); + const shown = await pushed(sw, first.accountId, second.id); + expect(shown.data.kind).toBe('mail-list'); + expect(shown.actions).toEqual([]); + }); + + test('Mark read goes to the open tab, which marks it in the list too, not to the route', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await routePosts(sw); + await click(sw, 'mark-read', shown.data); + await expect.poll(async () => (await serverCopy(mail.id)).keywords.$seen === true, { timeout: 15_000 }).toBe(true); + expect(await routePosts(sw)).toEqual([]); + expect(await opened(sw)).toEqual([]); + }); + + test('Archive goes to the open tab and files the message by the store', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await routePosts(sw); + await click(sw, 'archive', shown.data); + const client = await jmapAs(USER); + const inbox = await client.mailboxByRole('inbox'); + await expect.poll(async () => !(await serverCopy(mail.id)).mailboxIds[inbox!.id], { timeout: 20_000 }).toBe(true); + const archive = await archiveFolderId(); + expect(archive).toBeTruthy(); + expect(Object.keys((await serverCopy(mail.id)).mailboxIds)).toEqual([archive]); + expect(await routePosts(sw)).toEqual([]); + }); + + test('a body click still opens the message and runs nothing', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await routePosts(sw); + // focus() needs the activation a dispatched click lacks, so this ends in openWindow. + await click(sw, '', shown.data); + const urls = await opened(sw); + expect(urls).toHaveLength(1); + expect(urls[0]).toContain(`/mail/message/${mail.id}`); + expect(urls[0]).not.toContain('reply=1'); + expect(await routePosts(sw)).toEqual([]); + expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy(); + }); + + test('Reply opens the composer in the open tab, with the text typed on the notification', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await routePosts(sw); + await click(sw, 'reply', shown.data, `Typed on the toast ${RUN}`); + await expect.poll(async () => page.evaluate((subjectText) => { + const inputs = [...document.querySelectorAll('input')] as HTMLInputElement[]; + return inputs.some((input) => input.value === `Re: ${subjectText}`); + }, mail.subject), { timeout: 20_000 }).toBe(true); + await expect(page.getByText(`Typed on the toast ${RUN}`).first()).toBeVisible({ timeout: 10_000 }); + expect(await routePosts(sw)).toEqual([]); + expect(await opened(sw)).toEqual([]); + }); +}); + +test.describe('Background Sync', () => { + test('a queued change registers the tag, and a sync event makes a tab that missed `online` flush it', async ({ browser }) => { + const page = await userPage(browser, USER); + const context = page.context(); + await context.grantPermissions(['notifications', 'background-sync']); + const sw = await workerOf(context, page); + await page.addInitScript(() => { + const w = window as unknown as { __syncTags: string[] }; + w.__syncTags = []; + const register = SyncManager.prototype.register; + SyncManager.prototype.register = function patched(tag: string) { + w.__syncTags.push(tag); + return register.call(this, tag); + }; + }); + await page.goto('/settings/backup/offline'); + const toggle = page.getByTestId('offline-switch'); + await expect(toggle).toBeVisible({ timeout: 30_000 }); + if ((await toggle.getAttribute('aria-checked')) !== 'true') await toggle.click(); + await expect(page.getByTestId('offline-status')).toContainText('updated', { timeout: 180_000 }); + await page.goto('/mail'); + await expect(page.locator('[data-testid="email-list-item"]').first()).toBeVisible({ timeout: 60_000 }); + + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + + // The mail server is gone, the browser still online: no `online` event will come. + const block = `${new URL(JMAP_URL).origin}/**`; + await context.route(block, (route) => route.abort('internetdisconnected')); + await page.keyboard.press('Shift+G'); + await expect(page.getByTestId('offline-banner')).toContainText("The mail server can't be reached", { timeout: 60_000 }); + + // The tab queues the change, as it does for its own Mark read button. + await click(sw, 'mark-read', shown.data); + await expect.poll(() => page.evaluate(() => (window as unknown as { __syncTags: string[] }).__syncTags)).toContain('bulwark-outbox'); + expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy(); + + await context.unroute(block); + await sw.evaluate(async () => { + const waits: Promise[] = []; + const event = new Event('sync') as Event & Record; + Object.assign(event, { tag: 'bulwark-outbox', waitUntil: (p: Promise) => waits.push(p) }); + self.dispatchEvent(event); + await Promise.all(waits); + }); + await expect.poll(async () => (await serverCopy(mail.id)).keywords.$seen === true, { timeout: 30_000 }).toBe(true); + + // Offline mode stays local to this context, which ends here. + await context.close(); + }); +}); + +test.describe('with no tab open', () => { + let page: Page; + let context: BrowserContext; + let sw: Worker; + + /** A blank tab keeps the context alive: it's outside the worker's scope. */ + async function closeTheApp(): Promise { + const blank = await context.newPage(); + await page.close(); + await expect.poll(() => sw.evaluate(async () => (await (self as unknown as { clients: Clients }).clients.matchAll({ type: 'window', includeUncontrolled: true })).length)).toBe(0); + return blank; + } + + test.beforeEach(async ({ browser }) => { + page = await userPage(browser, USER); + context = page.context(); + await context.grantPermissions(['notifications']); + sw = await workerOf(context, page); + await expect.poll(() => sw.evaluate(async () => { + const cache = await caches.open('bulwark-push-state-v1'); + return !!(await cache.match(`${self.location.origin}/__push-state/actions`)); + }), { timeout: 30_000 }).toBe(true); + }); + + test.afterEach(async () => { + await context.close(); + }); + + test('Mark read goes through the route on the context cookie', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await closeTheApp(); + await routePosts(sw); + await click(sw, 'mark-read', shown.data); + expect((await routePosts(sw)).at(-1)).toContain('/api/push/action'); + expect((await serverCopy(mail.id)).keywords.$seen).toBe(true); + expect(await opened(sw)).toEqual([]); + }); + + test('Archive goes through the route, to the folder the app uses', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await closeTheApp(); + await click(sw, 'archive', shown.data); + const archive = await archiveFolderId(); + expect(archive).toBeTruthy(); + expect(Object.keys((await serverCopy(mail.id)).mailboxIds)).toEqual([archive]); + expect(await opened(sw)).toEqual([]); + }); + + test('Archive follows the mirrored mode into year and month folders', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await sw.evaluate(async () => { + const cache = await caches.open('bulwark-push-state-v1'); + const key = `${self.location.origin}/__push-state/actions`; + const prefs = await (await cache.match(key))!.json(); + await cache.put(key, new Response(JSON.stringify({ ...prefs, archiveMode: 'month' }))); + }); + await closeTheApp(); + await click(sw, 'archive', shown.data); + const client = await jmapAs(USER); + const copy = await serverCopy(mail.id); + const [folderId] = Object.keys(copy.mailboxIds); + const folders = await client.mailboxes(); + const month = folders.find((f) => f.id === folderId)!; + const year = folders.find((f) => f.id === month.parentId)!; + const when = new Date(copy.receivedAt); + expect(month.name).toBe(String(when.getMonth() + 1).padStart(2, '0')); + expect(year.name).toBe(String(when.getFullYear())); + expect(year.parentId).toBe(await archiveFolderId()); + }); + + test('Archive makes the Archive, year and month folders on an account without them', async () => { + const client = await jmapAs(USER); + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await sw.evaluate(async () => { + const cache = await caches.open('bulwark-push-state-v1'); + const key = `${self.location.origin}/__push-state/actions`; + const prefs = await (await cache.match(key))!.json(); + await cache.put(key, new Response(JSON.stringify({ ...prefs, archiveMode: 'month' }))); + }); + await closeTheApp(); + // Out of the way, so the account has no Archive for the moment. + const old = await client.mailboxByRole('archive'); + const oldName = `Old archive ${RUN}`; + if (old) { + const renamed = await client.request([['Mailbox/set', { accountId: client.accountId, update: { [old.id]: { name: oldName, role: null } } }, 'r']]); + expect(renamed.methodResponses[0][1].updated).toHaveProperty(old.id); + } + + await click(sw, 'archive', shown.data); + + expect(await opened(sw)).toEqual([]); + const created = await client.mailboxByRole('archive'); + expect(created?.name).toBe('Archive'); + expect(created?.id).not.toBe(old?.id); + const copy = await serverCopy(mail.id); + const folders = await client.mailboxes(); + const month = folders.find((f) => f.id === Object.keys(copy.mailboxIds)[0])!; + const year = folders.find((f) => f.id === month.parentId)!; + expect(year.parentId).toBe(created!.id); + + if (old) { + // Only this spec's messages live in ent-dave's Archive. + await client.request([['Mailbox/set', { accountId: client.accountId, destroy: [old.id], onDestroyRemoveEmails: true }, 'd']]); + } + }); + + test('with the context cookie gone the message opens instead, and stays as it was', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await closeTheApp(); + const jar = await context.cookies(); + await context.clearCookies({ name: /^jmap_stalwart_ctx/ }); + expect(jar.some((c) => c.name.startsWith('jmap_stalwart_ctx'))).toBe(true); + await routePosts(sw); + await click(sw, 'mark-read', shown.data); + expect((await routePosts(sw)).at(-1)).toContain('/api/push/action'); + const urls = await opened(sw); + expect(urls).toHaveLength(1); + expect(urls[0]).toContain(`/mail/message/${mail.id}`); + expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy(); + }); + + test('a notification from a login no longer signed in acts on nothing', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + await closeTheApp(); + await click(sw, 'mark-read', { ...shown.data, login: 'someone-else' }); + expect(await opened(sw)).toHaveLength(1); + expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy(); + }); + + test('Reply opens the link, the composer opens once with the typed text, and a reload leaves it shut', async () => { + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + const blank = await closeTheApp(); + await click(sw, 'reply', shown.data, `Typed while closed ${RUN}`); + const [url] = await opened(sw); + expect(url).toContain(`/mail/message/${mail.id}`); + expect(url).toContain('reply=1'); + expect(url).not.toContain('Typed'); + + page = await context.newPage(); + await page.goto(url); + await blank.close(); + const replyInput = () => page.evaluate((subjectText) => { + const inputs = [...document.querySelectorAll('input')] as HTMLInputElement[]; + return inputs.some((input) => input.value === `Re: ${subjectText}`); + }, mail.subject); + await expect.poll(replyInput, { timeout: 45_000 }).toBe(true); + await expect(page.getByText(`Typed while closed ${RUN}`).first()).toBeVisible({ timeout: 10_000 }); + expect(page.url()).not.toContain('reply=1'); + + // Leave the draft and come back: the link's job is done. + await page.reload(); + await expect(page.getByText('Please answer.').first()).toBeVisible({ timeout: 45_000 }); + expect(await replyInput()).toBe(false); + }); +}); + +test.describe('the route, as a page or another site would call it', () => { + test('refuses a cross-site POST at the gate, with the cookies on it', async ({ browser }) => { + const page = await userPage(browser, USER); + const mail = await oneUnread(); + const res = await page.request.post('/api/push/action', { + headers: { 'Sec-Fetch-Site': 'cross-site' }, + data: { action: 'mark-read', accountId: mail.accountId, emailId: mail.id, login: 'x' }, + }); + expect(res.status()).toBe(403); + expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy(); + await page.context().close(); + }); + + test('refuses another account of the same login, and answers 401 without cookies', async ({ browser }) => { + const page = await userPage(browser, USER); + await page.context().grantPermissions(['notifications']); + const sw = await workerOf(page.context(), page); + const mail = await oneUnread(); + const shown = await pushed(sw, mail.accountId, mail.id); + const post = (over: Record) => page.request.post('/api/push/action', { + data: { action: 'mark-read', accountId: mail.accountId, emailId: mail.id, login: shown.data.login, ...over }, + }); + expect((await post({ accountId: 'zzzzzz' })).status()).toBe(403); + expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy(); + await page.context().clearCookies({ name: /^jmap_stalwart_ctx/ }); + expect((await post({})).status()).toBe(401); + expect((await serverCopy(mail.id)).keywords.$seen).toBeFalsy(); + await page.context().close(); + }); +}); diff --git a/lib/__tests__/deep-links.test.ts b/lib/__tests__/deep-links.test.ts index c9696f162..0dc59db84 100644 --- a/lib/__tests__/deep-links.test.ts +++ b/lib/__tests__/deep-links.test.ts @@ -185,6 +185,16 @@ describe('mail paths', () => { expect(parseMailPath(['thread', 't1'])).toEqual({ kind: 'thread', id: 't1', accountId: undefined }); }); + it("reads a notification's reply flag on a message link, and only there", () => { + expect(parseMailPath(['message', 'm1'], new URLSearchParams('slot=1&owner=a&reply=1'))).toMatchObject({ + kind: 'message', id: 'm1', slot: 1, ownerAccountId: 'a', reply: true, + }); + expect(parseMailPath(['message', 'm1'], new URLSearchParams('reply=0'))).not.toHaveProperty('reply'); + expect(parseMailPath(['message', 'm1'])).not.toHaveProperty('reply'); + expect(parseMailPath(['folder', 'inbox'], new URLSearchParams('reply=1'))).not.toHaveProperty('reply'); + expect(parseMailPath(['thread', 't1'], new URLSearchParams('reply=1'))).not.toHaveProperty('reply'); + }); + it('reads the fullscreen marker on message links', () => { expect(parseMailPath(['message', 'm1'], new URLSearchParams('view=fullscreen'))).toEqual({ kind: 'message', diff --git a/lib/__tests__/push-action-route.test.ts b/lib/__tests__/push-action-route.test.ts new file mode 100644 index 000000000..3658b03b2 --- /dev/null +++ b/lib/__tests__/push-action-route.test.ts @@ -0,0 +1,313 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; +import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; + +// POST /api/push/action: a notification's Mark read and Archive with no tab +// open. Every refusal must leave the mail alone: the worker opens the message +// in the app instead. + +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: 3 })); + +type Login = { serverUrl: string; username: string; authHeader: string }; +let LOGINS: Array = []; +vi.mock('@/lib/stalwart/auth-context', () => ({ + readStalwartAuthContextFromStore: (_store: unknown, slot: number) => LOGINS[slot] ?? null, +})); + +const ALICE: Login = { serverUrl: 'https://one.example/', username: 'alice@example.org', authHeader: 'Bearer alice' }; +const BOB: Login = { serverUrl: 'https://one.example', username: 'bob@example.org', authHeader: 'Bearer bob' }; + +let policy: Record = {}; +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { ensureLoaded: async () => {}, getPolicy: () => policy }, +})); +let subject: { email: string; groups: string[] } | null = { email: 'alice@example.org', groups: [] }; +vi.mock('@/lib/admin/server-device-policy', () => ({ strictDeviceSubject: async () => subject })); + +type Calls = Array<[string, Record, string]>; +const writes: Array<{ auth: string; url: string; calls: Calls }> = []; +let session: Record; +let sessionStatus = 200; +let mailboxes: Array>; +let email: Record | null; +let emailSetResult: Record | null = null; +let sessionsAsked = 0; + +const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } }); +vi.mock('@/lib/stalwart/server-fetch', () => ({ + isTrustedJmapServerUrl: async () => true, + fetchJmapServer: async (url: string, init?: { headers?: Record; body?: string }) => { + if (url.endsWith('/.well-known/jmap')) { + sessionsAsked++; + return sessionStatus === 200 ? json(session) : new Response('', { status: sessionStatus }); + } + const calls = (JSON.parse(init?.body ?? '{}') as { methodCalls: Calls }).methodCalls; + if (calls.some(([name]) => name.endsWith('/set'))) { + writes.push({ auth: init?.headers?.Authorization ?? '', url, calls }); + } + return json({ + methodResponses: calls.map(([name, args, id]) => { + if (name === 'Mailbox/get') return [name, { list: mailboxes }, id]; + if (name === 'Email/get') return [name, { list: email ? [email] : [] }, id]; + if (name === 'Mailbox/set') return [name, { created: {} }, id]; + const emailId = Object.keys(args.update as Record)[0]; + return [name, emailSetResult ?? { updated: { [emailId]: null } }, id]; + }), + }); + }, +})); + +const INBOX = { id: 'inbox', name: 'Inbox', role: 'inbox' }; +const ARCHIVE = { id: 'arch', name: 'Archive', role: 'archive' }; + +async function call(body: Record, headers: Record = { 'sec-fetch-site': 'same-origin' }) { + const { POST } = await import('@/app/api/push/action/route'); + const res = await POST(new NextRequest('https://mail.example.com/api/push/action', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(body), + })); + return { status: res.status, body: await res.json() }; +} + +const fingerprint = (login: Login) => loginFingerprint(login.serverUrl, login.username); +const markRead = async (over: Record = {}) => ({ + action: 'mark-read', accountId: 'a', emailId: 'e1', login: await fingerprint(ALICE), ...over, +}); +const archive = async (over: Record = {}) => ({ ...(await markRead()), action: 'archive', ...over }); +const emailSet = () => { + const call = writes[0].calls.find(([name]) => name === 'Email/set'); + return call?.[1] as { accountId: string; update: Record> }; +}; +const mailboxSet = () => writes[0].calls.find(([name]) => name === 'Mailbox/set')?.[1] as { create: Record> } | undefined; + +beforeEach(() => { + LOGINS = [ALICE]; + policy = {}; + subject = { email: 'alice@example.org', groups: [] }; + writes.length = 0; + sessionsAsked = 0; + sessionStatus = 200; + session = { + apiUrl: 'https://one.example/jmap/', + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'a' }, + accounts: { a: { name: 'alice@example.org', accountCapabilities: { 'urn:ietf:params:jmap:mail': {} } } }, + }; + mailboxes = [INBOX, ARCHIVE]; + email = { id: 'e1', receivedAt: '2026-03-31T23:30:00Z' }; + emailSetResult = null; +}); + +describe('who may ask', () => { + it('refuses a request from another site before reading anything', async () => { + const res = await call(await markRead(), { 'sec-fetch-site': 'same-site' }); + expect(res.status).toBe(403); + expect(sessionsAsked).toBe(0); + expect(writes).toHaveLength(0); + }); + + it('accepts what a service worker sends', async () => { + expect((await call(await markRead())).status).toBe(200); + expect(writes).toHaveLength(1); + }); + + it.each([ + ['an unknown action', { action: 'delete' }], + ['an id that is no JMAP id', { emailId: '../../x' }], + ['no account', { accountId: undefined }], + ['no login', { login: undefined }], + ])('refuses %s', async (_name, over) => { + const res = await call(await markRead(over)); + expect(res.status).toBe(400); + expect(sessionsAsked).toBe(0); + }); +}); + +describe('which login', () => { + it('answers 401 with no cookie, without asking the mail server', async () => { + LOGINS = []; + expect((await call(await markRead())).status).toBe(401); + expect(sessionsAsked).toBe(0); + expect(writes).toHaveLength(0); + }); + + it('answers 401 when the slot went to someone else since the notification', async () => { + // Account ids repeat, so bob's session would accept account `a` as well. + LOGINS = [BOB]; + expect((await call(await markRead())).status).toBe(401); + expect(sessionsAsked).toBe(0); + expect(writes).toHaveLength(0); + }); + + it('finds the login in whichever slot it holds now and uses its credential', async () => { + LOGINS = [BOB, null, ALICE]; + expect((await call(await markRead())).status).toBe(200); + expect(writes[0].auth).toBe('Bearer alice'); + }); + + it('answers 409 when the login is in two slots', async () => { + LOGINS = [ALICE, { ...ALICE, authHeader: 'Bearer stale' }]; + expect((await call(await markRead())).status).toBe(409); + expect(writes).toHaveLength(0); + }); + + it('answers 401 for an expired token: only a page refreshes it', async () => { + sessionStatus = 401; + expect((await call(await markRead())).status).toBe(401); + expect(writes).toHaveLength(0); + }); + + it('answers 502 when the mail server is down, which says nothing about the token', async () => { + sessionStatus = 503; + expect((await call(await markRead())).status).toBe(502); + expect(writes).toHaveLength(0); + }); + + it('refuses an account this login has no part in', async () => { + const res = await call(await markRead({ accountId: 'stranger' })); + expect(res.status).toBe(403); + expect(writes).toHaveLength(0); + }); + + it("doesn't take a name from Object.prototype for an account", async () => { + const res = await call(await markRead({ accountId: 'constructor' })); + expect(res.status).toBe(403); + expect(writes).toHaveLength(0); + }); +}); + +describe('Mark read', () => { + it('sets $seen on that message, at the rebased API address', async () => { + expect(await call(await markRead())).toEqual({ status: 200, body: { ok: true } }); + expect(writes[0].url).toBe('https://one.example/jmap/'); + expect(emailSet()).toEqual({ accountId: 'a', update: { e1: { 'keywords/$seen': true } } }); + expect(mailboxSet()).toBeUndefined(); + }); + + it('acts in a shared account the login reaches', async () => { + session.accounts = { ...(session.accounts as object), team: { name: 'team@example.org' } }; + expect((await call(await markRead({ accountId: 'team' }))).status).toBe(200); + expect(emailSet().accountId).toBe('team'); + }); + + it('answers 404 for a message that is gone', async () => { + emailSetResult = { notUpdated: { e1: { type: 'notFound' } } }; + expect((await call(await markRead())).status).toBe(404); + }); + + it('answers 502 when the server refuses', async () => { + emailSetResult = { notUpdated: { e1: { type: 'forbidden' } } }; + expect((await call(await markRead())).status).toBe(502); + }); +}); + +describe('Archive', () => { + it('moves the message into the Archive folder', async () => { + expect((await call(await archive())).status).toBe(200); + expect(emailSet().update.e1).toEqual({ mailboxIds: { arch: true } }); + expect(mailboxSet()).toBeUndefined(); + }); + + it('finds an Archive by name when no folder has the role, as the app does', async () => { + mailboxes = [INBOX, { id: 'named', name: 'archive' }]; + await call(await archive()); + expect(emailSet().update.e1).toEqual({ mailboxIds: { named: true } }); + }); + + it('creates the Archive folder when the account has none, in the same request', async () => { + mailboxes = [INBOX]; + expect((await call(await archive())).status).toBe(200); + expect(mailboxSet()?.create).toEqual({ archive: { name: 'Archive', parentId: null, role: 'archive' } }); + expect(emailSet().update.e1).toEqual({ mailboxIds: { '#archive': true } }); + }); + + it("doesn't try when the account may not create one", async () => { + mailboxes = [INBOX]; + session.accounts = { a: { accountCapabilities: { 'urn:ietf:params:jmap:mail': { mayCreateTopLevelMailbox: false } } } }; + expect((await call(await archive())).status).toBe(403); + expect(writes).toHaveLength(0); + }); + + it("doesn't try an Archive that takes no mail", async () => { + mailboxes = [INBOX, { ...ARCHIVE, myRights: { mayAddItems: false } }]; + expect((await call(await archive())).status).toBe(403); + expect(writes).toHaveLength(0); + }); + + it('files by year into an existing year folder', async () => { + mailboxes = [INBOX, ARCHIVE, { id: 'y26', name: '2026', parentId: 'arch' }]; + await call(await archive({ archiveMode: 'year', timeZone: 'UTC' })); + expect(emailSet().update.e1).toEqual({ mailboxIds: { y26: true } }); + expect(mailboxSet()).toBeUndefined(); + }); + + it('ignores a folder named like the year elsewhere', async () => { + mailboxes = [INBOX, ARCHIVE, { id: 'other', name: '2026', parentId: 'inbox' }]; + await call(await archive({ archiveMode: 'year', timeZone: 'UTC' })); + expect(mailboxSet()?.create).toEqual({ year: { name: '2026', parentId: 'arch' } }); + expect(emailSet().update.e1).toEqual({ mailboxIds: { '#year': true } }); + }); + + it("files by month in the user's time zone, not the server's", async () => { + mailboxes = [INBOX, ARCHIVE, { id: 'y26', name: '2026', parentId: 'arch' }, { id: 'm03', name: '03', parentId: 'y26' }]; + // 23:30 UTC on 31 March is 1 April in Berlin. + await call(await archive({ archiveMode: 'month', timeZone: 'Europe/Berlin' })); + expect(mailboxSet()?.create).toEqual({ month: { name: '04', parentId: 'y26' } }); + expect(emailSet().update.e1).toEqual({ mailboxIds: { '#month': true } }); + + writes.length = 0; + await call(await archive({ archiveMode: 'month', timeZone: 'UTC' })); + expect(emailSet().update.e1).toEqual({ mailboxIds: { m03: true } }); + }); + + it('creates the whole path in month mode on an account without an Archive', async () => { + mailboxes = [INBOX]; + await call(await archive({ archiveMode: 'month', timeZone: 'UTC' })); + expect(mailboxSet()?.create).toEqual({ + archive: { name: 'Archive', parentId: null, role: 'archive' }, + year: { name: '2026', parentId: '#archive' }, + month: { name: '03', parentId: '#year' }, + }); + expect(emailSet().update.e1).toEqual({ mailboxIds: { '#month': true } }); + }); + + it('survives a time zone that is no time zone', async () => { + mailboxes = [INBOX, ARCHIVE, { id: 'y26', name: '2026', parentId: 'arch' }]; + expect((await call(await archive({ archiveMode: 'year', timeZone: 'Mars/Olympus' }))).status).toBe(200); + expect(emailSet().update.e1).toEqual({ mailboxIds: { y26: true } }); + }); + + it('answers 404 for a message that is gone', async () => { + email = null; + expect((await call(await archive())).status).toBe(404); + expect(writes).toHaveLength(0); + }); + + describe('the admin gate', () => { + it('refuses when the admin turned Archive off, whatever the worker believes', async () => { + policy = { features: { archiveActionEnabled: false } }; + expect((await call(await archive())).status).toBe(403); + expect(writes).toHaveLength(0); + }); + + it('follows the scope this login is in', async () => { + policy = { scopes: [{ id: 'team', label: 'Team', match: { domains: ['example.org'] }, policy: { features: { archiveActionEnabled: false } } }] }; + expect((await call(await archive())).status).toBe(403); + expect(writes).toHaveLength(0); + }); + + it("refuses when the login's scopes can't be worked out", async () => { + subject = null; + expect((await call(await archive())).status).toBe(403); + expect(writes).toHaveLength(0); + }); + + it('leaves Mark read alone', async () => { + policy = { features: { archiveActionEnabled: false } }; + expect((await call(await markRead())).status).toBe(200); + }); + }); +}); diff --git a/lib/__tests__/push-preview-route.test.ts b/lib/__tests__/push-preview-route.test.ts index 3880595cb..a7abae5ca 100644 --- a/lib/__tests__/push-preview-route.test.ts +++ b/lib/__tests__/push-preview-route.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { endlessResponse } from './helpers/endless-body'; +import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; vi.mock('next/server', () => ({ NextResponse: { @@ -112,6 +113,11 @@ describe('push preview route account resolution', () => { expect(mailboxQuery?.methodCalls[0][1].accountId).toBe('g'); }); + it('names the login for a message, as the action route knows it', async () => { + const { body } = await callRoute('a'); + expect(body.login).toBe(await loginFingerprint('https://mail.example.com', undefined)); + }); + it('names the login (cookie slot) the account belongs to', async () => { // The worker only knows the JMAP account id, which can repeat across // servers; the click must open the message in this login. @@ -186,7 +192,7 @@ describe('push preview JMAP failures', () => { ['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 }, + status: 200, body: { email: { id: 'd', threadId: 'td' }, unreadTotal: 1, slot: 0, login: expect.any(String) }, }); }); }); @@ -202,7 +208,7 @@ it('previews a delivered message outside an empty Inbox', async () => { ['Email/get', { list: [{ id: 'filed', threadId: 't2' }] }, 'delivered'], ] })); expect(await callRoute('a', 'filed')).toEqual({ - status: 200, body: { email: { id: 'filed', threadId: 't2' }, unreadTotal: 1, slot: 0 }, + status: 200, body: { email: { id: 'filed', threadId: 't2' }, unreadTotal: 1, slot: 0, login: expect.any(String) }, }); }); diff --git a/lib/__tests__/sw-harness.ts b/lib/__tests__/sw-harness.ts index d54631a62..d945c3322 100644 --- a/lib/__tests__/sw-harness.ts +++ b/lib/__tests__/sw-harness.ts @@ -29,17 +29,40 @@ export class FakeCaches { } } -export function loadWorker(opts: { buildId: string; caches: FakeCaches; clients: Array<{ id: string }>; fetch?: (input: unknown) => Promise }) { +export interface ShownNotification { + title: string; + options: Record; +} + +export function loadWorker(opts: { + buildId: string; + caches: FakeCaches; + clients: Array<{ id: string }>; + fetch?: (input: unknown, init?: RequestInit) => Promise; + /** More globals of a real worker: Notification, NotificationEvent, MessageChannel. */ + globals?: Record; + openWindow?: (url: string) => Promise; + /** What showNotification does, after it was recorded in `shown`. */ + showNotification?: (title: string, options: Record) => Promise; + notifications?: Array>; +}) { const listeners = new Map void>(); + const shown: ShownNotification[] = []; let skipped = 0; const self = { location: { href: `https://mail.example.com/sw.js?v=${opts.buildId}`, origin: 'https://mail.example.com' }, addEventListener: (type: string, fn: (event: unknown) => void) => listeners.set(type, fn), skipWaiting: () => { skipped++; }, - clients: { claim: async () => {}, matchAll: async () => opts.clients }, - registration: { showNotification: async () => {}, getNotifications: async () => [] }, + clients: { claim: async () => {}, matchAll: async () => opts.clients, openWindow: opts.openWindow }, + registration: { + showNotification: async (title: string, options: Record) => { + shown.push({ title, options }); + await opts.showNotification?.(title, options); + }, + getNotifications: async () => opts.notifications ?? [], + }, }; - const context = vm.createContext({ self, caches: opts.caches, fetch: opts.fetch ?? (async () => { throw new TypeError('offline'); }), URL, URLSearchParams, Request, Response, Headers, Date, console, AbortController, setTimeout, clearTimeout }); + const context = vm.createContext({ self, caches: opts.caches, fetch: opts.fetch ?? (async () => { throw new TypeError('offline'); }), URL, URLSearchParams, Request, Response, Headers, Date, console, AbortController, setTimeout, clearTimeout, ...opts.globals }); vm.runInContext(SW_SOURCE, context); async function dispatch(type: string, event: Record = {}) { const waits: Promise[] = []; @@ -52,5 +75,5 @@ export function loadWorker(opts: { buildId: string; caches: FakeCaches; clients: listeners.get('fetch')?.({ request: { method: 'GET', mode: 'cors', ...req }, respondWith: (p: Promise) => { answer = p; } }); return answer ? await answer : null; } - return { dispatch, listeners, request, skipped: () => skipped }; + return { dispatch, listeners, request, shown, skipped: () => skipped }; } diff --git a/lib/__tests__/sw-notification-actions.test.ts b/lib/__tests__/sw-notification-actions.test.ts new file mode 100644 index 000000000..f452c79b3 --- /dev/null +++ b/lib/__tests__/sw-notification-actions.test.ts @@ -0,0 +1,363 @@ +import { describe, expect, it, vi } from 'vitest'; +import { FakeCaches, loadWorker } from './sw-harness'; + +// A notification's Archive, Mark read and Reply buttons (public/sw.js). The +// worker has no login: an open tab goes first, then the cookie route, then the +// message opens in the app. + +const ORIGIN = 'https://mail.example.com'; +const PUSH_CACHE = 'bulwark-push-state-v1'; +const PREFS = { + archiveEnabled: true, + archiveMode: 'month', + labels: { archive: 'Archivieren', markRead: 'Als gelesen markieren', reply: 'Antworten', replyPlaceholder: 'Antwort schreiben' }, +}; +const ONE_MESSAGE = { kind: 'email', emailId: 'e1', threadId: 't1', accountId: 'a', slot: 2, login: 'fp' }; +const MESSAGE_URL = '/mail/message/e1?slot=2&owner=a'; + +async function cachesWith(prefs?: unknown) { + const caches = new FakeCaches(); + if (prefs) { + const cache = await caches.open(PUSH_CACHE); + await cache.put(`${ORIGIN}/__push-state/actions`, new Response(JSON.stringify(prefs))); + } + return caches; +} + +/** A window client that answers a posted action with the given statuses. */ +function tab(id: string, statuses: string[], extra: Record = {}) { + const messages: Array> = []; + const client = { + id, + url: `${ORIGIN}/mail/folder/inbox`, + messages, + focus: vi.fn(async function focus(this: unknown) { return client; }), + navigate: vi.fn(async () => client), + postMessage(message: Record, ports?: MessagePort[]) { + messages.push(message); + if (message.kind !== 'notification-action') return; + const port = ports?.[0]; + for (const status of statuses) port?.postMessage({ status }); + }, + ...extra, + }; + return client; +} + +function worker(opts: { + caches: FakeCaches; + clients?: Array<{ id: string }>; + fetch?: (input: unknown, init?: RequestInit) => Promise; + max?: number | null; + inline?: boolean; + showNotification?: (title: string, options: Record) => Promise; + notifications?: Array>; +}) { + const openWindow = vi.fn(async () => null); + const fetch = vi.fn(opts.fetch ?? (async () => new Response('{}', { status: 200 }))); + const max = opts.max === undefined ? 3 : opts.max; + const loaded = loadWorker({ + buildId: 'b1', + caches: opts.caches, + clients: opts.clients ?? [], + fetch, + openWindow, + showNotification: opts.showNotification, + notifications: opts.notifications, + globals: { + MessageChannel, + ...(max === null ? {} : { Notification: { maxActions: max } }), + ...(opts.inline ? { NotificationEvent: class { get reply() { return null; } } } : {}), + }, + }); + const click = (action: string, extra: Record = {}, data: Record = ONE_MESSAGE) => + loaded.dispatch('notificationclick', { action, ...extra, notification: { data, tag: 'bulwark-mail:a', close() {} } }); + const posts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'POST'); + return { ...loaded, click, openWindow, fetch, posts }; +} + +const pushFor = (preview: Record) => async (w: ReturnType) => { + await w.dispatch('push', { data: { json: () => ({ kind: 'jmap-email-push', accountId: 'a', emailIds: ['e1'] }) } }); + return preview; +}; + +const previewOf = (unreadTotal: number) => JSON.stringify({ + unreadTotal, + slot: 2, + login: 'fp', + email: { id: 'e1', threadId: 't1', subject: 'Hello', from: [{ name: 'Bob' }] }, +}); + +describe('the buttons a notification gets', () => { + const buttonsFor = async (opts: { prefs?: unknown; max?: number | null; inline?: boolean; unreadTotal?: number }) => { + const w = worker({ + caches: await cachesWith(opts.prefs), + max: opts.max, + inline: opts.inline, + fetch: async () => new Response(previewOf(opts.unreadTotal ?? 1), { status: 200 }), + }); + await pushFor({})(w); + expect(w.shown).toHaveLength(1); + return w.shown[0].options.actions as Array> | undefined; + }; + + it('offers the three in the labels the page left, on one message', async () => { + expect(await buttonsFor({ prefs: PREFS })).toEqual([ + { action: 'archive', title: 'Archivieren' }, + { action: 'mark-read', title: 'Als gelesen markieren' }, + { action: 'reply', title: 'Antworten' }, + ]); + }); + + it('keeps Archive and Reply where the browser has two buttons', async () => { + const buttons = await buttonsFor({ prefs: PREFS, max: 2 }); + expect(buttons?.map((b) => b.action)).toEqual(['archive', 'reply']); + }); + + it('leaves Archive out when the admin turned it off', async () => { + const buttons = await buttonsFor({ prefs: { ...PREFS, archiveEnabled: false }, max: 2 }); + expect(buttons?.map((b) => b.action)).toEqual(['mark-read', 'reply']); + }); + + it('offers no Archive, in English, before any page has spoken', async () => { + expect(await buttonsFor({})).toEqual([ + { action: 'mark-read', title: 'Mark as read' }, + { action: 'reply', title: 'Reply' }, + ]); + }); + + it('adds nothing in a browser without notification buttons', async () => { + expect(await buttonsFor({ prefs: PREFS, max: null })).toBeUndefined(); + expect(await buttonsFor({ prefs: PREFS, max: 0 })).toBeUndefined(); + }); + + it('gives the group notification none: it names no message', async () => { + expect(await buttonsFor({ prefs: PREFS, unreadTotal: 4 })).toBeUndefined(); + }); + + it('gives a message without its login none', async () => { + const w = worker({ + caches: await cachesWith(PREFS), + fetch: async () => new Response(JSON.stringify({ unreadTotal: 1, slot: 2, email: { id: 'e1', threadId: 't1' } }), { status: 200 }), + }); + await pushFor({})(w); + expect(w.shown[0].options.actions).toBeUndefined(); + }); + + it('makes Reply a text field only where the browser can hand the text back', async () => { + const buttons = await buttonsFor({ prefs: PREFS, inline: true }); + expect(buttons?.find((b) => b.action === 'reply')).toEqual({ + action: 'reply', title: 'Antworten', type: 'text', placeholder: 'Antwort schreiben', + }); + }); + + it('still shows the notification when the browser refuses the buttons', async () => { + const w = worker({ + caches: await cachesWith(PREFS), + fetch: async () => new Response(previewOf(1), { status: 200 }), + showNotification: async (_title, options) => { + if (options.actions) throw new TypeError('bad action'); + }, + }); + await pushFor({})(w); + expect(w.shown).toHaveLength(2); + expect(w.shown[1].options.actions).toBeUndefined(); + expect(w.shown[1].options.data).toMatchObject({ emailId: 'e1', login: 'fp' }); + }); + + it('keeps the buttons when a redelivered push shows the same notification again', async () => { + const existing = { title: 'Bob', body: 'Hello', data: ONE_MESSAGE }; + const caches = await cachesWith(PREFS); + const cache = await caches.open(PUSH_CACHE); + await cache.put(`${ORIGIN}/__push-state/notified/a`, new Response(JSON.stringify({ ids: ['e1'] }))); + const w = worker({ caches, notifications: [existing] }); + await pushFor({})(w); + expect(w.shown).toHaveLength(1); + expect((w.shown[0].options.actions as unknown[]).length).toBe(3); + }); +}); + +describe('a click', () => { + it('on the body works as before: it opens the message and runs nothing', async () => { + const client = tab('t', ['done']); + const w = worker({ caches: await cachesWith(PREFS), clients: [client] }); + await w.click(''); + expect(client.navigate).toHaveBeenCalledWith(MESSAGE_URL); + expect(client.messages.map((m) => m.kind)).toEqual(['notificationclick']); + expect(w.posts()).toHaveLength(0); + }); + + it('on an unknown button counts as a click on the body', async () => { + const client = tab('t', ['done']); + const w = worker({ caches: await cachesWith(PREFS), clients: [client] }); + await w.click('explode'); + expect(client.navigate).toHaveBeenCalledWith(MESSAGE_URL); + expect(w.posts()).toHaveLength(0); + }); + + it('on a button of a group notification opens the inbox and runs nothing', async () => { + const client = tab('t', ['done']); + const w = worker({ caches: await cachesWith(PREFS), clients: [client] }); + await w.click('archive', {}, { kind: 'mail-list', accountId: 'a', slot: 2 }); + expect(client.navigate).toHaveBeenCalledWith('/mail/folder/inbox?slot=2&owner=a'); + expect(w.posts()).toHaveLength(0); + }); +}); + +describe('Mark read and Archive', () => { + it('go to an open tab first, which leaves nothing else to do', async () => { + const client = tab('t', ['accepted', 'done']); + const w = worker({ caches: await cachesWith(PREFS), clients: [client] }); + await w.click('mark-read'); + expect(client.messages.find((m) => m.kind === 'notification-action')).toEqual({ + kind: 'notification-action', action: 'mark-read', emailId: 'e1', threadId: 't1', accountId: 'a', slot: 2, login: 'fp', + }); + expect(w.posts()).toHaveLength(0); + expect(client.navigate).not.toHaveBeenCalled(); + expect(client.focus).not.toHaveBeenCalled(); + expect(w.openWindow).not.toHaveBeenCalled(); + }); + + it('go to the tab in front first', async () => { + const hidden = tab('hidden', ['done'], { visibilityState: 'hidden' }); + const front = tab('front', ['done'], { focused: true, visibilityState: 'visible' }); + const w = worker({ caches: await cachesWith(PREFS), clients: [hidden, front] }); + await w.click('mark-read'); + expect(front.messages.some((m) => m.kind === 'notification-action')).toBe(true); + expect(hidden.messages.some((m) => m.kind === 'notification-action')).toBe(false); + }); + + it('try the next tab when one declines', async () => { + const other = tab('other', ['declined']); + const right = tab('right', ['done']); + const w = worker({ caches: await cachesWith(PREFS), clients: [other, right] }); + await w.click('mark-read'); + expect(right.messages.some((m) => m.kind === 'notification-action')).toBe(true); + expect(w.posts()).toHaveLength(0); + }); + + it('use the route with no tab open, and nothing opens when it works', async () => { + const w = worker({ caches: await cachesWith(PREFS) }); + await w.click('mark-read'); + expect(w.posts()).toHaveLength(1); + const [url, init] = w.posts()[0]; + expect(url).toBe('/api/push/action'); + expect(init?.credentials).toBe('include'); + expect(JSON.parse(String(init?.body))).toEqual({ action: 'mark-read', accountId: 'a', emailId: 'e1', login: 'fp' }); + expect(w.openWindow).not.toHaveBeenCalled(); + }); + + it('send the mirrored archive mode and the time zone with Archive', async () => { + const w = worker({ caches: await cachesWith(PREFS) }); + await w.click('archive'); + const body = JSON.parse(String(w.posts()[0][1]?.body)); + expect(body).toMatchObject({ action: 'archive', archiveMode: 'month', accountId: 'a', emailId: 'e1', login: 'fp' }); + expect(typeof body.timeZone).toBe('string'); + expect(body.timeZone).not.toBe(''); + }); + + it('use the route when a tab never answers, so a frozen tab costs a moment only', async () => { + const frozen = tab('frozen', []); + const w = worker({ caches: await cachesWith(PREFS), clients: [frozen] }); + await w.click('mark-read'); + expect(w.posts()).toHaveLength(1); + expect(frozen.navigate).not.toHaveBeenCalled(); + }); + + it.each([401, 403, 404, 409, 502])('open the message when the route answers %i', async (status) => { + const w = worker({ caches: await cachesWith(PREFS), fetch: async () => new Response('{}', { status }) }); + await w.click('archive'); + expect(w.posts()).toHaveLength(1); + expect(w.openWindow).toHaveBeenCalledWith(MESSAGE_URL); + }); + + it('open the message when the route is out of reach', async () => { + const w = worker({ caches: await cachesWith(PREFS), fetch: async () => { throw new TypeError('offline'); } }); + await w.click('mark-read'); + expect(w.openWindow).toHaveBeenCalledWith(MESSAGE_URL); + }); + + it('open the message when a tab with a live login tried and failed, without the route', async () => { + const client = tab('t', ['accepted', 'failed']); + const w = worker({ caches: await cachesWith(PREFS), clients: [client] }); + await w.click('archive'); + expect(w.posts()).toHaveLength(0); + expect(client.focus).toHaveBeenCalled(); + expect(client.navigate).toHaveBeenCalledWith(MESSAGE_URL); + }); + + it('never post an Archive the mirror says is off', async () => { + const w = worker({ caches: await cachesWith({ ...PREFS, archiveEnabled: false }) }); + await w.click('archive'); + expect(w.posts()).toHaveLength(0); + expect(w.openWindow).toHaveBeenCalledWith(MESSAGE_URL); + }); +}); + +describe('Reply', () => { + it('opens the composer in an open tab and brings it to the front', async () => { + const client = tab('t', ['done']); + const w = worker({ caches: await cachesWith(PREFS), clients: [client] }); + await w.click('reply', { reply: ' Thanks! ' }); + expect(client.messages.find((m) => m.kind === 'notification-action')).toMatchObject({ action: 'reply', emailId: 'e1', text: 'Thanks!' }); + expect(client.focus).toHaveBeenCalled(); + expect(client.navigate).not.toHaveBeenCalled(); + expect(w.posts()).toHaveLength(0); + }); + + it('navigates a tab that has no mail page to the reply link', async () => { + const client = tab('t', ['declined']); + const w = worker({ caches: await cachesWith(PREFS), clients: [client] }); + await w.click('reply'); + expect(client.focus).toHaveBeenCalled(); + expect(client.navigate).toHaveBeenCalledWith(`${MESSAGE_URL}&reply=1`); + }); + + it('opens a window on the reply link with no tab open, never through the route', async () => { + const w = worker({ caches: await cachesWith(PREFS) }); + await w.click('reply'); + expect(w.openWindow).toHaveBeenCalledWith(`${MESSAGE_URL}&reply=1`); + expect(w.posts()).toHaveLength(0); + }); + + it('keeps typed text in the store for the page, out of the URL', async () => { + const caches = await cachesWith(PREFS); + const w = worker({ caches }); + await w.click('reply', { reply: 'Private words' }); + expect(w.openWindow).toHaveBeenCalledWith(`${MESSAGE_URL}&reply=1`); + const cache = await caches.open(PUSH_CACHE); + const kept = await (await cache.match(`${ORIGIN}/__push-state/pending-reply`))!.json(); + expect(kept).toMatchObject({ emailId: 'e1', text: 'Private words' }); + }); + + it('leaves nothing in the store without typed text', async () => { + const caches = await cachesWith(PREFS); + const w = worker({ caches }); + await w.click('reply', { reply: ' ' }); + const cache = await caches.open(PUSH_CACHE); + expect(await cache.match(`${ORIGIN}/__push-state/pending-reply`)).toBeUndefined(); + }); +}); + +describe('Background Sync', () => { + it('tells every open tab to flush the outbox', async () => { + const one = tab('one', []); + const two = tab('two', []); + const w = worker({ caches: await cachesWith(), clients: [one, two] }); + await w.dispatch('sync', { tag: 'bulwark-outbox' }); + expect(one.messages).toEqual([{ kind: 'outbox-flush' }]); + expect(two.messages).toEqual([{ kind: 'outbox-flush' }]); + }); + + it('ignores other tags', async () => { + const one = tab('one', []); + const w = worker({ caches: await cachesWith(), clients: [one] }); + await w.dispatch('sync', { tag: 'something-else' }); + expect(one.messages).toEqual([]); + }); + + it('settles with no tab open', async () => { + const w = worker({ caches: await cachesWith(), clients: [] }); + await expect(w.dispatch('sync', { tag: 'bulwark-outbox' })).resolves.toBeUndefined(); + }); +}); diff --git a/lib/deep-links.ts b/lib/deep-links.ts index 483ad33a9..9e890e58e 100644 --- a/lib/deep-links.ts +++ b/lib/deep-links.ts @@ -137,7 +137,7 @@ const ALIASED_ROLES = new Set(['inbox', 'sent', 'drafts', 'trash', 'archive', 'j */ export type MailDeepLink = | { kind: 'folder'; ref: string; accountId?: string; slot?: number; ownerAccountId?: string } - | { kind: 'message'; id: string; accountId?: string; slot?: number; fullscreen?: boolean; ownerAccountId?: string } + | { kind: 'message'; id: string; accountId?: string; slot?: number; fullscreen?: boolean; ownerAccountId?: string; reply?: boolean } | { kind: 'thread'; id: string; accountId?: string; slot?: number; ownerAccountId?: string }; export interface MailLinkState { @@ -286,12 +286,15 @@ export function parseMailPath( // opens message links as fullscreen email tabs and ignores the flag. const fullscreen = search?.get('view') === 'fullscreen' || undefined; const ownerAccountId = search?.get('owner') || undefined; + // `?reply=1`, a notification's Reply: opens the reply composer once. Only + // opens it: nothing a link says can send or change mail. + const reply = search?.get('reply') === '1' || undefined; const [kind, value] = segments; if (kind && value) { const id = decodeSegment(value); if (id) { - if (kind === 'message') return { kind: 'message', id, accountId, slot, fullscreen, ownerAccountId }; + if (kind === 'message') return { kind: 'message', id, accountId, slot, fullscreen, ownerAccountId, ...(reply ? { reply } : {}) }; if (kind === 'thread') return { kind: 'thread', id, accountId, slot, ownerAccountId }; if (kind === 'folder') return { kind: 'folder', ref: id, accountId, slot, ownerAccountId }; } diff --git a/lib/offline/__tests__/queue-sync.test.ts b/lib/offline/__tests__/queue-sync.test.ts new file mode 100644 index 000000000..a31f5616e --- /dev/null +++ b/lib/offline/__tests__/queue-sync.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { queuePatch, queueSend } from '../queue'; + +// Whatever goes into the queue asks for a Background Sync, once it's stored. + +const requestOutboxSync = vi.fn(); +const stored: unknown[] = []; +vi.mock('@/lib/pwa-notify/outbox-sync', () => ({ requestOutboxSync: () => requestOutboxSync(stored.length) })); +vi.mock('../db', () => ({ + idbAdd: vi.fn(async (_store: string, record: unknown) => { stored.push(record); return stored.length; }), + idbDeleteAccount: vi.fn(), idbGet: vi.fn(), idbGetAll: vi.fn(), idbGetAllByAccount: vi.fn(), idbUpdate: vi.fn(), idbWrite: vi.fn(), +})); +vi.mock('../crypto', () => ({ + sealJson: vi.fn(async () => ({ iv: 'i', data: 'd' })), + openJson: vi.fn(), openBytes: vi.fn(), sealBytes: vi.fn(), +})); + +beforeEach(() => { + stored.length = 0; + requestOutboxSync.mockClear(); +}); + +describe('the offline queue', () => { + it('asks for a sync after storing a change', async () => { + await queuePatch('acc', 'jmap', {} as never); + expect(requestOutboxSync).toHaveBeenCalledTimes(1); + expect(requestOutboxSync).toHaveBeenCalledWith(1); + }); + + it('asks for a sync after storing a message, and still returns its id', async () => { + const id = await queueSend('acc', {} as never, {} as never); + expect(id).toBe(1); + expect(requestOutboxSync).toHaveBeenCalledWith(1); + }); +}); diff --git a/lib/offline/queue.ts b/lib/offline/queue.ts index 61e53091a..4da2d5bf0 100644 --- a/lib/offline/queue.ts +++ b/lib/offline/queue.ts @@ -18,6 +18,7 @@ import { idbAdd, idbDeleteAccount, idbGet, idbGetAll, idbGetAllByAccount, idbUpdate, idbWrite } from './db'; import { openBytes, openJson, sealBytes, sealJson, type Sealed } from './crypto'; import { generateUUID } from '@/lib/utils'; +import { requestOutboxSync } from '@/lib/pwa-notify/outbox-sync'; export interface PatchItem { kind: 'patch'; @@ -104,11 +105,14 @@ interface SendPayload { export async function queuePatch(account: string, jmap: string, update: PatchItem['update']): Promise { const item: PatchItem = { kind: 'patch', jmap, update }; await idbAdd('queue', { account, kind: 'patch', createdAt: Date.now(), ...(await sealJson(item)) } satisfies QueueRecord); + requestOutboxSync(); } export async function queueSend(account: string, summary: OutboxSummary, item: SendItem, fingerprint?: PolicyFingerprint): Promise { const payload: SendPayload = { summary, item, fingerprint }; - return Number(await idbAdd('queue', { account, kind: 'send', createdAt: Date.now(), status: 'waiting', ...(await sealJson(payload)) } satisfies QueueRecord)); + const id = Number(await idbAdd('queue', { account, kind: 'send', createdAt: Date.now(), status: 'waiting', ...(await sealJson(payload)) } satisfies QueueRecord)); + requestOutboxSync(); + return id; } export interface QueuedPatch { id: number; account: string; item: PatchItem } diff --git a/lib/pwa-notify/__tests__/outbox-sync.test.ts b/lib/pwa-notify/__tests__/outbox-sync.test.ts new file mode 100644 index 000000000..0e2ae151d --- /dev/null +++ b/lib/pwa-notify/__tests__/outbox-sync.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { requestOutboxSync } from '../outbox-sync'; + +// Background Sync where the browser has it. Elsewhere, and in Electron where +// register rejects, nothing changes and nothing throws. + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function browserWith(opts: { syncManager?: boolean; registration?: unknown; worker?: boolean }) { + if (opts.syncManager) vi.stubGlobal('SyncManager', class {}); + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: opts.worker === false ? undefined : { getRegistration: async () => opts.registration }, + }); +} + +afterEach(() => { + vi.unstubAllGlobals(); + Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: undefined }); +}); + +describe('requestOutboxSync', () => { + it('registers the tag the worker listens for', async () => { + const register = vi.fn(async () => {}); + browserWith({ syncManager: true, registration: { sync: { register } } }); + requestOutboxSync(); + await settle(); + expect(register).toHaveBeenCalledWith('bulwark-outbox'); + }); + + it('does nothing in a browser without Background Sync', async () => { + const register = vi.fn(async () => {}); + browserWith({ syncManager: false, registration: { sync: { register } } }); + requestOutboxSync(); + await settle(); + expect(register).not.toHaveBeenCalled(); + }); + + it('does nothing with no worker registered', async () => { + browserWith({ syncManager: true, registration: undefined }); + expect(() => requestOutboxSync()).not.toThrow(); + await settle(); + }); + + it('does nothing without service workers', async () => { + browserWith({ syncManager: true, worker: false }); + expect(() => requestOutboxSync()).not.toThrow(); + await settle(); + }); + + it('swallows the rejection Electron answers with', async () => { + const rejected = vi.fn(); + process.on('unhandledRejection', rejected); + const register = vi.fn(async () => { throw new DOMException('Background Sync is disabled', 'UnknownError'); }); + browserWith({ syncManager: true, registration: { sync: { register } } }); + requestOutboxSync(); + await settle(); + await settle(); + process.off('unhandledRejection', rejected); + expect(register).toHaveBeenCalled(); + expect(rejected).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/pwa-notify/__tests__/page-actions.test.ts b/lib/pwa-notify/__tests__/page-actions.test.ts new file mode 100644 index 000000000..0351fcbfb --- /dev/null +++ b/lib/pwa-notify/__tests__/page-actions.test.ts @@ -0,0 +1,175 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { loginFingerprint } from '../login-fingerprint'; +import { isNotificationActionRequest, runNotificationAction, setNotificationReplyOpener, type NotificationActionRequest, type NotificationActionStatus } from '../page-actions'; + +// A notification's button in an open tab: only for the login it was shown for, +// through the store actions, with the tab's answer telling the worker what's next. + +const accounts = [ + { id: 'alice@one', cookieSlot: 0, serverUrl: 'https://one.example', username: 'alice@example.org' }, + { id: 'bob@two', cookieSlot: 1, serverUrl: 'https://two.example', username: 'bob@example.org' }, +]; +const makeClient = (accountId: string) => ({ + getAccountId: () => accountId, + getEmail: vi.fn(async (id: string) => (id === 'gone' ? null : { id, receivedAt: '2026-01-01T00:00:00Z' })), +}); +let clients: Record>; +let activeAccountId = 'alice@one'; +let archiveEnabled = true; +const batchMarkAsRead = vi.fn(async (..._args: unknown[]) => true); +const batchArchive = vi.fn(async (..._args: unknown[]) => true); + +vi.mock('@/stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts }) } })); +vi.mock('@/stores/auth-store', () => ({ + useAuthStore: { + getState: () => ({ + activeAccountId, + client: clients[activeAccountId] ?? null, + getClientForAccount: (id: string) => clients[id], + }), + }, +})); +vi.mock('@/stores/email-store', () => ({ useEmailStore: { getState: () => ({ batchMarkAsRead, batchArchive }) } })); +vi.mock('@/lib/archive-gate', () => ({ isArchiveActionEnabled: () => archiveEnabled })); + +async function run(over: Partial = {}) { + const request: NotificationActionRequest = { + action: 'mark-read', + emailId: 'e1', + accountId: 'a', + slot: 0, + login: await loginFingerprint('https://one.example', 'alice@example.org'), + ...over, + }; + const answers: NotificationActionStatus[] = []; + await runNotificationAction(request, (status) => answers.push(status)); + return answers; +} + +beforeEach(() => { + vi.clearAllMocks(); + clients = { 'alice@one': makeClient('a'), 'bob@two': makeClient('b') }; + activeAccountId = 'alice@one'; + archiveEnabled = true; + batchMarkAsRead.mockResolvedValue(true); + batchArchive.mockResolvedValue(true); +}); + +describe('which notifications a tab takes', () => { + it('declines when the slot holds someone else now', async () => { + const answers = await run({ login: await loginFingerprint('https://other.example', 'alice@example.org') }); + expect(answers).toEqual(['declined']); + expect(batchMarkAsRead).not.toHaveBeenCalled(); + }); + + it('declines a slot it has no login for', async () => { + expect(await run({ slot: 7 })).toEqual(['declined']); + }); + + it('declines without a login to compare', async () => { + expect(await run({ login: undefined })).toEqual(['declined']); + expect(batchMarkAsRead).not.toHaveBeenCalled(); + }); + + it('declines when that login is not connected in this tab', async () => { + delete clients['alice@one']; + activeAccountId = 'bob@two'; + expect(await run()).toEqual(['declined']); + }); + + it('checks a message from the worker for the shape it needs', () => { + expect(isNotificationActionRequest({ action: 'archive', emailId: 'e', accountId: 'a' })).toBe(true); + expect(isNotificationActionRequest({ action: 'delete', emailId: 'e', accountId: 'a' })).toBe(false); + expect(isNotificationActionRequest({ action: 'archive', emailId: '', accountId: 'a' })).toBe(false); + expect(isNotificationActionRequest({ action: 'archive', emailId: 'e' })).toBe(false); + expect(isNotificationActionRequest(null)).toBe(false); + }); +}); + +describe('Mark read', () => { + it('goes through the store, with the login of the notification', async () => { + expect(await run()).toEqual(['accepted', 'done']); + const [active, read, target] = batchMarkAsRead.mock.calls[0]; + expect(active).toBe(clients['alice@one']); + expect(read).toBe(true); + expect(target).toEqual({ emailIds: ['e1'], client: clients['alice@one'], accountId: undefined }); + }); + + it('reaches a login that is not the active one through its own client', async () => { + const answers = await run({ slot: 1, accountId: 'b', login: await loginFingerprint('https://two.example', 'bob@example.org') }); + expect(answers).toEqual(['accepted', 'done']); + const [active, , target] = batchMarkAsRead.mock.calls[0]; + expect(active).toBe(clients['alice@one']); + expect((target as { client: unknown }).client).toBe(clients['bob@two']); + }); + + it('names a shared account', async () => { + await run({ accountId: 'team' }); + expect((batchMarkAsRead.mock.calls[0][2] as { accountId?: string }).accountId).toBe('team'); + }); + + it('reports a failure, so the worker opens the message', async () => { + batchMarkAsRead.mockRejectedValue(new Error('forbidden')); + expect(await run()).toEqual(['accepted', 'failed']); + }); +}); + +describe('Archive', () => { + it('goes through the store with the message, which it files by date', async () => { + expect(await run({ action: 'archive' })).toEqual(['accepted', 'done']); + const [active, target] = batchArchive.mock.calls[0]; + expect(active).toBe(clients['alice@one']); + expect(target).toEqual({ + emailIds: ['e1'], + client: clients['alice@one'], + emails: [{ id: 'e1', receivedAt: '2026-01-01T00:00:00Z' }], + }); + }); + + it('declines when the admin turned it off', async () => { + archiveEnabled = false; + expect(await run({ action: 'archive' })).toEqual(['declined']); + expect(batchArchive).not.toHaveBeenCalled(); + }); + + it("leaves a shared account's Archive to the route: the store reads own folders only", async () => { + expect(await run({ action: 'archive', accountId: 'team' })).toEqual(['declined']); + expect(batchArchive).not.toHaveBeenCalled(); + }); + + it('reports a message that is gone', async () => { + expect(await run({ action: 'archive', emailId: 'gone' })).toEqual(['accepted', 'failed']); + expect(batchArchive).not.toHaveBeenCalled(); + }); + + it('reports a plugin or the store stopping it', async () => { + batchArchive.mockResolvedValue(false); + expect(await run({ action: 'archive' })).toEqual(['accepted', 'failed']); + }); +}); + +describe('Reply', () => { + it('declines on a page without the mail app', async () => { + expect(await run({ action: 'reply' })).toEqual(['declined']); + }); + + it('hands the request to the mail page, typed text included', async () => { + const opener = vi.fn(); + const remove = setNotificationReplyOpener(opener); + expect(await run({ action: 'reply', text: 'Thanks' })).toEqual(['done']); + expect(opener).toHaveBeenCalledWith({ emailId: 'e1', accountId: 'a', slot: 0, text: 'Thanks' }); + remove(); + expect(await run({ action: 'reply' })).toEqual(['declined']); + expect(opener).toHaveBeenCalledTimes(1); + }); + + it('keeps a newer opener when an older one is removed', async () => { + const remove = setNotificationReplyOpener(vi.fn()); + const current = vi.fn(); + const removeCurrent = setNotificationReplyOpener(current); + remove(); + await run({ action: 'reply' }); + expect(current).toHaveBeenCalled(); + removeCurrent(); + }); +}); diff --git a/lib/pwa-notify/__tests__/worker-state.test.ts b/lib/pwa-notify/__tests__/worker-state.test.ts new file mode 100644 index 000000000..0a175d5b7 --- /dev/null +++ b/lib/pwa-notify/__tests__/worker-state.test.ts @@ -0,0 +1,70 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakeCaches } from '@/lib/__tests__/sw-harness'; +import { PUSH_STATE_CACHE } from '@/lib/push-preferences'; +import { consumePendingReplyText, notificationActionPrefsKey, pendingReplyKey, writeNotificationActionPrefs } from '../worker-state'; + +let caches: FakeCaches; + +const keep = async (value: unknown) => { + const cache = await caches.open(PUSH_STATE_CACHE); + await cache.put(pendingReplyKey(window.location.origin), new Response(JSON.stringify(value))); +}; + +beforeEach(() => { + caches = new FakeCaches(); + vi.stubGlobal('caches', caches); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('the worker store', () => { + it('holds the prefs where sw.js reads them', async () => { + const prefs = { archiveEnabled: true, archiveMode: 'year' as const, labels: { archive: 'A', markRead: 'M', reply: 'R', replyPlaceholder: 'P' } }; + await writeNotificationActionPrefs(prefs); + const cache = await caches.open(PUSH_STATE_CACHE); + const stored = await cache.match(notificationActionPrefsKey(window.location.origin)); + expect(await stored!.json()).toEqual(prefs); + }); + + it('uses the same keys and cache as sw.js, which can import nothing', () => { + const worker = readFileSync(join(process.cwd(), 'public', 'sw.js'), 'utf8'); + expect(worker).toContain(`const PUSH_STATE_CACHE = "${PUSH_STATE_CACHE}"`); + expect(worker).toContain('/__push-state/actions`'); + expect(worker).toContain('/__push-state/pending-reply`'); + expect(notificationActionPrefsKey('https://x')).toBe('https://x/__push-state/actions'); + expect(pendingReplyKey('https://x')).toBe('https://x/__push-state/pending-reply'); + }); + + it('survives a browser without the Cache API', async () => { + vi.stubGlobal('caches', undefined); + await expect(writeNotificationActionPrefs({ archiveEnabled: false, archiveMode: 'single', labels: { archive: '', markRead: '', reply: '', replyPlaceholder: '' } })).resolves.toBeUndefined(); + await expect(consumePendingReplyText('e1')).resolves.toBe(''); + }); +}); + +describe('a reply typed on a notification', () => { + it('reaches the page once', async () => { + await keep({ emailId: 'e1', text: 'Thanks', at: Date.now() }); + expect(await consumePendingReplyText('e1')).toBe('Thanks'); + expect(await consumePendingReplyText('e1')).toBe(''); + }); + + it("doesn't go into a reply to another message, and is gone after", async () => { + await keep({ emailId: 'e1', text: 'Thanks', at: Date.now() }); + expect(await consumePendingReplyText('e2')).toBe(''); + expect(await consumePendingReplyText('e1')).toBe(''); + }); + + it("doesn't go into a reply much later", async () => { + await keep({ emailId: 'e1', text: 'Thanks', at: Date.now() - 6 * 60 * 1000 }); + expect(await consumePendingReplyText('e1')).toBe(''); + }); + + it('is empty when there is none', async () => { + expect(await consumePendingReplyText('e1')).toBe(''); + }); +}); diff --git a/lib/pwa-notify/login-fingerprint.ts b/lib/pwa-notify/login-fingerprint.ts new file mode 100644 index 000000000..7debefe5d --- /dev/null +++ b/lib/pwa-notify/login-fingerprint.ts @@ -0,0 +1,13 @@ +/** + * Names a login (server and user) in a notification's data, without naming + * either. A cookie slot goes to whoever signs in next, and JMAP account ids + * repeat across servers, so neither says that an action on a notification + * from yesterday still means the same mailbox. + * + * Runs in the page and on the server: both ends compare the result. + */ +export async function loginFingerprint(serverUrl: string, username: string | undefined): Promise { + const text = `${serverUrl.replace(/\/+$/, '')}\n${(username ?? '').trim().toLowerCase()}`; + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)); + return Array.from(new Uint8Array(digest).slice(0, 16), (byte) => byte.toString(16).padStart(2, '0')).join(''); +} diff --git a/lib/pwa-notify/outbox-sync.ts b/lib/pwa-notify/outbox-sync.ts new file mode 100644 index 000000000..4e2fd8db4 --- /dev/null +++ b/lib/pwa-notify/outbox-sync.ts @@ -0,0 +1,24 @@ +/** Same tag as sw.js's OUTBOX_SYNC_TAG. */ +const OUTBOX_SYNC_TAG = 'bulwark-outbox'; + +/** + * Something went into the outbox: asks the browser to wake the service worker + * when the network is back, which tells the open tabs to flush. Only a tab + * can flush (the worker has no login), so this helps a tab that missed the + * `online` event. + * + * Chromium only. Without it, or without a worker (Lite, development), or in + * Electron, where register rejects, the outbox flushes as it always did. + */ +export function requestOutboxSync(): void { + void (async () => { + try { + if (typeof window === 'undefined' || !('SyncManager' in window) || !navigator.serviceWorker) return; + const registration = await navigator.serviceWorker.getRegistration(); + const sync = (registration as (ServiceWorkerRegistration & { sync?: { register(tag: string): Promise } }) | undefined)?.sync; + await sync?.register(OUTBOX_SYNC_TAG); + } catch { + // Nothing to do: the outbox doesn't depend on this. + } + })(); +} diff --git a/lib/pwa-notify/page-actions.ts b/lib/pwa-notify/page-actions.ts new file mode 100644 index 000000000..43cc96f95 --- /dev/null +++ b/lib/pwa-notify/page-actions.ts @@ -0,0 +1,121 @@ +import { isArchiveActionEnabled } from '@/lib/archive-gate'; +import { useAccountStore } from '@/stores/account-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { loginFingerprint } from './login-fingerprint'; + +/** What the service worker posts when a notification's button is clicked (public/sw.js). */ +export interface NotificationActionRequest { + action: 'archive' | 'mark-read' | 'reply'; + emailId: string; + accountId: string; + threadId?: string; + slot?: number; + login?: string; + /** A reply typed on the notification, where the browser has a text field there. */ + text?: string; +} + +/** + * `declined`: not for this tab, the worker tries the next tab or the route. + * `accepted`: this tab is on it. `done` and `failed`: how it ended. + */ +export type NotificationActionStatus = 'declined' | 'accepted' | 'done' | 'failed'; + +export interface NotificationReplyRequest { + emailId: string; + accountId: string; + slot: number; + text?: string; +} + +type ReplyOpener = (request: NotificationReplyRequest) => void; + +let replyOpener: ReplyOpener | null = null; + +/** The mail page opens replies. On other pages the worker navigates to it instead. */ +export function setNotificationReplyOpener(opener: ReplyOpener): () => void { + replyOpener = opener; + return () => { + if (replyOpener === opener) replyOpener = null; + }; +} + +export function isNotificationActionRequest(value: unknown): value is NotificationActionRequest { + const request = value as Partial | null; + return !!request + && (request.action === 'archive' || request.action === 'mark-read' || request.action === 'reply') + && typeof request.emailId === 'string' && !!request.emailId + && typeof request.accountId === 'string' && !!request.accountId; +} + +/** The login the notification is for, if this tab has it signed in right now. */ +async function loginOf(request: NotificationActionRequest) { + const entry = useAccountStore.getState().accounts.find((account) => account.cookieSlot === request.slot); + if (!entry || !request.login) return null; + if ((await loginFingerprint(entry.serverUrl, entry.username)) !== request.login) return null; + const auth = useAuthStore.getState(); + const client = auth.getClientForAccount(entry.id) ?? (entry.id === auth.activeAccountId ? auth.client : null); + return client ? { client } : null; +} + +/** + * Runs a notification's button in this tab, through the same store actions as + * the app's own buttons: plugin hooks, archive mode and the Archive gate all + * apply, and the list on screen follows. + */ +export async function runNotificationAction( + request: NotificationActionRequest, + answer: (status: NotificationActionStatus) => void, +): Promise { + try { + const login = await loginOf(request); + const activeClient = useAuthStore.getState().client; + if (!login || !activeClient) { + answer('declined'); + return; + } + if (request.action === 'reply') { + if (!replyOpener || request.slot === undefined) { + answer('declined'); + return; + } + replyOpener({ emailId: request.emailId, accountId: request.accountId, slot: request.slot, text: request.text }); + answer('done'); + return; + } + + const own = request.accountId === login.client.getAccountId(); + if (request.action === 'mark-read') { + answer('accepted'); + await useEmailStore.getState().batchMarkAsRead(activeClient, true, { + emailIds: [request.emailId], + client: login.client, + accountId: own ? undefined : request.accountId, + }); + answer('done'); + return; + } + + // The store's targeted archive reads a login's own folders only. A shared + // account's Archive is the route's to find. + if (!own || !isArchiveActionEnabled()) { + answer('declined'); + return; + } + answer('accepted'); + const email = await login.client.getEmail(request.emailId); + if (!email) { + answer('failed'); + return; + } + const archived = await useEmailStore.getState().batchArchive(activeClient, { + emailIds: [request.emailId], + client: login.client, + emails: [email], + }); + answer(archived ? 'done' : 'failed'); + } catch { + answer('failed'); + } +} diff --git a/lib/pwa-notify/worker-state.ts b/lib/pwa-notify/worker-state.ts new file mode 100644 index 000000000..0fb48cdb6 --- /dev/null +++ b/lib/pwa-notify/worker-state.ts @@ -0,0 +1,58 @@ +import { PUSH_STATE_CACHE } from '@/lib/push-preferences'; + +/** + * What the service worker needs for a notification's buttons. It has no + * settings store and no translations, so the page mirrors these into the + * worker's Cache store, as it does the sound setting (lib/push-preferences.ts). + * Signing out deletes the store with the rest of the push state. + */ +export interface NotificationActionPrefs { + archiveEnabled: boolean; + archiveMode: 'single' | 'year' | 'month'; + labels: { archive: string; markRead: string; reply: string; replyPlaceholder: string }; +} + +const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, ''); + +/** A reply typed on a notification waits this long for the page it opened. */ +const PENDING_REPLY_MAX_AGE_MS = 5 * 60 * 1000; + +/** The cache key sw.js reads the prefs from. */ +export function notificationActionPrefsKey(origin: string): string { + return `${origin}${BASE_PATH}/__push-state/actions`; +} + +/** The cache key sw.js keeps a reply typed on a notification under. */ +export function pendingReplyKey(origin: string): string { + return `${origin}${BASE_PATH}/__push-state/pending-reply`; +} + +export async function writeNotificationActionPrefs(prefs: NotificationActionPrefs): Promise { + try { + if (typeof caches === 'undefined') return; + const cache = await caches.open(PUSH_STATE_CACHE); + await cache.put( + notificationActionPrefsKey(window.location.origin), + new Response(JSON.stringify(prefs), { headers: { 'Content-Type': 'application/json' } }), + ); + } catch { + // Without the store the worker offers no Archive, in English. + } +} + +/** The text typed on this message's notification, once. */ +export async function consumePendingReplyText(emailId: string): Promise { + try { + if (typeof caches === 'undefined') return ''; + const cache = await caches.open(PUSH_STATE_CACHE); + const key = pendingReplyKey(window.location.origin); + const res = await cache.match(key); + if (!res) return ''; + const pending = (await res.json()) as { emailId?: unknown; text?: unknown; at?: unknown }; + await cache.delete(key); + const fresh = typeof pending.at === 'number' && Date.now() - pending.at < PENDING_REPLY_MAX_AGE_MS; + return fresh && pending.emailId === emailId && typeof pending.text === 'string' ? pending.text : ''; + } catch { + return ''; + } +} diff --git a/locales/ar/common.json b/locales/ar/common.json index cc8420d14..a1cb16ab2 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6350,6 +6350,12 @@ "sign_language": "لغة الإشارة", "new_tab": "يُفتح في علامة تبويب جديدة" }, + "pwaNotify": { + "action_archive": "أرشفة", + "action_mark_read": "تمييز كمقروءة", + "action_reply": "رد", + "reply_placeholder": "اكتب ردًا" + }, "structured_mail_calendar": { "add_to_calendar": "إضافة إلى التقويم", "update_in_calendar": "تحديث في التقويم", diff --git a/locales/ca/common.json b/locales/ca/common.json index 6e15f53fc..9458ade33 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Llengua de signes", "new_tab": "s'obre en una pestanya nova" }, + "pwaNotify": { + "action_archive": "Arxiva", + "action_mark_read": "Marca com a llegit", + "action_reply": "Respon", + "reply_placeholder": "Escriu una resposta" + }, "structured_mail_calendar": { "add_to_calendar": "Afegeix al calendari", "update_in_calendar": "Actualitza al calendari", diff --git a/locales/cs/common.json b/locales/cs/common.json index d8426dce2..aaeebd622 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Znakový jazyk", "new_tab": "otevře se na nové kartě" }, + "pwaNotify": { + "action_archive": "Archivovat", + "action_mark_read": "Označit jako přečtené", + "action_reply": "Odpovědět", + "reply_placeholder": "Napište odpověď" + }, "structured_mail_calendar": { "add_to_calendar": "Přidat do kalendáře", "update_in_calendar": "Aktualizovat v kalendáři", diff --git a/locales/da/common.json b/locales/da/common.json index cf66a7294..8e5180c10 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Tegnsprog", "new_tab": "åbner i en ny fane" }, + "pwaNotify": { + "action_archive": "Arkivér", + "action_mark_read": "Markér som læst", + "action_reply": "Svar", + "reply_placeholder": "Skriv et svar" + }, "structured_mail_calendar": { "add_to_calendar": "Tilføj til kalender", "update_in_calendar": "Opdater i kalender", diff --git a/locales/de/common.json b/locales/de/common.json index 3772dbab8..d24c812bc 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Gebärdensprache", "new_tab": "öffnet in einem neuen Tab" }, + "pwaNotify": { + "action_archive": "Archivieren", + "action_mark_read": "Als gelesen markieren", + "action_reply": "Antworten", + "reply_placeholder": "Antwort schreiben" + }, "structured_mail_calendar": { "add_to_calendar": "Zum Kalender hinzufügen", "update_in_calendar": "Im Kalender aktualisieren", diff --git a/locales/en/common.json b/locales/en/common.json index 57a58dab4..e43553012 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Sign language", "new_tab": "opens in a new tab" }, + "pwaNotify": { + "action_archive": "Archive", + "action_mark_read": "Mark as read", + "action_reply": "Reply", + "reply_placeholder": "Write a reply" + }, "structured_mail_calendar": { "add_to_calendar": "Add to calendar", "update_in_calendar": "Update in calendar", diff --git a/locales/es/common.json b/locales/es/common.json index cc786a268..4dc8c717f 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Lengua de signos", "new_tab": "se abre en una pestaña nueva" }, + "pwaNotify": { + "action_archive": "Archivar", + "action_mark_read": "Marcar como leído", + "action_reply": "Responder", + "reply_placeholder": "Escribe una respuesta" + }, "structured_mail_calendar": { "add_to_calendar": "Añadir al calendario", "update_in_calendar": "Actualizar en el calendario", diff --git a/locales/et/common.json b/locales/et/common.json index 2743f7f83..df266cdb2 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Viipekeel", "new_tab": "avaneb uuel vahekaardil" }, + "pwaNotify": { + "action_archive": "Arhiveeri", + "action_mark_read": "Märgi loetuks", + "action_reply": "Vasta", + "reply_placeholder": "Kirjuta vastus" + }, "structured_mail_calendar": { "add_to_calendar": "Lisa kalendrisse", "update_in_calendar": "Uuenda kalendris", diff --git a/locales/fa/common.json b/locales/fa/common.json index 539f81c1a..20f3bd264 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6350,6 +6350,12 @@ "sign_language": "زبان اشاره", "new_tab": "در زبانه‌ای تازه باز می‌شود" }, + "pwaNotify": { + "action_archive": "بایگانی", + "action_mark_read": "علامت‌گذاری خوانده شده", + "action_reply": "پاسخ", + "reply_placeholder": "پاسخ بنویسید" + }, "structured_mail_calendar": { "add_to_calendar": "افزودن به تقویم", "update_in_calendar": "به‌روزرسانی در تقویم", diff --git a/locales/fr/common.json b/locales/fr/common.json index fb7d148ec..019a6c00c 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Langue des signes", "new_tab": "s'ouvre dans un nouvel onglet" }, + "pwaNotify": { + "action_archive": "Archiver", + "action_mark_read": "Marquer comme lu", + "action_reply": "Répondre", + "reply_placeholder": "Écrire une réponse" + }, "structured_mail_calendar": { "add_to_calendar": "Ajouter au calendrier", "update_in_calendar": "Mettre à jour dans le calendrier", diff --git a/locales/he/common.json b/locales/he/common.json index 73d75426a..52650b64a 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6351,6 +6351,12 @@ "sign_language": "שפת סימנים", "new_tab": "נפתח בכרטיסייה חדשה" }, + "pwaNotify": { + "action_archive": "ארכיון", + "action_mark_read": "סמן כנקרא", + "action_reply": "השב", + "reply_placeholder": "כתיבת תשובה" + }, "structured_mail_calendar": { "add_to_calendar": "הוסף ליומן", "update_in_calendar": "עדכן ביומן", diff --git a/locales/hu/common.json b/locales/hu/common.json index b45fe495e..22c05e7e8 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Jelnyelv", "new_tab": "új lapon nyílik meg" }, + "pwaNotify": { + "action_archive": "Archiválás", + "action_mark_read": "Megjelölés olvasottként", + "action_reply": "Válasz", + "reply_placeholder": "Válasz írása" + }, "structured_mail_calendar": { "add_to_calendar": "Hozzáadás a naptárhoz", "update_in_calendar": "Frissítés a naptárban", diff --git a/locales/it/common.json b/locales/it/common.json index a021b7397..3b87ba410 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Lingua dei segni", "new_tab": "si apre in una nuova scheda" }, + "pwaNotify": { + "action_archive": "Archivia", + "action_mark_read": "Segna come letto", + "action_reply": "Rispondi", + "reply_placeholder": "Scrivi una risposta" + }, "structured_mail_calendar": { "add_to_calendar": "Aggiungi al calendario", "update_in_calendar": "Aggiorna nel calendario", diff --git a/locales/ja/common.json b/locales/ja/common.json index f2ea41909..4833b9215 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6350,6 +6350,12 @@ "sign_language": "手話", "new_tab": "新しいタブで開きます" }, + "pwaNotify": { + "action_archive": "アーカイブ", + "action_mark_read": "既読にする", + "action_reply": "返信", + "reply_placeholder": "返信を入力" + }, "structured_mail_calendar": { "add_to_calendar": "カレンダーに追加", "update_in_calendar": "カレンダーを更新", diff --git a/locales/ko/common.json b/locales/ko/common.json index cbde91d32..f403b389e 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6350,6 +6350,12 @@ "sign_language": "수어", "new_tab": "새 탭에서 열림" }, + "pwaNotify": { + "action_archive": "보관", + "action_mark_read": "읽은 상태로 표시", + "action_reply": "답장", + "reply_placeholder": "답장 쓰기" + }, "structured_mail_calendar": { "add_to_calendar": "캘린더에 추가", "update_in_calendar": "캘린더에서 업데이트", diff --git a/locales/lv/common.json b/locales/lv/common.json index 02d30c2f9..5ee617996 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Zīmju valoda", "new_tab": "atveras jaunā cilnē" }, + "pwaNotify": { + "action_archive": "Arhivēt", + "action_mark_read": "Atzīmēt kā izlasītu", + "action_reply": "Atbildēt", + "reply_placeholder": "Rakstiet atbildi" + }, "structured_mail_calendar": { "add_to_calendar": "Pievienot kalendāram", "update_in_calendar": "Atjaunināt kalendārā", diff --git a/locales/mn/common.json b/locales/mn/common.json index 4dd86dea2..bd940ff26 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Дохионы хэл", "new_tab": "шинэ табад нээгдэнэ" }, + "pwaNotify": { + "action_archive": "Архив", + "action_mark_read": "Уншсан гэж тэмдэглэх", + "action_reply": "Хариулах", + "reply_placeholder": "Хариу бичих" + }, "structured_mail_calendar": { "add_to_calendar": "Хуанлид нэмэх", "update_in_calendar": "Хуанлид шинэчлэх", diff --git a/locales/nb/common.json b/locales/nb/common.json index d98bd1756..1362f1e01 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Tegnspråk", "new_tab": "åpnes i en ny fane" }, + "pwaNotify": { + "action_archive": "Arkiver", + "action_mark_read": "Merk som lest", + "action_reply": "Svar", + "reply_placeholder": "Skriv et svar" + }, "structured_mail_calendar": { "add_to_calendar": "Legg til i kalenderen", "update_in_calendar": "Oppdater i kalenderen", diff --git a/locales/nl/common.json b/locales/nl/common.json index 4d541f358..d6694f868 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Gebarentaal", "new_tab": "opent in een nieuw tabblad" }, + "pwaNotify": { + "action_archive": "Archiveren", + "action_mark_read": "Markeren als gelezen", + "action_reply": "Beantwoorden", + "reply_placeholder": "Schrijf een antwoord" + }, "structured_mail_calendar": { "add_to_calendar": "Toevoegen aan agenda", "update_in_calendar": "Bijwerken in agenda", diff --git a/locales/pl/common.json b/locales/pl/common.json index a6a7cd520..45e0db29b 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Język migowy", "new_tab": "otwiera się w nowej karcie" }, + "pwaNotify": { + "action_archive": "Archiwizuj", + "action_mark_read": "Oznacz jako przeczytane", + "action_reply": "Odpowiedz", + "reply_placeholder": "Napisz odpowiedź" + }, "structured_mail_calendar": { "add_to_calendar": "Dodaj do kalendarza", "update_in_calendar": "Zaktualizuj w kalendarzu", diff --git a/locales/pt/common.json b/locales/pt/common.json index 685a98aea..7c5723df2 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Língua de sinais", "new_tab": "abre em uma nova aba" }, + "pwaNotify": { + "action_archive": "Arquivar", + "action_mark_read": "Marcar como lido", + "action_reply": "Responder", + "reply_placeholder": "Escreva uma resposta" + }, "structured_mail_calendar": { "add_to_calendar": "Adicionar ao calendário", "update_in_calendar": "Atualizar no calendário", diff --git a/locales/ro/common.json b/locales/ro/common.json index 45c3fefed..e62685ff1 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Limbaj mimico-gestual", "new_tab": "se deschide într-o filă nouă" }, + "pwaNotify": { + "action_archive": "Arhivează", + "action_mark_read": "Marcați ca citit", + "action_reply": "Răspunde", + "reply_placeholder": "Scrieți un răspuns" + }, "structured_mail_calendar": { "add_to_calendar": "Adaugă în calendar", "update_in_calendar": "Actualizează în calendar", diff --git a/locales/ru/common.json b/locales/ru/common.json index b40069b61..ed4a27405 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Жестовый язык", "new_tab": "открывается в новой вкладке" }, + "pwaNotify": { + "action_archive": "Архивировать", + "action_mark_read": "Отметить прочитанным", + "action_reply": "Ответить", + "reply_placeholder": "Напишите ответ" + }, "structured_mail_calendar": { "add_to_calendar": "Добавить в календарь", "update_in_calendar": "Обновить в календаре", diff --git a/locales/sk/common.json b/locales/sk/common.json index aa0304452..5d154e4c3 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Posunkový jazyk", "new_tab": "otvorí sa na novej karte" }, + "pwaNotify": { + "action_archive": "Archivovať", + "action_mark_read": "Označiť ako prečítané", + "action_reply": "Odpovedať", + "reply_placeholder": "Napíšte odpoveď" + }, "structured_mail_calendar": { "add_to_calendar": "Pridať do kalendára", "update_in_calendar": "Aktualizovať v kalendári", diff --git a/locales/th/common.json b/locales/th/common.json index 4d2c52754..ec2e1774f 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -6358,6 +6358,12 @@ "sign_language": "ภาษามือ", "new_tab": "เปิดในแท็บใหม่" }, + "pwaNotify": { + "action_archive": "เก็บถาวร", + "action_mark_read": "ทำเครื่องหมายว่าอ่านแล้ว", + "action_reply": "ตอบกลับ", + "reply_placeholder": "เขียนคำตอบ" + }, "structured_mail_calendar": { "add_to_calendar": "เพิ่มลงในปฏิทิน", "update_in_calendar": "อัปเดตในปฏิทิน", diff --git a/locales/tr/common.json b/locales/tr/common.json index 4f25997fa..eeebfa2c1 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6350,6 +6350,12 @@ "sign_language": "İşaret dili", "new_tab": "yeni sekmede açılır" }, + "pwaNotify": { + "action_archive": "Arşivle", + "action_mark_read": "Okundu olarak işaretle", + "action_reply": "Yanıtla", + "reply_placeholder": "Yanıt yazın" + }, "structured_mail_calendar": { "add_to_calendar": "Takvime ekle", "update_in_calendar": "Takvimde güncelle", diff --git a/locales/uk/common.json b/locales/uk/common.json index 408e920da..a74e6aef0 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6350,6 +6350,12 @@ "sign_language": "Жестова мова", "new_tab": "відкривається в новій вкладці" }, + "pwaNotify": { + "action_archive": "Архів", + "action_mark_read": "Позначити як прочитане", + "action_reply": "Відповісти", + "reply_placeholder": "Напишіть відповідь" + }, "structured_mail_calendar": { "add_to_calendar": "Додати в календар", "update_in_calendar": "Оновити в календарі", diff --git a/locales/vi/common.json b/locales/vi/common.json index 6952aec2e..583d0db87 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -6362,6 +6362,12 @@ "sign_language": "Ngôn ngữ ký hiệu", "new_tab": "Mở trong một tab mới" }, + "pwaNotify": { + "action_archive": "Lưu trữ", + "action_mark_read": "Đánh dấu là đã đọc", + "action_reply": "Trả lời", + "reply_placeholder": "Viết trả lời" + }, "structured_mail_calendar": { "add_to_calendar": "Thêm vào lịch", "update_in_calendar": "Cập nhật trong lịch", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 1236173cf..5b52b6b63 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6350,6 +6350,12 @@ "sign_language": "手語", "new_tab": "在新分頁中開啟" }, + "pwaNotify": { + "action_archive": "封存", + "action_mark_read": "標示為已讀", + "action_reply": "回覆", + "reply_placeholder": "撰寫回覆" + }, "structured_mail_calendar": { "add_to_calendar": "新增到行事曆", "update_in_calendar": "在行事曆中更新", diff --git a/locales/zh/common.json b/locales/zh/common.json index f51d10c28..c306c55cf 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6350,6 +6350,12 @@ "sign_language": "手语", "new_tab": "在新标签页中打开" }, + "pwaNotify": { + "action_archive": "归档", + "action_mark_read": "标记为已读", + "action_reply": "回复", + "reply_placeholder": "撰写回复" + }, "structured_mail_calendar": { "add_to_calendar": "添加到日历", "update_in_calendar": "在日历中更新", diff --git a/public/sw.js b/public/sw.js index 3dc7ff7b8..8ce60bb8b 100644 --- a/public/sw.js +++ b/public/sw.js @@ -337,6 +337,14 @@ self.addEventListener("pushsubscriptionchange", (event) => { event.waitUntil(handlePushSubscriptionChange(event)); }); +// Background Sync, thin: this worker has no login and can't reach the outbox, +// so it tells the open tabs to flush theirs. With no tab open, the next one +// flushes when it starts. +self.addEventListener("sync", (event) => { + if (event.tag !== OUTBOX_SYNC_TAG) return; + event.waitUntil(tellClientsToFlushOutbox()); +}); + self.addEventListener("message", (event) => { const data = event.data || {}; if (data.type === "client-build") { @@ -486,6 +494,9 @@ async function handlePush(event) { // The login (cookie slot) that owns this account, so the click opens the // message there and not in whichever account happens to be active. const slot = preview && Number.isInteger(preview.slot) ? preview.slot : undefined; + // Which login that slot held when the message came: an action on this + // notification must not land on whoever signed into the slot since. + const login = preview && typeof preview.login === "string" ? preview.login : undefined; // Push subscription is scoped to EmailDelivery, but stragglers from the // older broader-types subscription, marking-as-read races and verification @@ -521,7 +532,7 @@ async function handlePush(event) { // (Organisation > Devices): the route sent neither. title = accountLabel ? `New mail (${accountLabel})` : "New mail"; body = unreadTotal > 1 ? `${unreadTotal} unread messages` : "You have new mail"; - if (unreadTotal <= 1) data = { kind: "email", emailId: email.id, threadId: email.threadId, accountId, slot }; + if (unreadTotal <= 1) data = { kind: "email", emailId: email.id, threadId: email.threadId, accountId, slot, login }; } else if (email) { const sender = email.from && email.from[0]; const senderName = (sender && sender.name) || (sender && sender.email) || "New mail"; @@ -534,7 +545,7 @@ async function handlePush(event) { body += "\n" + (more === 1 ? "+1 more message" : `+${more} more messages`); } else { // Exactly one unread: deep-link straight to that message on click. - data = { kind: "email", emailId: email.id, threadId: email.threadId, accountId, slot }; + data = { kind: "email", emailId: email.id, threadId: email.threadId, accountId, slot, login }; } } else { title = accountLabel ? `New mail (${accountLabel})` : "New mail"; @@ -543,7 +554,7 @@ async function handlePush(event) { // With the sound setting off, nothing rings and no quiet window starts. const audible = (await soundPreference()) && !(await withinQuietWindow(accountId)); - await self.registration.showNotification(title, { + await showWithActions(title, { body, // Shared per-account tag: each new push replaces the account's single // notification rather than adding another. @@ -587,7 +598,7 @@ async function showWithoutAlert(groupTag) { try { const existing = (await self.registration.getNotifications({ tag: groupTag }))[0]; if (existing) { - await self.registration.showNotification(existing.title, { + await showWithActions(existing.title, { ...options, body: existing.body, data: existing.data, @@ -732,7 +743,7 @@ async function handleNotificationClick(event) { return handleMailtoFocusNotificationClick(); } - const targetUrl = buildClickUrl(data); + let targetUrl = buildClickUrl(data); const allClients = await self.clients.matchAll({ type: "window", @@ -748,6 +759,14 @@ async function handleNotificationClick(event) { } } + // A button on the notification. When it can't be done from here, the click + // carries on below and opens the message, like a click on the body. + const action = notificationActionOf(event, data); + if (action) { + if (await runNotificationAction(action, data, event.reply, allClients)) return; + if (action === "reply") targetUrl = buildClickUrl(data, true); + } + for (const client of allClients) { // Reuse an existing tab whenever possible - users on desktop browsers // get annoyed when each notification opens a fresh window. @@ -920,11 +939,12 @@ function getReusableClientScore(state) { return 3; } -function buildClickUrl(data) { +function buildClickUrl(data, reply) { if (!data) return `${BASE_PATH}/`; const params = new URLSearchParams(); if (Number.isInteger(data.slot)) params.set("slot", String(data.slot)); if (typeof data.accountId === "string" && data.accountId) params.set("owner", data.accountId); + if (reply) params.set("reply", "1"); // Not params.size: Safari before 17 (iOS 16.4+ web push) lacks it. const qs = params.toString(); const targetQuery = qs ? `?${qs}` : ""; @@ -941,3 +961,238 @@ function buildClickUrl(data) { // app shell, so the click still feels purposeful. return `${BASE_PATH}/?openLatestUnread=1`; } + +// ---- Notification actions: Archive, Mark read, Reply ---- +// +// A worker has no login. An open tab has one, so it goes first. With none, a +// same-origin route acts with the login's context cookie (app/api/push/action). +// That cookie goes with the browser session and holds a token only a page +// refreshes, so the route can fail; the message then opens in the app. + +const NOTIFICATION_ACTIONS = ["archive", "mark-read", "reply"]; +// How long a tab has to take an action. A frozen tab doesn't answer. +const CLIENT_TAKE_MS = 1_000; +const CLIENTS_TAKE_TOTAL_MS = 2_500; +// How long a tab that took it has before this worker stops waiting for the outcome. +const CLIENT_RESULT_MS = 5_000; +const ACTION_ROUTE_DEADLINE_MS = 6_000; +// All of the above stay inside the 10 s a browser allows openWindow and focus +// after a notification click, so the fallback can still open the message. + +const OUTBOX_SYNC_TAG = "bulwark-outbox"; + +function notificationActionPrefsKey() { + return `${self.location.origin}${BASE_PATH}/__push-state/actions`; +} + +function pendingReplyKey() { + return `${self.location.origin}${BASE_PATH}/__push-state/pending-reply`; +} + +/** + * What the page mirrors into this store (lib/pwa-notify/worker-state.ts): the + * labels in the user's language, Archive on or off, how Archive files mail. + * Nothing stored: no Archive button, since the admin may have turned it off. + */ +async function readNotificationActionPrefs() { + let stored = null; + try { + const cache = await caches.open(PUSH_STATE_CACHE); + const res = await cache.match(notificationActionPrefsKey()); + stored = res ? await res.json() : null; + } catch (_) { + stored = null; + } + const labels = (stored && stored.labels) || {}; + return { + archiveEnabled: !!stored && stored.archiveEnabled === true, + archiveMode: stored && typeof stored.archiveMode === "string" ? stored.archiveMode : "single", + labels: { + archive: labels.archive || "Archive", + markRead: labels.markRead || "Mark as read", + reply: labels.reply || "Reply", + replyPlaceholder: labels.replyPlaceholder || "Write a reply", + }, + }; +} + +function maxNotificationActions() { + if (typeof Notification === "undefined") return 0; + return Number.isInteger(Notification.maxActions) ? Notification.maxActions : 0; +} + +function inlineReplySupported() { + return typeof NotificationEvent !== "undefined" && "reply" in NotificationEvent.prototype; +} + +/** The buttons for a notification. Only one message, with its login known, gets any. */ +async function notificationActions(data) { + if (!data || data.kind !== "email" || !data.emailId || !data.accountId || !data.login) return []; + const max = maxNotificationActions(); + if (max < 1) return []; + const prefs = await readNotificationActionPrefs(); + const offered = { + "mark-read": { action: "mark-read", title: prefs.labels.markRead }, + reply: inlineReplySupported() + ? { action: "reply", title: prefs.labels.reply, type: "text", placeholder: prefs.labels.replyPlaceholder } + : { action: "reply", title: prefs.labels.reply }, + }; + if (prefs.archiveEnabled) offered.archive = { action: "archive", title: prefs.labels.archive }; + // Chromium allows two. Mark read goes first then: opening the message does + // that too, and nothing else on the toast archives or starts a reply. + const kept = ["archive", "reply", "mark-read"].filter((name) => offered[name]).slice(0, max); + return NOTIFICATION_ACTIONS.filter((name) => kept.includes(name)).map((name) => offered[name]); +} + +/** A notification must show, whatever a browser thinks of its buttons. */ +async function showWithActions(title, options) { + const actions = await notificationActions(options.data); + if (actions.length > 0) { + try { + await self.registration.showNotification(title, { ...options, actions }); + return; + } catch (_) { + // Shown without buttons below. + } + } + await self.registration.showNotification(title, options); +} + +function notificationActionOf(event, data) { + if (!NOTIFICATION_ACTIONS.includes(event.action)) return null; + return data.kind === "email" && data.emailId && data.accountId ? event.action : null; +} + +/** Asks one tab. Resolves "done", "failed" (it tried), or "declined" (it didn't take it). */ +function askClient(client, message) { + return new Promise((resolve) => { + if (typeof MessageChannel !== "function") { + resolve("declined"); + return; + } + const channel = new MessageChannel(); + let timer = null; + const finish = (result) => { + clearTimeout(timer); + channel.port1.onmessage = null; + resolve(result); + }; + timer = setTimeout(() => finish("declined"), CLIENT_TAKE_MS); + channel.port1.onmessage = (answer) => { + const status = answer.data && answer.data.status; + if (status === "accepted") { + // It's on it. A slow server shouldn't send the action round twice. + clearTimeout(timer); + timer = setTimeout(() => finish("done"), CLIENT_RESULT_MS); + return; + } + finish(status === "done" || status === "failed" ? status : "declined"); + }; + try { + client.postMessage(message, [channel.port2]); + } catch (_) { + finish("declined"); + } + }); +} + +/** The first open tab that takes the action, the one in front first. */ +async function askOpenClients(message, allClients) { + const score = (client) => (client.focused ? 0 : client.visibilityState === "visible" ? 1 : 2); + const ordered = allClients + .filter((client) => !client.frameType || client.frameType === "top-level") + .sort((a, b) => score(a) - score(b)); + const giveUpAt = Date.now() + CLIENTS_TAKE_TOTAL_MS; + for (const client of ordered) { + if (Date.now() >= giveUpAt) break; + const result = await askClient(client, message); + if (result !== "declined") return { result, client }; + } + return { result: "declined", client: null }; +} + +/** Keeps a typed reply for the page the click is about to open; it's not for a URL. */ +async function keepPendingReply(emailId, text) { + try { + const cache = await caches.open(PUSH_STATE_CACHE); + await cache.put( + pendingReplyKey(), + new Response(JSON.stringify({ emailId, text, at: Date.now() }), { + headers: { "Content-Type": "application/json" }, + }), + ); + } catch (_) { + // The composer opens empty. + } +} + +async function postNotificationAction(action, data) { + const prefs = await readNotificationActionPrefs(); + if (action === "archive" && !prefs.archiveEnabled) return false; + const body = { action, accountId: data.accountId, emailId: data.emailId, login: data.login }; + if (action === "archive") { + body.archiveMode = prefs.archiveMode; + // Year and month folders go by the date the user sees. + if (typeof Intl !== "undefined") body.timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + } + const controller = typeof AbortController === "function" ? new AbortController() : null; + const timer = controller ? setTimeout(() => controller.abort(), ACTION_ROUTE_DEADLINE_MS) : null; + try { + const res = await fetch(`${BASE_PATH}/api/push/action`, { + method: "POST", + credentials: "include", + cache: "no-store", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + ...(controller ? { signal: controller.signal } : {}), + }); + return res.ok; + } catch (_) { + return false; + } finally { + if (timer) clearTimeout(timer); + } +} + +/** True when the action is done and nothing has to open. */ +async function runNotificationAction(action, data, reply, allClients) { + const text = action === "reply" && typeof reply === "string" ? reply.trim() : ""; + const { result, client } = await askOpenClients({ + kind: "notification-action", + action, + emailId: data.emailId, + threadId: data.threadId, + accountId: data.accountId, + slot: data.slot, + login: data.login, + ...(text ? { text } : {}), + }, allClients); + if (result === "done") { + if (action === "reply" && "focus" in client) { + try { + await client.focus(); + } catch (_) { + // The composer is open in that tab; focusing can be blocked by the browser. + } + } + return true; + } + // A tab with a live login tried and failed: the route won't do better. + if (result === "failed") return false; + if (action === "reply") { + if (text) await keepPendingReply(data.emailId, text); + return false; + } + return postNotificationAction(action, data); +} + +async function tellClientsToFlushOutbox() { + const allClients = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + for (const client of allClients) { + try { + client.postMessage({ kind: "outbox-flush" }); + } catch (_) { + // Closed or detached client - ignore. + } + } +}