// 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'; import type { AccountInfo, EmailPushConfig, Mailbox } from '@/lib/jmap/types'; import { isJmapSetError, jmapErrorType } from '@/lib/jmap/jmap-error'; import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays'; import { IS_LITE } from '@/lib/lite'; import { EMPTY_NOTIFICATION_MUTES, isAccountMuted, isMailboxMuted, mailboxOverridesForAccount, type NotificationMutes, } from '@/lib/notification-mutes'; // Per-account keys: a single browser may be signed in to multiple accounts, // each with its own JMAP PushSubscription and its own relay record. Scoping // the deviceClientId per account is what makes per-account notifications work // at all - the relay keys subscriptions on subscriptionId (= deviceClientId), // so a globally-shared key meant re-registering account B overwrote A. const DEVICE_CLIENT_ID_PREFIX = 'bulwark.push.deviceClientId.v1.'; const SUBSCRIPTION_ID_PREFIX = 'bulwark.push.subscriptionId.v1.'; // JMAP account ids the server refused in this account's emailPush map (see // emailPushFallbacks). Left out of the map from then on, so a refusal isn't // provoked again on every page load. Re-register and disable clear it. const EMAIL_PUSH_REFUSED_PREFIX = 'bulwark.push.emailPushRefused.v1.'; function deviceClientIdKey(accountId: string): string { return DEVICE_CLIENT_ID_PREFIX + accountId; } function subscriptionIdKey(accountId: string): string { return SUBSCRIPTION_ID_PREFIX + accountId; } function emailPushRefusedKey(accountId: string): string { return EMAIL_PUSH_REFUSED_PREFIX + accountId; } const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, ''); const SW_SCOPE = `${BASE_PATH}/`; const SW_URL = `${BASE_PATH}/sw.js`; // Re-exported for callers that already import the relay default from here. // The relay list itself lives in lib/push-relays.ts. export { DEFAULT_RELAY_BASE_URL }; // 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; // Only `EmailDelivery` state-changes when new mail is actually delivered. // `Email` fires for any mutation (sending, drafting, moving, marking read, // deleting) and `Mailbox` fires for mailbox edits - both produced spurious // system notifications, so we keep them out of the push subscription. // In-app sync uses a separate StateChange channel and is unaffected. const PUSH_TYPES = ['EmailDelivery'] as const; // draft-ietf-jmap-emailpush (Stalwart >= 0.16.16). `EmailDelivery` alone // still fires for every ingested message - including spam the server files // straight into Junk - because the server can't know which folders a client // cares about. With `emailPush` the server evaluates a per-account filter // against each new message before pushing and stays silent on a miss, so // junk-filed mail never wakes the device. Older servers don't advertise the // capability and get the plain EmailDelivery subscription as before. export const EMAIL_PUSH_CAPABILITY = 'urn:ietf:params:jmap:emailpush'; // Only ids: the relay stays content-blind and the SW dedupes on them. const EMAIL_PUSH_PROPERTIES = ['id', 'threadId']; function sameTypes(a: readonly string[] | null | undefined, b: readonly string[]): boolean { if (!a || a.length !== b.length) return false; const sortedA = [...a].sort(); const sortedB = [...b].sort(); return sortedA.every((t, i) => t === sortedB[i]); } export function serverSupportsEmailPush(client: IJMAPClient): boolean { try { return EMAIL_PUSH_CAPABILITY in (client.getCapabilities() ?? {}); } catch { return false; } } /** * The delivery filter we want on every account the subscription fans out to. * * Default mode: skip anything the spam filter tagged `$junk` and anything * that lives only in a Junk-role mailbox (Sieve `fileinto` doesn't set the * keyword). The two are ANDed so a stale mailbox id - the user deleted and * recreated Junk - degrades to keyword-only filtering rather than letting * everything through. * * Inbox-only mode: same `$junk` keyword guard, but the mailbox condition * requires the message to be IN the account's Inbox rather than merely NOT * in Junk - mail a Sieve rule files into any other folder stays silent. A * group account with no Inbox we can see gets a filter that never matches: * leaving it out of the map would make the server fall back to unfiltered * pushes for that account. * * Only the primary account and the accounts that list emailpush in their * accountCapabilities go in the map. Stalwart fans a subscription out to the * user's own account and their groups only, and refuses the whole map as * `forbidden` when it names a mailbox shared by ACL. 1.0 lists emailpush on * exactly those accounts; 0.16.16 to 0.16.23 list it on every account, and * enableWebPush narrows the map when the server refuses it. * * Muted accounts and folders (#1169) are cut out of the same filter. In the * default mode a muted folder joins Junk in the "not only in" list; a muted * account only lets through mail in the folders the user unmuted again, or * nothing at all. In inbox-only mode a muted Inbox silences the account. */ export async function buildEmailPushConfig( client: IJMAPClient, inboxOnly: boolean, mutes: NotificationMutes = EMPTY_NOTIFICATION_MUTES, ): Promise> { const primary = client.getAccountId(); // Every such account that has any mailbox gets a filter entry - a group // account without a Junk-role mailbox still needs one so its subscription // isn't left unfiltered. const accountIds = new Set([primary]); const junkByAccount = new Map(); const inboxByAccount = new Map(); const mailboxIdsByAccount = new Map>(); // In inbox-only mode a swallowed fetch failure would masquerade as "no Inbox // mailbox" below, and with mutes it would drop them all and unmute // everything, so let the real error surface; otherwise the default mode can // still degrade to keyword-only filtering on a transient miss. const hasMutes = mutes.accounts.length > 0 || Object.keys(mutes.mailboxes).length > 0; const mailboxes = inboxOnly || hasMutes ? await client.getAllMailboxes() : await client.getAllMailboxes().catch(() => [] as Mailbox[]); for (const m of mailboxes) { const accountId = m.accountId || primary; if (accountId !== primary && !client.hasAccountCapability(EMAIL_PUSH_CAPABILITY, accountId)) continue; accountIds.add(accountId); // Shared-account mailboxes carry a client-side ":" id; // the server only knows the original. const serverId = m.originalId ?? m.id; const ids = mailboxIdsByAccount.get(accountId) ?? new Set(); ids.add(serverId); mailboxIdsByAccount.set(accountId, ids); if (m.role === 'junk') { const junk = junkByAccount.get(accountId) ?? []; junk.push(serverId); junkByAccount.set(accountId, junk); } if (m.role === 'inbox') inboxByAccount.set(accountId, serverId); } const config: Record = {}; for (const accountId of accountIds) { const junkIds = junkByAccount.get(accountId) ?? []; // Settings for folders that no longer exist are left out. const existing = mailboxIdsByAccount.get(accountId) ?? new Set(); const overrides = mailboxOverridesForAccount(mutes, accountId); const conditions: Record[] = [{ notKeyword: '$junk' }]; if (inboxOnly) { const inboxId = inboxByAccount.get(accountId); // The primary account always has an Inbox; missing it means its // Mailbox/get failed, and muting it would be worse than failing loudly. if (!inboxId && accountId === primary) { throw new Error(`No Inbox mailbox found for account ${accountId}; cannot build an inbox-only push filter`); } conditions.push(inboxId && !isMailboxMuted(mutes, accountId, inboxId) ? { inMailbox: inboxId } : NEVER_MATCH); } else if (isAccountMuted(mutes, accountId)) { const allowed = overrides.unmuted .filter((id) => existing.has(id) && !junkIds.includes(id)) .sort(); if (allowed.length === 0) conditions.push(NEVER_MATCH); else if (allowed.length === 1) conditions.push({ inMailbox: allowed[0] }); else conditions.push({ operator: 'OR', conditions: allowed.map((id) => ({ inMailbox: id })) }); } else { const excluded = new Set([...junkIds, ...overrides.muted.filter((id) => existing.has(id))]); if (excluded.size > 0) conditions.push({ inMailboxOtherThan: [...excluded].sort() }); } config[accountId] = { // Always the operator form: that's how the server echoes it back, so a // stored config compares equal to a freshly built one. filter: { operator: 'AND', conditions }, properties: [...EMAIL_PUSH_PROPERTIES], urgency: 'high', }; } return config; } // Contradicts the `notKeyword: '$junk'` every filter starts with, so the AND // never matches: the account stays in the map but never pushes. const NEVER_MATCH = { hasKeyword: '$junk' } as const; function normalizeEmailPush(value: unknown): string { const sortKeys = (v: unknown): unknown => { if (Array.isArray(v)) return v.map(sortKeys); if (v && typeof v === 'object') { return Object.keys(v as Record).sort().reduce>((acc, k) => { acc[k] = sortKeys((v as Record)[k]); return acc; }, {}); } return v; }; return JSON.stringify(sortKeys(value ?? null)); } function sameEmailPush( a: Record | null | undefined, b: Record, ): boolean { if (!a) return false; return normalizeEmailPush(a) === normalizeEmailPush(b); } // RFC 9670. Stalwart 1.0 lists it in every account's accountCapabilities, // 0.16 in none. const PRINCIPALS_OWNER_CAPABILITY = 'urn:ietf:params:jmap:principals:owner'; /** * Whether the account capabilities name the accounts a subscription reaches: * Stalwart 1.0 lists emailpush on the user's own account and their groups * only. 0.16.16 to 0.16.23 list it on every account, ACL shares included, and * 0.16.6 to 0.16.15 on none, so there the client has to work it out. */ function emailPushCapabilityIsExact(client: IJMAPClient): boolean { return client.hasAccountCapability(PRINCIPALS_OWNER_CAPABILITY, client.getAccountId()); } // Stalwart marks the accounts a user owns - their own and the groups they // belong to - through the create flags of its per-account collection // capabilities: true there, false on accounts shared with them by ACL. A user // without calendars, contacts or files has none of the flags, so their groups // don't look owned either; ownGroupAccounts finds those on 0.16. const OWNERSHIP_FLAGS: ReadonlyArray = [ ['urn:ietf:params:jmap:calendars', 'mayCreateCalendar'], ['urn:ietf:params:jmap:contacts', 'mayCreateAddressBook'], ['urn:ietf:params:jmap:filenode', 'mayCreateTopLevelFileNode'], ]; function looksOwned(info: AccountInfo | null): boolean { if (!info) return false; if (info.isPersonal) return true; if (info.isReadOnly) return false; return OWNERSHIP_FLAGS.some(([urn, flag]) => { const capability = info.accountCapabilities[urn] as Record | undefined; return capability?.[flag] === true; }); } /** * Which of `accountIds` are groups the user belongs to, as far as Stalwart * 0.16 shows it: it gives a group's members an identity for each of the * group's addresses and names the group's account after its address. An * account shared by ACL has neither. A group whose identity the member * deleted goes unrecognised. */ function ownGroupAccounts(client: IJMAPClient, accountIds: Iterable, identityEmails: Iterable): Set { const addresses = new Set(Array.from(identityEmails, (email) => email.trim().toLowerCase())); return new Set(Array.from(accountIds).filter((id) => { const info = client.getAccountInfo(id); return !!info && !info.isPersonal && addresses.has(info.name.trim().toLowerCase()); })); } /** * The emailPush maps to try, widest first. Stalwart refuses the whole map as * `forbidden` when it names an account the user neither owns nor belongs to * as a group member - a mailbox shared by ACL - and only fans a subscription * out to owned accounts anyway, so dropping the others loses nothing. * * On 1.0 the map already holds only the accounts the capabilities allow, so * the only fallback is the primary account, which is always accepted. 0.16 * lists both kinds of account as isPersonal:false, isReadOnly:false, so its * first fallback keeps the groups the user's identities reveal and the * accounts that look owned (see OWNERSHIP_FLAGS), and its last the primary * account alone. */ async function emailPushFallbacks( client: IJMAPClient, desired: Record, ): Promise[]> { const primary = client.getAccountId(); const keep = (predicate: (id: string) => boolean) => Object.fromEntries(Object.entries(desired).filter(([id]) => id === primary || predicate(id))); const steps = [keep(() => false)]; if (!emailPushCapabilityIsExact(client)) { // Without the identities no group is recognised, as before. const identities = await client.getIdentities().catch(() => []); const groups = ownGroupAccounts(client, Object.keys(desired), identities.map((identity) => identity.email)); steps.unshift(keep((id) => groups.has(id) || looksOwned(client.getAccountInfo(id)))); } const out = [desired]; for (const next of steps) { // Only steps that actually drop an account are worth another round-trip. if (Object.keys(next).length < Object.keys(out[out.length - 1]).length) out.push(next); } return out; } /** * Run a subscription write that carries `emailPush`, narrowing the map along * emailPushFallbacks while the server refuses it with a `forbidden` SetError. * Anything else - a method-level error, overQuota, a failed request - would * fail the same way with a narrower map, so it is thrown as is. Resolves to * the write's result and the map the server took. */ async function writeWithEmailPush( client: IJMAPClient, emailPush: Record | null, write: (emailPush: Record | null) => Promise, ): Promise<{ result: T; emailPush: Record | null }> { let attempts: (Record | null)[] = [emailPush]; for (let i = 0; ; i++) { try { return { result: await write(attempts[i]), emailPush: attempts[i] }; } catch (err) { // Only a refused map is worth retrying with a narrower one. if (!emailPush || !isJmapSetError(err) || jmapErrorType(err) !== 'forbidden') throw err; // The narrower maps are worked out only once the server refused one. if (i === 0) attempts = await emailPushFallbacks(client, emailPush); if (i === attempts.length - 1) throw err; } } } function readRefusedEmailPushAccounts(accountId: string): string[] { try { const parsed: unknown = JSON.parse(localStorage.getItem(emailPushRefusedKey(accountId)) ?? '[]'); return Array.isArray(parsed) ? parsed.filter((s): s is string => typeof s === 'string') : []; } catch { return []; } } /** Add the accounts the server dropped from `wanted` to reach `accepted`. */ function rememberRefusedEmailPushAccounts( accountId: string, refusedBefore: string[], wanted: Record | null, accepted: Record | null, ): void { if (!wanted || !accepted) return; const refusedNow = Object.keys(wanted).filter((id) => !(id in accepted)); if (refusedNow.length === 0) return; localStorage.setItem( emailPushRefusedKey(accountId), JSON.stringify(Array.from(new Set([...refusedBefore, ...refusedNow]))), ); } function withoutAccounts( config: Record, accountIds: string[], ): Record { return Object.fromEntries(Object.entries(config).filter(([id]) => !accountIds.includes(id))); } /** * Which of `accountIds` (accounts the user sees mailboxes in) background * notifications don't reach: mailboxes others share with the user by ACL. * Stalwart only fans a subscription out to the user's own account and their * groups, whatever the emailPush map says, on every version. * * 1.0 leaves emailpush out of exactly those accounts' capabilities, and that * settles it. 0.16 doesn't say, so there it is the accounts the server refused * in the map and the ones that don't look owned, minus the groups the user's * identities (`identityEmails`) reveal: a group member without calendars, * contacts or files has no create flag to go by. */ export function mailAccountsWithoutPush( client: IJMAPClient, accountIds: Iterable, identityEmails: Iterable = [], ): string[] { const primary = client.getAccountId(); const others = Array.from(new Set(accountIds)).filter((id) => id && id !== primary); if (others.length === 0) return []; if (emailPushCapabilityIsExact(client)) { return others.filter((id) => !client.hasAccountCapability(EMAIL_PUSH_CAPABILITY, id)); } const groups = ownGroupAccounts(client, others, identityEmails); const refused = readRefusedEmailPushAccounts(primary); return others.filter((id) => !groups.has(id) && (refused.includes(id) || !looksOwned(client.getAccountInfo(id)))); } 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; // Destroy the recorded server-side subscription and create a brand-new one // instead of refreshing the existing record's expiry. Stalwart binds the set // of accounts a subscription fans out to at creation time, so a subscription // that outlives a permission change keeps pushing for mailboxes the user can // no longer read - recreating is the only client-side remedy (#841). forceRecreate?: boolean; // Scope OS push notifications to Inbox-only mail. Mirrors settings-store's // pushNotifyInboxOnly. Required, not optional: it changes the server-side // delivery filter, so every caller must decide explicitly. inboxOnly: boolean; // Muted accounts and folders, settings-store's notificationMutes (#1169). mutes?: NotificationMutes; } 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; // No service worker is registered in the static Lite build (see // components/service-worker-registration.tsx), so push cannot be enabled. if (IS_LITE) 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(accountId: string): string { const key = deviceClientIdKey(accountId); const existing = localStorage.getItem(key); if (existing) return existing; const next = randomDeviceClientId(); localStorage.setItem(key, next); return next; } function anyOtherAccountHasSubscription(accountId: string): boolean { const skip = subscriptionIdKey(accountId); for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && k !== skip && k.startsWith(SUBSCRIPTION_ID_PREFIX)) return true; } return false; } // 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; } // A browser that doesn't expose the key it subscribed with counts as a // mismatch: we can't prove the subscription belongs to this relay. function sameKey(existing: ArrayBuffer | null | undefined, expected: Uint8Array): boolean { if (!existing) return false; const bytes = new Uint8Array(existing); if (bytes.length !== expected.length) return false; return bytes.every((byte, i) => byte === expected[i]); } 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(SW_SCOPE); if (!registration) { registration = await navigator.serviceWorker.register(SW_URL, { scope: SW_SCOPE }); } 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}`); } } /** The relay's view of a subscription - see relayStatusFor. */ export type PushRelayStatus = 'active' | 'inactive' | 'unknown'; /** * Ask the relay what it knows about a subscription. `inactive` means the relay * recognises the record and it is provably dead - it has never forwarded a push * and isn't freshly registered. `unknown` covers everything we cannot vouch for: * the relay doesn't recognise the id, an older relay without this endpoint, or a * network blip. Callers must treat `unknown` as "leave it alone", never as dead. */ async function relayStatusFor( relayBaseUrl: string, subscriptionId: string, ): Promise { if (!relayBaseUrl || !subscriptionId) return 'unknown'; try { const res = await fetch( buildRelayUrl(relayBaseUrl, `/api/push/active/${encodeURIComponent(subscriptionId)}`), ); if (!res.ok) return 'unknown'; const body = (await res.json()) as { active?: unknown }; if (body.active === true) return 'active'; if (body.active === false) return 'inactive'; return 'unknown'; } catch { return 'unknown'; } } /** * Returns true ONLY when the relay positively reports a subscription inactive, * so we never reap anything we can't confirm is dead. This lets enableWebPush * clear its own abandoned attempts - and dead siblings left by cleared site data * that regenerated the deviceClientId - without disturbing another live device * or the mobile app that shares the account. */ async function relayReportsDead( relayBaseUrl: string, subscriptionId: string, ): Promise { return (await relayStatusFor(relayBaseUrl, subscriptionId)) === 'inactive'; } async function pollVerificationCode( relayBaseUrl: string, subscriptionId: string, ): Promise { // Stalwart per-account rate-limits PushVerification posts (default 60s). // If there are leftover unverified subscriptions on the account, our new // one queues up behind them - so we wait long enough to clear one verify // window even in the unlucky case. const timeoutAt = Date.now() + 75_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'); } // Push the subscription's expiry forward and re-sync `types` / the delivery // filter when they drift. Resolves to false when the server refused the // update with a SetError (the subscription is gone, or no narrower map is // accepted), which the caller answers with a replacement; otherwise to the // emailPush map the update installed (null when it left the filter alone). // Every other failure - the network, a timeout, rate limiting, a method-level // error - says nothing about the subscription and is thrown. async function refreshSubscriptionExpires( client: IJMAPClient, sub: { id: string; expires: string | null; types: string[] | null; emailPush?: Record | null; }, // null when the server has no emailPush support - leave the property alone. desiredEmailPush: Record | null, ): Promise | null }> { const typesNeedUpdate = !sameTypes(sub.types, PUSH_TYPES); // Also re-sync the delivery filter: a subscription created before this // client learned about emailPush has none, and the Junk mailbox id can // change under us. const emailPushNeedsUpdate = desiredEmailPush !== null && !sameEmailPush(sub.emailPush, desiredEmailPush); if (!typesNeedUpdate && !emailPushNeedsUpdate && 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 { emailPush: null }; } const patch: { expires?: string; types?: string[] } = { expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS), }; if (typesNeedUpdate) patch.types = [...PUSH_TYPES]; try { const { emailPush } = await writeWithEmailPush( client, emailPushNeedsUpdate ? desiredEmailPush : null, (filter) => client.updatePushSubscription(sub.id, filter ? { ...patch, emailPush: filter } : patch), ); return { emailPush }; } catch (err) { if (isJmapSetError(err)) return false; throw err; } } 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. // // Only reuse it if it was made with THIS relay's key. A subscription is // bound to the VAPID key it was created with, so one left over from another // relay (or from before this relay rotated its keys) is rejected by the push // service on every send - Mozilla answers 401 "VAPID public key mismatch" - // while isWebPushEnabled keeps reporting push as on. const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey); // A stale or broken local record makes Firefox reject the lookup itself with // `AbortError: Error retrieving push subscription`, which would abort the // whole enable. There is nothing to reuse in that case, so carry on to // subscribe() and let its own error surface if the push service is really // unreachable. let pushSubscription = await registration.pushManager.getSubscription().catch(() => null); if ( pushSubscription && !sameKey(pushSubscription.options?.applicationServerKey, applicationServerKey) ) { // An unsubscribe that fails leaves the old record in place; subscribing // with the right key is what matters, so don't abort the enable for it. await pushSubscription.unsubscribe().catch(() => undefined); pushSubscription = null; } if (!pushSubscription) { pushSubscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey, }); } const accountId = params.client.getAccountId(); const deviceClientId = getOrCreateDeviceClientId(accountId); 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. With // forceRecreate we replace it instead: the account set a Stalwart // subscription fans out to is fixed at creation time, so refreshing // `expires` carries stale permissions forward and only a new record picks up // revoked shared-mailbox access (#841). Either way the old record keeps // delivering until its replacement is verified, so a replacement the server // refuses never leaves the browser without push. // // A failed listing ends the run here: taken for "the server has no // subscription", it would drop the key of a working registration and push // would read as off from then on. const existingSubs = await params.client.listPushSubscriptions(); // A forced re-registration re-learns which accounts the server accepts in // the emailPush map. if (params.forceRecreate) localStorage.removeItem(emailPushRefusedKey(accountId)); // On 1.0 the capabilities the map is built from already leave out every // account the server refuses; a list remembered under 0.16 could only keep // a group out. const refusedBefore = emailPushCapabilityIsExact(params.client) ? [] : readRefusedEmailPushAccounts(accountId); const emailPush = serverSupportsEmailPush(params.client) ? withoutAccounts(await buildEmailPushConfig(params.client, params.inboxOnly, params.mutes), refusedBefore) : null; const subIdKey = subscriptionIdKey(accountId); const storedServerId = localStorage.getItem(subIdKey); let replacedServerId: string | null = null; if (storedServerId) { const match = existingSubs.find((s) => s.id === storedServerId); if (match) { if (!params.forceRecreate) { const refreshed = await refreshSubscriptionExpires(params.client, match, emailPush); if (refreshed) { rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, refreshed.emailPush); return { subscriptionId: storedServerId }; } } // The server refused the refresh, or the caller asked for a fresh // record: create a replacement below and destroy this one once the // replacement is verified. replacedServerId = storedServerId; } // When the server no longer has it (expired, or revoked elsewhere), the // stored id stays until the replacement is verified below: it is // what tells resyncWebPush this account opted in, so dropping it here // would end recovery for good after one interrupted attempt. } // Reap leftover subscriptions that would otherwise starve the new one's // verification. Stalwart emits only one PushVerification per account per // ~60s; up to 0.16.13 it picks the first unverified subscription it stores, // usually the oldest, so a single stale straggler blocks every fresh attempt // - the symptom is the confusing "Timed out waiting for PushVerification" // error (0.16.14 and later pick the newest). We can't read a subscription's // verified state or URL over JMAP (Stalwart hides both), only its // deviceClientId, so we decide what's safe to remove like this: // - same deviceClientId as ours: a previous attempt from THIS browser, // always safe to reap. // - a different deviceClientId: could be another live device or the mobile // app on this account. Ask the relay whether it's still alive and only // reap the ones it confirms are dead. Anything live - or anything the // relay can't vouch for (a different relay, a non-Bulwark client, a // network blip) - is left untouched. for (const s of existingSubs) { if (s.id === storedServerId) continue; if (s.deviceClientId === deviceClientId) { await params.client.destroyPushSubscription(s.id).catch(() => undefined); continue; } if (await relayReportsDead(relayBaseUrl, s.deviceClientId)) { await params.client.destroyPushSubscription(s.id).catch(() => undefined); } } const create = () => writeWithEmailPush(params.client, emailPush, (filter) => params.client.createPushSubscription({ deviceClientId, url: buildRelayUrl(relayBaseUrl, `/api/push/jmap/${encodeURIComponent(deviceClientId)}`), types: [...PUSH_TYPES], expires: expiresFromNow(SUBSCRIPTION_EXPIRES_DAYS), ...(filter ? { emailPush: filter } : {}), }), ); let created: Awaited>; try { created = await create(); } catch (err) { // Stalwart caps the subscriptions of an account (15 by default) and counts // the one being replaced, so at the cap Re-register could never create // its replacement. The user asked for a new record: free the old one's // place and try once more. Should that fail too, push is off and the // error says why. if (!params.forceRecreate || !replacedServerId || jmapErrorType(err) !== 'overQuota') throw err; await params.client.destroyPushSubscription(replacedServerId); localStorage.removeItem(subIdKey); replacedServerId = null; created = await create(); } const serverAssignedId = created.result; rememberRefusedEmailPushAccounts(accountId, refusedBefore, emailPush, created.emailPush); const verificationCode = await pollVerificationCode(relayBaseUrl, deviceClientId); await params.client.verifyPushSubscription(serverAssignedId, verificationCode); localStorage.setItem(subIdKey, serverAssignedId); if (replacedServerId) { await params.client.destroyPushSubscription(replacedServerId).catch(() => undefined); } return { subscriptionId: serverAssignedId }; } export interface DisableWebPushParams { client: IJMAPClient; relayBaseUrl?: string; } // Best-effort teardown: clear the JMAP subscription, the relay mapping, and // (only when no other accounts still need it) the browser-wide // 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 accountId = params.client.getAccountId(); const subIdKey = subscriptionIdKey(accountId); const devIdKey = deviceClientIdKey(accountId); const storedServerId = localStorage.getItem(subIdKey); const deviceClientId = localStorage.getItem(devIdKey); // Destroy every subscription the server holds for this device, not just the // id we happen to have recorded. A destroy that lost its round-trip, a failed // enable, or site data cleared between sessions can leave a registration this // client no longer tracks - and Stalwart keeps fanning StateChanges out to it // until it expires, so "disable" has to mean gone (#841). const idsToDestroy = new Set(); if (storedServerId) idsToDestroy.add(storedServerId); if (deviceClientId) { const existingSubs = await params.client.listPushSubscriptions().catch(() => []); for (const s of existingSubs) { if (s.deviceClientId === deviceClientId) idsToDestroy.add(s.id); } } for (const id of idsToDestroy) { await params.client.destroyPushSubscription(id).catch(() => undefined); } localStorage.removeItem(subIdKey); // Enabling again starts from the full emailPush map. localStorage.removeItem(emailPushRefusedKey(accountId)); if (deviceClientId && relayBaseUrl) { await fetch( buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`), { method: 'DELETE' }, ).catch(() => undefined); } // Keep the deviceClientId around so a later re-enable for this account // reuses the same relay subscriptionId rather than scattering orphans. // The browser-wide PushSubscription is shared by every account on this // origin, so only tear it down if no other account is still using it. if ( !anyOtherAccountHasSubscription(accountId) && typeof navigator !== 'undefined' && 'serviceWorker' in navigator ) { const registration = await navigator.serviceWorker.getRegistration(SW_SCOPE); const sub = await registration?.pushManager.getSubscription(); if (sub) await sub.unsubscribe().catch(() => undefined); } } export interface PushDevice { // The JMAP PushSubscription id - what you destroy to revoke it. id: string; // Client-chosen id the relay keys its endpoint mapping on. deviceClientId: string; expires: string | null; types: string[] | null; // True when this registration belongs to the browser you're looking at. isThisDevice: boolean; relayStatus: PushRelayStatus; } /** * Every push registration the JMAP server holds for this account, annotated * with whether it is this browser and what the relay makes of it. * * Stalwart hides a subscription's url and verified state from clients, so * deviceClientId is the only handle we get. That's enough to spot our own * registration and to ask the relay about the rest - but registrations made * against a different relay, or by a non-Bulwark client, come back `unknown` * rather than dead, and the UI must present them as revocable-but-unclassified. */ export async function listPushDevices(params: { client: IJMAPClient; relayBaseUrl?: string; }): Promise { const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, ''); const accountId = params.client.getAccountId(); const thisDeviceClientId = typeof localStorage === 'undefined' ? null : localStorage.getItem(deviceClientIdKey(accountId)); const subs = await params.client.listPushSubscriptions(); return Promise.all( subs.map(async (s) => ({ id: s.id, deviceClientId: s.deviceClientId, expires: s.expires ?? null, types: s.types ?? null, isThisDevice: thisDeviceClientId !== null && s.deviceClientId === thisDeviceClientId, relayStatus: await relayStatusFor(relayBaseUrl, s.deviceClientId), })), ); } /** * Revoke one registration. Destroying the JMAP subscription stops the server * fanning StateChanges to it; dropping the relay mapping stops the relay * forwarding anything already in flight and frees the deviceClientId. Revoking * this device runs the full local teardown so the UI doesn't keep claiming push * is on. */ export async function revokePushDevice(params: { client: IJMAPClient; device: Pick; relayBaseUrl?: string; }): Promise { const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, ''); if (params.device.isThisDevice) { await disableWebPush({ client: params.client, relayBaseUrl }); return; } await params.client.destroyPushSubscription(params.device.id); if (relayBaseUrl && params.device.deviceClientId) { await fetch( buildRelayUrl( relayBaseUrl, `/api/push/register/${encodeURIComponent(params.device.deviceClientId)}`, ), { method: 'DELETE' }, ).catch(() => undefined); } } export async function isWebPushEnabled(accountId: string): Promise { if (!isWebPushSupported()) return false; if (Notification.permission !== 'granted') return false; const registration = await navigator.serviceWorker.getRegistration(SW_SCOPE); if (!registration) return false; const sub = await registration.pushManager.getSubscription(); return sub !== null && localStorage.getItem(subscriptionIdKey(accountId)) !== null; } // When each account was last re-synced during this page load, and whether // that attempt worked. Once a day is enough for the drift between sessions // (a client update that changed what we subscribe to, a recreated Junk // mailbox), but not only once: Stalwart clamps `expires` to 7 days, so a tab // or installed app left open for a week would otherwise let the subscription // lapse and push stop silently. const RESYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // After a failed attempt: soon again, but not on every return to the tab. const RESYNC_RETRY_MS = 15 * 60 * 1000; interface ResyncAttempt { at: number; ok: boolean; } const lastResync = new Map(); // Re-syncs still running, per account. One at a time per login: they share // the browser's single PushSubscription, and a parallel re-create would race // on it. Also what lets the onboarding prompt wait instead of asking the // user to enable push the re-sync is just restoring (#1321). const inFlightResync = new Map>(); export interface ResyncWebPushParams { client: IJMAPClient; relayBaseUrl?: string; accountLabel?: string; // Mirrors settings-store's pushNotifyInboxOnly - see EnableWebPushParams. inboxOnly: boolean; mutes?: NotificationMutes; // Skip the once-a-day limit: the filter itself changed (a folder was // muted) and has to reach the server now. force?: boolean; } /** * Bring an already-enabled push registration up to date without any user * action: refresh its expiry and install/repair the delivery filter. Nothing * here can prompt - it requires a saved opt-in and granted permission - * and every failure is swallowed because the app must not care whether the * background touch-up worked. A failed re-sync leaves the registration and * its key as they were. Returns true when a re-sync actually ran. */ export function resyncWebPush(params: ResyncWebPushParams): Promise { let accountId: string; try { accountId = params.client.getAccountId(); } catch { return Promise.resolve(false); } if (!accountId) return Promise.resolve(false); const pending = inFlightResync.get(accountId); if (pending) { if (!params.force) return pending; // The filter changed while a re-sync runs: send it once that one is done. return pending.then(() => resyncWebPush(params)); } // Registered before the first await, so a waiter that asks in the same // tick already sees this run. const run: Promise = runResync(accountId, params).finally(() => { if (inFlightResync.get(accountId) === run) inFlightResync.delete(accountId); }); inFlightResync.set(accountId, run); return run; } async function runResync(accountId: string, params: ResyncWebPushParams): Promise { try { // The browser may have lost its endpoint while our saved opt-in and // server registration survived. Recreate it through the normal enable // flow instead of skipping the account in that state, which is what // isWebPushEnabled (it requires a live browser subscription) would do. if (!isWebPushSupported() || Notification.permission !== 'granted' || !localStorage.getItem(subscriptionIdKey(accountId))) return false; if (!params.force && !(await resyncDue(accountId))) return false; await enableWebPush({ client: params.client, relayBaseUrl: params.relayBaseUrl, accountLabel: params.accountLabel, inboxOnly: params.inboxOnly, mutes: params.mutes, }); lastResync.set(accountId, { at: Date.now(), ok: true }); return true; } catch { // A failed attempt must not wait a day for the next one, nor rerun the // whole enable on every tab focus. lastResync.set(accountId, { at: Date.now(), ok: false }); return false; } } async function resyncDue(accountId: string): Promise { const last = lastResync.get(accountId); if (!last) return true; const elapsed = Date.now() - last.at; if (!last.ok) return elapsed >= RESYNC_RETRY_MS; if (elapsed >= RESYNC_INTERVAL_MS) return true; // Inside the daily limit, but the browser may have dropped the subscription // since (Safari does when site data is cleared, Firefox when the push // service expires it). A tab that stays open would otherwise go without // push until it is reloaded. The check is local, so it can run on every // return to the tab. return browserSubscriptionLost(); } // Whether the browser no longer holds a push subscription for our service // worker. A missing registration counts as lost too: enableWebPush puts it // back. async function browserSubscriptionLost(): Promise { const registration = await navigator.serviceWorker.getRegistration(SW_SCOPE).catch(() => null); if (!registration) return true; const sub = await registration.pushManager.getSubscription().catch(() => null); return sub === null; } /** * Settles once no re-sync is running for the account; at once when none is. * isWebPushEnabled says "off" while a re-sync recreates the browser * subscription, so a caller that would act on "off" - the onboarding prompt - * waits for this first (#1321). */ export function waitForWebPushResync(accountId: string): Promise { const pending = inFlightResync.get(accountId); if (!pending) return Promise.resolve(); // A forced update may have queued up behind this run. return pending.then(() => waitForWebPushResync(accountId)); } // Test hook: forget which accounts were re-synced during this page load. export function resetWebPushResyncState(): void { lastResync.clear(); inFlightResync.clear(); } export interface BulkWebPushTarget { accountId: string; client: IJMAPClient; accountLabel?: string; } export interface BulkWebPushResult { enabled: string[]; /** Accounts left untouched, with the reason the first failure gave. */ failed: Array<{ accountId: string; error: unknown }>; } /** * Enable push for several accounts against one browser subscription. * * The browser only ever holds a single PushSubscription: `enableWebPush` reuses * it and registers one server-side subscription per account, which is why every * account has to be walked separately. Sequential on purpose - the first call * is the one that may prompt for permission, and parallel prompts race. * * A browser-level refusal (unsupported, permission denied or dismissed) applies * to every account, so it stops the walk instead of failing them one by one. */ export async function enableWebPushForAccounts( targets: BulkWebPushTarget[], // inboxOnly mirrors settings-store's pushNotifyInboxOnly, as for a single account. options: { relayBaseUrl?: string; forceRecreate?: boolean; inboxOnly: boolean; mutes?: NotificationMutes }, ): Promise { const result: BulkWebPushResult = { enabled: [], failed: [] }; let browserRefusal: unknown; for (const target of targets) { if (browserRefusal !== undefined) { result.failed.push({ accountId: target.accountId, error: browserRefusal }); continue; } try { await enableWebPush({ client: target.client, relayBaseUrl: options.relayBaseUrl, accountLabel: target.accountLabel, forceRecreate: options.forceRecreate, inboxOnly: options.inboxOnly, mutes: options.mutes, }); result.enabled.push(target.accountId); } catch (error) { if (error instanceof WebPushUnsupportedError || !isWebPushSupported() || Notification.permission !== 'granted') browserRefusal = error; result.failed.push({ accountId: target.accountId, error }); } } return result; }