diff --git a/app/api/push/preview/route.ts b/app/api/push/preview/route.ts new file mode 100644 index 00000000..0bbae2f7 --- /dev/null +++ b/app/api/push/preview/route.ts @@ -0,0 +1,135 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; +import { getStalwartCredentials } from '@/lib/stalwart/credentials'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +/** + * GET /api/push/preview + * + * Called from the service worker when a Web Push wake-up arrives. Fetches the + * latest unread email so the SW can build an enriched system notification + * (sender, subject, avatar) without ever exposing JMAP credentials to the + * SW context. + * + * The relay's push payload is intentionally minimal (just a state-change + * ping), so this is what makes "From: Alice / Subject: …" appear instead of + * a generic "New mail" string. + */ +export async function GET(request: NextRequest) { + try { + const creds = await getStalwartCredentials(request); + if (!creds) { + return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); + } + + const sessionRes = await fetch(`${creds.serverUrl}/.well-known/jmap`, { + headers: { Authorization: creds.authHeader }, + }); + if (!sessionRes.ok) { + return NextResponse.json({ error: 'JMAP session failed' }, { status: 502 }); + } + const session = (await sessionRes.json()) as { + apiUrl?: string; + primaryAccounts?: Record; + }; + const apiUrl = session.apiUrl; + const accountId = session.primaryAccounts?.['urn:ietf:params:jmap:mail']; + if (!apiUrl || !accountId) { + return NextResponse.json({ error: 'Incomplete JMAP session' }, { status: 502 }); + } + + // Find the inbox, then pull the most recent unread message in it. We use + // a single batched JMAP request with back-references so this round-trip + // is one POST regardless of how many messages exist. + const requestBody = { + using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'], + methodCalls: [ + [ + 'Mailbox/query', + { accountId, filter: { role: 'inbox' }, limit: 1 }, + 'mb', + ], + [ + 'Email/query', + { + accountId, + filter: { + operator: 'AND', + conditions: [ + { inMailbox: { resultOf: 'mb', name: 'Mailbox/query', path: '/ids/0' } }, + { notKeyword: '$seen' }, + ], + }, + sort: [{ property: 'receivedAt', isAscending: false }], + limit: 1, + calculateTotal: true, + }, + 'eq', + ], + [ + 'Email/get', + { + accountId, + '#ids': { resultOf: 'eq', name: 'Email/query', path: '/ids' }, + properties: ['id', 'threadId', 'from', 'subject', 'preview', 'receivedAt'], + }, + 'eg', + ], + ], + }; + + const jmapRes = await fetch(apiUrl, { + method: 'POST', + headers: { + Authorization: creds.authHeader, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }); + if (!jmapRes.ok) { + return NextResponse.json({ error: 'JMAP request failed' }, { status: 502 }); + } + const data = (await jmapRes.json()) as { + methodResponses: [string, Record, string][]; + }; + + type EmailLite = { + id: string; + threadId: string; + from?: { name?: string | null; email?: string }[] | null; + subject?: string | null; + preview?: string | null; + receivedAt?: string | null; + }; + + let email: EmailLite | null = null; + let unreadTotal = 0; + for (const [method, body] of data.methodResponses) { + if (method === 'Email/query') { + unreadTotal = ((body as { total?: number }).total) ?? 0; + } + if (method === 'Email/get') { + const list = (body as { list?: EmailLite[] }).list ?? []; + email = list[0] ?? null; + } + } + + return NextResponse.json({ + email, + unreadTotal, + }, { + headers: { + // SW already gates on its own logic - don't let push events get + // cached and served stale. + 'Cache-Control': 'no-store', + }, + }); + } catch (error) { + logger.error('push preview failed', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + return NextResponse.json({ error: 'Internal error' }, { status: 500 }); + } +} diff --git a/components/settings/notification-settings.tsx b/components/settings/notification-settings.tsx index 66bf5f97..3fd51fc3 100644 --- a/components/settings/notification-settings.tsx +++ b/components/settings/notification-settings.tsx @@ -1,13 +1,32 @@ "use client"; +import { useEffect, useState } from 'react'; import { useTranslations } from 'next-intl'; import { useSettingsStore } from '@/stores/settings-store'; import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section'; import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound'; import type { NotificationSoundChoice } from '@/lib/notification-sound'; import { Button } from '@/components/ui/button'; -import { Volume2 } from 'lucide-react'; +import { CheckCircle2, Volume2, XCircle } from 'lucide-react'; import { usePolicyStore } from '@/stores/policy-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { useConfirmDialog } from '@/hooks/use-confirm-dialog'; +import { + DEFAULT_RELAY_BASE_URL, + WebPushUnsupportedError, + disableWebPush, + enableWebPush, + isWebPushEnabled, + isWebPushSupported, +} from '@/lib/web-push'; + +type PushStatus = + | { kind: 'idle' } + | { kind: 'busy' } + | { kind: 'enabled' } + | { kind: 'unsupported' } + | { kind: 'error'; message: string }; export function NotificationSettings() { const t = useTranslations('settings.notifications'); @@ -21,6 +40,77 @@ export function NotificationSettings() { updateSetting, } = useSettingsStore(); const { isSettingLocked, isSettingHidden } = usePolicyStore(); + const client = useAuthStore((s) => s.client); + const username = useAuthStore((s) => s.username); + const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); + + const supported = typeof window !== 'undefined' && isWebPushSupported(); + const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY_BASE_URL); + const [pushStatus, setPushStatus] = useState( + supported ? { kind: 'idle' } : { kind: 'unsupported' }, + ); + + useEffect(() => { + if (!supported) return; + void (async () => { + const enabled = await isWebPushEnabled(); + if (enabled) setPushStatus({ kind: 'enabled' }); + })(); + }, [supported]); + + const trimmedRelay = relayUrl.trim().replace(/\/+$/, ''); + const isValidRelay = /^https?:\/\/.+/i.test(trimmedRelay); + const busy = pushStatus.kind === 'busy'; + + const handleEnablePush = async () => { + if (!client) { + setPushStatus({ kind: 'error', message: 'Sign in first' }); + return; + } + if (!isValidRelay) { + setPushStatus({ kind: 'error', message: 'Enter a valid https:// URL' }); + return; + } + setPushStatus({ kind: 'busy' }); + try { + await enableWebPush({ + client, + relayBaseUrl: trimmedRelay, + accountLabel: username ?? undefined, + }); + setPushStatus({ kind: 'enabled' }); + } catch (err) { + if (err instanceof WebPushUnsupportedError) { + setPushStatus({ kind: 'unsupported' }); + return; + } + setPushStatus({ + kind: 'error', + message: err instanceof Error ? err.message : 'Failed to enable push', + }); + } + }; + + const handleDisablePush = async () => { + if (!client) return; + const confirmed = await confirmDialog({ + title: t('push.confirm_disable_title'), + message: t('push.confirm_disable_message'), + confirmText: t('push.disable'), + variant: 'destructive', + }); + if (!confirmed) return; + setPushStatus({ kind: 'busy' }); + try { + await disableWebPush({ client, relayBaseUrl: trimmedRelay }); + setPushStatus({ kind: 'idle' }); + } catch (err) { + setPushStatus({ + kind: 'error', + message: err instanceof Error ? err.message : 'Failed to disable push', + }); + } + }; const soundOptions = NOTIFICATION_SOUNDS.map((s) => ({ value: s.id, @@ -29,6 +119,46 @@ export function NotificationSettings() { return (
+ +
+
+ + +
+

{t('push.relay_desc')}

+ setRelayUrl(e.target.value)} + placeholder={t('push.relay_placeholder')} + disabled={busy || pushStatus.kind === 'unsupported'} + className="w-full rounded border bg-background px-3 py-2 text-sm disabled:opacity-50" + /> +
+ + {pushStatus.kind === 'enabled' && ( + + )} +
+ {pushStatus.kind === 'unsupported' && ( +

{t('push.ios_hint')}

+ )} +
+
+ + +
); } + +function PushStatusBadge({ + status, + t, +}: { + status: PushStatus; + t: ReturnType; +}) { + if (status.kind === 'enabled') { + return ( + + + {t('push.status_active')} + + ); + } + if (status.kind === 'busy') { + return {t('push.status_busy')}; + } + if (status.kind === 'unsupported') { + return ( + + + {t('push.status_unsupported')} + + ); + } + if (status.kind === 'error') { + return ( + + + {status.message} + + ); + } + return {t('push.status_inactive')}; +} diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index db06c1ec..5b825a94 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -95,6 +95,18 @@ export class DemoJMAPClient implements IJMAPClient { getLastStates(): AccountStates { return { ...this.lastStates }; } setLastStates(states: AccountStates): void { this.lastStates = { ...states }; } + // PushSubscription endpoints have no meaning in demo mode - the demo client + // never makes real network calls so there's nothing for the relay to push to. + async listPushSubscriptions() { return []; } + async createPushSubscription(): Promise { + throw new Error('Push subscriptions are not available in demo mode'); + } + async verifyPushSubscription(): Promise { + throw new Error('Push subscriptions are not available in demo mode'); + } + async updatePushSubscription(): Promise { return false; } + async destroyPushSubscription(): Promise { /* no-op */ } + // ── Quota ───────────────────────────────────────────────────── async getQuota(): Promise<{ used: number; total: number } | null> { diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 041b02f3..e82f0bf4 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -1,4 +1,4 @@ -import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal } from "./types"; +import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, Principal, PushSubscription } from "./types"; import type { SieveScript, SieveCapabilities } from "./sieve-types"; /** @@ -51,6 +51,20 @@ export interface IJMAPClient { getLastStates(): AccountStates; setLastStates(states: AccountStates): void; + // ── PushSubscription (RFC 8620 §7.2) ─────────────────────────── + // Browser-driven Web Push setup: register a relay URL the JMAP server can + // forward StateChange events to. Mobile uses the same primitives. + listPushSubscriptions(): Promise; + createPushSubscription(params: { + deviceClientId: string; + url: string; + types: string[]; + expires?: string; + }): Promise; + verifyPushSubscription(id: string, verificationCode: string): Promise; + updatePushSubscription(id: string, patch: { expires?: string; types?: string[] }): Promise; + destroyPushSubscription(id: string): Promise; + // ── Quota ───────────────────────────────────────────────────── getQuota(): Promise<{ used: number; total: number } | null>; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 49ec63c2..0bce1a2b 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1,4 +1,4 @@ -import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal } from "./types"; +import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, Principal, PushSubscription } from "./types"; import type { SieveScript, SieveCapabilities } from "./sieve-types"; import type { IJMAPClient } from "./client-interface"; import { toWildcardQuery } from "./search-utils"; @@ -5313,4 +5313,80 @@ export class JMAPClient implements IJMAPClient { } } } + + // ── PushSubscription (RFC 8620 §7.2) ────────────────────────────── + // Used by the PWA Web Push integration. The mobile app does the same dance + // through its own JMAP client - keep these in sync. + + async listPushSubscriptions(): Promise { + const response = await this.request( + [['PushSubscription/get', { ids: null }, '0']], + ['urn:ietf:params:jmap:core'], + ); + const [, body] = response.methodResponses[0] ?? []; + return ((body as { list?: PushSubscription[] } | undefined)?.list) ?? []; + } + + async createPushSubscription(params: { + deviceClientId: string; + url: string; + types: string[]; + expires?: string; + }): Promise { + const created: Record = { + deviceClientId: params.deviceClientId, + url: params.url, + types: params.types, + }; + if (params.expires) created.expires = params.expires; + + const response = await this.request( + [['PushSubscription/set', { create: { new: created } }, '0']], + ['urn:ietf:params:jmap:core'], + ); + const [, body] = response.methodResponses[0] ?? []; + const result = (body as { created?: { new?: { id?: string } }; notCreated?: { new?: unknown } } | undefined); + const id = result?.created?.new?.id; + if (!id) { + throw new Error( + `PushSubscription/set create failed: ${JSON.stringify(result?.notCreated?.new ?? body)}`, + ); + } + return id; + } + + async verifyPushSubscription(id: string, verificationCode: string): Promise { + const response = await this.request( + [['PushSubscription/set', { update: { [id]: { verificationCode } } }, '0']], + ['urn:ietf:params:jmap:core'], + ); + const [, body] = response.methodResponses[0] ?? []; + const notUpdated = (body as { notUpdated?: Record } | undefined)?.notUpdated?.[id]; + if (notUpdated) { + throw new Error(`PushSubscription verification failed: ${JSON.stringify(notUpdated)}`); + } + } + + // Returns false when the server rejects the update (e.g. the subscription + // was already destroyed) - the caller treats that as a signal to recreate. + async updatePushSubscription( + id: string, + patch: { expires?: string; types?: string[] }, + ): Promise { + const response = await this.request( + [['PushSubscription/set', { update: { [id]: patch } }, '0']], + ['urn:ietf:params:jmap:core'], + ); + const [, body] = response.methodResponses[0] ?? []; + const r = body as { updated?: Record; notUpdated?: Record } | undefined; + if (r?.notUpdated?.[id]) return false; + return r?.updated?.[id] !== undefined; + } + + async destroyPushSubscription(id: string): Promise { + await this.request( + [['PushSubscription/set', { destroy: [id] }, '0']], + ['urn:ietf:params:jmap:core'], + ); + } } \ No newline at end of file diff --git a/lib/web-push.ts b/lib/web-push.ts new file mode 100644 index 00000000..fa03253c --- /dev/null +++ b/lib/web-push.ts @@ -0,0 +1,322 @@ +// Browser-side Web Push setup. Mirrors the React Native flow in +// repos/react-native/src/lib/push-notifications.ts so the relay sees the same +// shape from both clients - the only differences are which native API +// produces the push token (PushManager.subscribe here, FCM there) and which +// register endpoint we hit on the relay. + +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +const DEVICE_CLIENT_ID_KEY = 'bulwark.push.deviceClientId.v1'; +const SUBSCRIPTION_ID_KEY = 'bulwark.push.subscriptionId.v1'; + +// Hosted relay so self-hosters don't need their own VAPID + Firebase setup. +// Override at build time via NEXT_PUBLIC_PUSH_RELAY_URL or at runtime by +// calling enableWebPush({ relayBaseUrl }) from the settings UI. +export const DEFAULT_RELAY_BASE_URL = + process.env.NEXT_PUBLIC_PUSH_RELAY_URL || 'https://notifications.relay.bulwarkmail.org'; + +// Match the mobile app's lifetime hint. The JMAP server may clamp this down. +const SUBSCRIPTION_EXPIRES_DAYS = 90; +const SUBSCRIPTION_REFRESH_THRESHOLD_DAYS = 7; + +const PUSH_TYPES = ['Email', 'EmailDelivery', 'Mailbox'] as const; + +export interface EnableWebPushParams { + client: IJMAPClient; + // Optional - falls back to DEFAULT_RELAY_BASE_URL. + relayBaseUrl?: string; + // Free-form label the relay shows in /metrics; never returned in pushes. + accountLabel?: string; +} + +export interface EnableWebPushResult { + subscriptionId: string; +} + +export class WebPushUnsupportedError extends Error { + constructor(message: string) { + super(message); + this.name = 'WebPushUnsupportedError'; + } +} + +export function isWebPushSupported(): boolean { + if (typeof window === 'undefined') return false; + return ( + 'serviceWorker' in navigator && + 'PushManager' in window && + 'Notification' in window + ); +} + +function buildRelayUrl(base: string, suffix: string): string { + return base.replace(/\/+$/, '') + suffix; +} + +function expiresFromNow(days: number): string { + return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString(); +} + +function randomDeviceClientId(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); +} + +function getOrCreateDeviceClientId(): string { + const existing = localStorage.getItem(DEVICE_CLIENT_ID_KEY); + if (existing) return existing; + const next = randomDeviceClientId(); + localStorage.setItem(DEVICE_CLIENT_ID_KEY, next); + return next; +} + +// PushManager.subscribe wants the VAPID public key as a BufferSource. +// Returning a Uint8Array (not the wider ArrayBufferLike that +// includes SharedArrayBuffer) keeps strict TS happy on lib.dom 2024+. +function urlBase64ToUint8Array(base64Url: string): Uint8Array { + const padding = '='.repeat((4 - (base64Url.length % 4)) % 4); + const base64 = (base64Url + padding).replace(/-/g, '+').replace(/_/g, '/'); + const raw = atob(base64); + const buffer = new ArrayBuffer(raw.length); + const out = new Uint8Array(buffer); + for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); + return out; +} + +function readPushKey( + sub: PushSubscription, + name: 'p256dh' | 'auth', +): string { + const raw = sub.getKey(name); + if (!raw) throw new Error(`PushSubscription is missing the ${name} key`); + // Browsers want application/json over the wire so encode as base64url. + let binary = ''; + const bytes = new Uint8Array(raw); + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +async function fetchVapidPublicKey(relayBaseUrl: string): Promise { + const res = await fetch(buildRelayUrl(relayBaseUrl, '/api/push/vapid-public-key')); + if (!res.ok) { + if (res.status === 503) { + throw new Error('The push relay does not have Web Push configured'); + } + throw new Error(`Failed to fetch VAPID key: ${res.status}`); + } + const body = (await res.json()) as { publicKey?: string }; + if (!body.publicKey) throw new Error('Relay returned an empty VAPID key'); + return body.publicKey; +} + +async function ensurePermission(): Promise { + if (Notification.permission === 'granted') return; + if (Notification.permission === 'denied') { + throw new Error('Notifications are blocked - allow them in browser settings to continue'); + } + const result = await Notification.requestPermission(); + if (result !== 'granted') { + throw new Error('Notification permission was not granted'); + } +} + +async function ensureServiceWorker(): Promise { + // The webmail's PWA already registers /sw.js for installability. If it + // hasn't been picked up yet (e.g. first load), kick it ourselves so the + // push handler is in place. + let registration = await navigator.serviceWorker.getRegistration('/'); + if (!registration) { + registration = await navigator.serviceWorker.register('/sw.js'); + } + await navigator.serviceWorker.ready; + return registration; +} + +async function registerWithRelay(params: { + relayBaseUrl: string; + subscriptionId: string; + // Subset of PushSubscriptionJSON we actually serialise. Inlined so eslint's + // no-undef rule (which doesn't know about DOM type-only globals) is happy. + subscription: { + endpoint: string; + keys: { p256dh: string; auth: string }; + }; + accountLabel?: string; +}): Promise { + const { endpoint, keys } = params.subscription; + if (!endpoint || !keys?.p256dh || !keys?.auth) { + throw new Error('Browser returned an incomplete PushSubscription'); + } + const res = await fetch(buildRelayUrl(params.relayBaseUrl, '/api/push/register/web'), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + subscriptionId: params.subscriptionId, + subscription: { endpoint, keys: { p256dh: keys.p256dh, auth: keys.auth } }, + accountLabel: params.accountLabel, + }), + }); + if (!res.ok) { + throw new Error(`Relay register failed: ${res.status}`); + } +} + +async function pollVerificationCode( + relayBaseUrl: string, + subscriptionId: string, +): Promise { + const timeoutAt = Date.now() + 20_000; + let delay = 400; + while (Date.now() < timeoutAt) { + const res = await fetch( + buildRelayUrl(relayBaseUrl, `/api/push/verify/${encodeURIComponent(subscriptionId)}`), + ); + if (res.ok) { + const body = (await res.json()) as { verificationCode?: string | null }; + if (body.verificationCode) return body.verificationCode; + } + await new Promise((r) => setTimeout(r, delay)); + delay = Math.min(delay * 1.5, 2000); + } + throw new Error('Timed out waiting for PushVerification from the JMAP server'); +} + +async function refreshSubscriptionExpires( + client: IJMAPClient, + sub: { id: string; expires: string | null }, +): Promise { + if (sub.expires) { + const remainingMs = new Date(sub.expires).getTime() - Date.now(); + const thresholdMs = SUBSCRIPTION_REFRESH_THRESHOLD_DAYS * 24 * 60 * 60 * 1000; + if (Number.isFinite(remainingMs) && remainingMs > thresholdMs) return true; + } + try { + return await client.updatePushSubscription(sub.id, { + expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS), + }); + } catch { + return false; + } +} + +export async function enableWebPush( + params: EnableWebPushParams, +): Promise { + if (!isWebPushSupported()) { + throw new WebPushUnsupportedError( + 'This browser does not support Web Push. On iOS the site needs to be installed to the home screen.', + ); + } + + const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, ''); + if (!relayBaseUrl) throw new Error('relayBaseUrl is required'); + + await ensurePermission(); + const registration = await ensureServiceWorker(); + + const vapidPublicKey = await fetchVapidPublicKey(relayBaseUrl); + + // Reuse an existing browser PushSubscription when possible - resubscribing + // with the same VAPID key produces the same endpoint, but the call still + // costs a network round-trip the user can feel. + let pushSubscription = await registration.pushManager.getSubscription(); + if (pushSubscription) { + const keyMatches = pushSubscription.options?.applicationServerKey; + if (!keyMatches) { + await pushSubscription.unsubscribe(); + pushSubscription = null; + } + } + if (!pushSubscription) { + pushSubscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), + }); + } + + const deviceClientId = getOrCreateDeviceClientId(); + + await registerWithRelay({ + relayBaseUrl, + subscriptionId: deviceClientId, + subscription: { + endpoint: pushSubscription.endpoint, + keys: { + p256dh: readPushKey(pushSubscription, 'p256dh'), + auth: readPushKey(pushSubscription, 'auth'), + }, + }, + accountLabel: params.accountLabel, + }); + + // Reuse the JMAP-side PushSubscription if the server still has it, just + // refreshing the expiry so it doesn't time out between sessions. + const storedServerId = localStorage.getItem(SUBSCRIPTION_ID_KEY); + if (storedServerId) { + const existing = await params.client.listPushSubscriptions().catch(() => []); + const match = existing.find((s) => s.id === storedServerId); + if (match) { + const refreshed = await refreshSubscriptionExpires(params.client, match); + if (refreshed) return { subscriptionId: storedServerId }; + await params.client.destroyPushSubscription(storedServerId).catch(() => undefined); + } + localStorage.removeItem(SUBSCRIPTION_ID_KEY); + } + + const serverAssignedId = await params.client.createPushSubscription({ + deviceClientId, + url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`), + types: [...PUSH_TYPES], + expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS), + }); + + const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId); + await params.client.verifyPushSubscription(serverAssignedId, verificationCode); + localStorage.setItem(SUBSCRIPTION_ID_KEY, serverAssignedId); + + return { subscriptionId: serverAssignedId }; +} + +export interface DisableWebPushParams { + client: IJMAPClient; + relayBaseUrl?: string; +} + +// Best-effort teardown: clear the JMAP subscription, the relay mapping, and +// the browser PushSubscription. Any single failure is swallowed so the user +// always ends up in a "disabled" state locally. +export async function disableWebPush(params: DisableWebPushParams): Promise { + const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, ''); + + const storedServerId = localStorage.getItem(SUBSCRIPTION_ID_KEY); + if (storedServerId) { + await params.client.destroyPushSubscription(storedServerId).catch(() => undefined); + localStorage.removeItem(SUBSCRIPTION_ID_KEY); + } + + const deviceClientId = localStorage.getItem(DEVICE_CLIENT_ID_KEY); + if (deviceClientId && relayBaseUrl) { + await fetch( + buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`), + { method: 'DELETE' }, + ).catch(() => undefined); + } + + if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) { + const registration = await navigator.serviceWorker.getRegistration('/'); + const sub = await registration?.pushManager.getSubscription(); + if (sub) await sub.unsubscribe().catch(() => undefined); + } +} + +export async function isWebPushEnabled(): Promise { + if (!isWebPushSupported()) return false; + if (Notification.permission !== 'granted') return false; + const registration = await navigator.serviceWorker.getRegistration('/'); + if (!registration) return false; + const sub = await registration.pushManager.getSubscription(); + return sub !== null && localStorage.getItem(SUBSCRIPTION_ID_KEY) !== null; +} diff --git a/locales/en/common.json b/locales/en/common.json index 49e78319..97551606 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -783,6 +783,23 @@ "swift": "Swift Gesture", "relax": "Relax" }, + "push": { + "title": "Background Notifications", + "description": "Receive system notifications for new mail when this site is closed. Delivered via the Bulwark push relay; the relay never sees mail content.", + "relay_label": "Push relay", + "relay_desc": "Defaults to the hosted Bulwark relay. Change only if you self-host.", + "relay_placeholder": "https://notifications.relay.example.com", + "status_active": "Active on this device", + "status_inactive": "Not enabled on this device", + "status_unsupported": "This browser does not support Web Push", + "status_busy": "Working…", + "enable": "Enable", + "reenable": "Re-register", + "disable": "Disable", + "confirm_disable_title": "Disable background notifications?", + "confirm_disable_message": "This device will stop receiving alerts when the site is closed.", + "ios_hint": "On iOS, install the site to your home screen first - Safari only delivers Web Push to installed PWAs." + }, "sound_selection": { "title": "Notification Sound", "description": "Choose which sound to play for notifications", diff --git a/public/sw.js b/public/sw.js index 4110d0c8..373b60e1 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,8 +1,14 @@ /* eslint-disable no-undef */ -// Minimal service worker – satisfies the PWA installability requirement -// without caching any assets. All requests fall through to the network, -// so there is no risk of serving stale chunks after a deployment. +// Bulwark service worker. +// +// This SW does two jobs: +// 1. Satisfy the PWA installability requirement (network-only fetch handler, +// no caching - so we never serve stale chunks after a deployment). +// 2. Receive Web Push wake-up pings from the relay and turn them into +// enriched system notifications. Mirrors the React Native FCM headless +// task: relay sends only a state-change ping, the client fetches the +// newest unread email itself so the relay never sees mail content. self.addEventListener("install", () => { self.skipWaiting(); @@ -12,5 +18,116 @@ self.addEventListener("activate", (event) => { event.waitUntil(self.clients.claim()); }); -// Network-only fetch handler – no caching. self.addEventListener("fetch", () => {}); + +self.addEventListener("push", (event) => { + event.waitUntil(handlePush(event)); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + event.waitUntil(handleNotificationClick(event)); +}); + +async function handlePush(event) { + let payload = null; + try { + payload = event.data ? event.data.json() : null; + } catch (_) { + payload = null; + } + + const accountLabel = (payload && typeof payload.accountLabel === "string") + ? payload.accountLabel + : ""; + + // Best effort: ask the webmail to look up the latest unread email so we can + // build a useful notification. If the request fails (offline, session + // expired, server down) we fall back to a generic "New mail" so the user + // still sees something. + let preview = null; + try { + const res = await fetch("/api/push/preview", { + credentials: "include", + cache: "no-store", + }); + if (res.ok) { + preview = await res.json(); + } + } catch (_) { + preview = null; + } + + const email = preview && preview.email ? preview.email : null; + const unreadTotal = preview && typeof preview.unreadTotal === "number" + ? preview.unreadTotal + : 0; + + let title; + let body; + let tag = "bulwark-mail"; + let data = { kind: "mail-list" }; + + if (email) { + const sender = email.from && email.from[0]; + const senderName = (sender && sender.name) || (sender && sender.email) || "New mail"; + title = senderName + (accountLabel ? ` (${accountLabel})` : ""); + body = email.subject || email.preview || "(no subject)"; + tag = "bulwark-mail:" + email.id; + data = { + kind: "email", + emailId: email.id, + threadId: email.threadId, + }; + } else { + title = accountLabel ? `New mail (${accountLabel})` : "New mail"; + body = unreadTotal > 1 ? `${unreadTotal} unread messages` : "You have new mail"; + } + + await self.registration.showNotification(title, { + body, + tag, + icon: "/icon-192x192.png", + badge: "/icon-192x192.png", + data, + renotify: true, + }); +} + +async function handleNotificationClick(event) { + const data = event.notification.data || {}; + const targetUrl = buildClickUrl(data); + + const allClients = await self.clients.matchAll({ + type: "window", + includeUncontrolled: 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. + if ("focus" in client) { + try { + if ("navigate" in client && targetUrl) { + await client.navigate(targetUrl); + } + return client.focus(); + } catch (_) { + // navigate() can reject for cross-origin or detached clients - fall + // through and open a new window below. + } + } + } + + if (self.clients.openWindow) { + return self.clients.openWindow(targetUrl || "/"); + } +} + +function buildClickUrl(data) { + if (!data) return "/"; + if (data.kind === "email" && data.emailId) { + return `/?email=${encodeURIComponent(data.emailId)}`; + } + return "/"; +}