/* eslint-disable no-undef */ // 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. // When the app is mounted at a subpath (Next.js basePath, e.g. /webmail), the // SW is served at /webmail/sw.js and registered with scope /webmail/. Derive // the prefix from the SW's own URL so push fetches and notification clicks // land on the right path - service workers can't read process.env. function getBasePath() { const path = new URL(self.location.href).pathname; // self.location is .../sw.js; strip the trailing filename to get the dir, // then strip the trailing slash so it concatenates cleanly with `/foo`. const dir = path.replace(/[^/]*$/, ""); return dir.replace(/\/+$/, ""); } const BASE_PATH = getBasePath(); const MAILTO_CLIENTS = new Map(); self.addEventListener("install", () => { self.skipWaiting(); }); self.addEventListener("activate", (event) => { event.waitUntil(self.clients.claim()); }); self.addEventListener("fetch", () => {}); self.addEventListener("push", (event) => { event.waitUntil(handlePush(event)); }); self.addEventListener("notificationclick", (event) => { event.notification.close(); event.waitUntil(handleNotificationClick(event)); }); self.addEventListener("pushsubscriptionchange", (event) => { event.waitUntil(handlePushSubscriptionChange(event)); }); self.addEventListener("message", (event) => { const data = event.data || {}; if (data.type === "mailto-client-ready") { if (event.source && event.source.id) { MAILTO_CLIENTS.set(event.source.id, { path: typeof data.path === "string" ? data.path : "", standalone: data.standalone === true, clientId: typeof data.clientId === "string" ? data.clientId : "", focusNotificationTitle: typeof data.focusNotificationTitle === "string" ? data.focusNotificationTitle : "", focusNotificationBody: typeof data.focusNotificationBody === "string" ? data.focusNotificationBody : "", }); } return; } if (data.type === "mailto-client-gone") { if (event.source && event.source.id) { const current = MAILTO_CLIENTS.get(event.source.id); if (!current || (typeof data.clientId === "string" && current.clientId === data.clientId) || (typeof data.clientId !== "string" && typeof data.path === "string" && current.path === data.path)) { MAILTO_CLIENTS.delete(event.source.id); } } return; } if (data.type === "open-mailto-in-client") { event.waitUntil(handleOpenMailtoInClient(event)); return; } if (data.type === "focus-existing-mailto-client") { event.waitUntil(focusExistingWindowClient(event.source && event.source.id, true)); return; } if (data.type !== "focus-existing-client") return; event.waitUntil(focusExistingWindowClient(event.source && event.source.id)); }); 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 : ""; // Two payload shapes reach us from the relay: // - "jmap-email-push": the server evaluated our per-account delivery // filter (draft-ietf-jmap-emailpush) and this is a list of the new // message ids that passed it - spam filed into Junk never gets here. // - "jmap-state-change" (older servers): a bare EmailDelivery ping wrapped // as { changed: { [accountId]: {...} } }, fired for every ingested // message including junk. The relay forwards a single account per push, // so the first key is the one this notification is for. const changed = payload && payload.changed && typeof payload.changed === "object" ? payload.changed : null; const accountId = (payload && typeof payload.accountId === "string" && payload.accountId) || (changed ? Object.keys(changed)[0] || "" : ""); const emailIds = payload && Array.isArray(payload.emailIds) ? payload.emailIds.filter((id) => typeof id === "string" && id) : []; // Never notify twice for the same message. A push can be redelivered (relay // retry, browser replay after coming back online) and, on the older // state-change path, a junk delivery wakes us while an unread message we // already announced is still sitting in the Inbox - without this we would // re-buzz for that old message. const notified = await readNotifiedIds(accountId); const freshIds = emailIds.filter((id) => !notified.includes(id)); const groupTag = "bulwark-mail:" + (accountId || "default"); if (emailIds.length > 0 && freshIds.length === 0) { await showWithoutAlert(groupTag); return; } // Best effort: ask the webmail to look up the email so we can build a useful // notification. With ids from the server we ask for exactly the newest of // them; otherwise the API falls back to the newest unread Inbox message. 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; let previewOk = false; try { const query = new URLSearchParams(); if (accountId) query.set("accountId", accountId); if (freshIds.length > 0) query.set("emailId", freshIds[freshIds.length - 1]); const qs = query.toString(); const previewUrl = `${BASE_PATH}/api/push/preview${qs ? `?${qs}` : ""}`; // A notification shown more than 30 s after the push counts as a silent // push in Safari (#1322), so a slow preview gives way to the generic one. const controller = typeof AbortController === "function" ? new AbortController() : null; const timer = controller ? setTimeout(() => controller.abort(), PREVIEW_DEADLINE_MS) : null; try { const res = await fetch(previewUrl, { credentials: "include", cache: "no-store", ...(controller ? { signal: controller.signal } : {}), }); if (res.ok) { preview = await res.json(); previewOk = true; } } finally { if (timer) clearTimeout(timer); } } catch (_) { preview = null; previewOk = false; } const email = preview && preview.email ? preview.email : null; const unreadTotal = preview && typeof preview.unreadTotal === "number" ? preview.unreadTotal : 0; // 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; // Push subscription is scoped to EmailDelivery, but stragglers from the // older broader-types subscription, marking-as-read races and verification // pings can still wake us with no actual unread mail. When the preview API // succeeded and reports zero unread, stay silent. When the preview API // failed (network/auth/server down) we cannot tell, so fall through to the // generic "New mail" toast rather than miss a real delivery. if (previewOk && !email && unreadTotal === 0) { await showWithoutAlert(groupTag); return; } // State-change path only (no ids from the server): the newest unread Inbox // message is one we already announced, so whatever woke us was not new // Inbox mail - typically a delivery into Junk or a Sieve-filed folder. if (emailIds.length === 0 && previewOk && email && notified.includes(email.id)) { await showWithoutAlert(groupTag); return; } // Group per account under one shared tag so a burst of new mail collapses // into a single, self-updating notification instead of one toast per message // (Android renders one notification per unique tag, which is why 50 arrivals // used to stack 50 toasts). The newest message is the headline and a // Gmail-style "+N more" line carries the rest, counted from the account's // unread total. let title; let body; let data = { kind: "mail-list", accountId, slot }; 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)"; const more = unreadTotal > 1 ? unreadTotal - 1 : 0; if (more > 0) { // Several unread: this is a group. Keep the newest as the headline, add // the "+N more" count, and open the inbox (not one message) on click. 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, slot }; } } else { title = accountLabel ? `New mail (${accountLabel})` : "New mail"; body = unreadTotal > 1 ? `${unreadTotal} unread messages` : "You have new mail"; } // With the sound setting off, nothing rings and no quiet window starts. const audible = (await soundPreference()) && !(await withinQuietWindow(accountId)); await self.registration.showNotification(title, { body, // Shared per-account tag: each new push replaces the account's single // notification rather than adding another. tag: groupTag, // Branded app icon via the PWA-icon endpoint (admin-configured, else the // built-in default). The static /icon-192x192.png ignored admin branding. icon: `${BASE_PATH}/api/pwa-icon/192`, badge: `${BASE_PATH}/api/pwa-icon/192`, data, // Inside the quiet window, or with the sound setting off, the // notification still appears or updates, without a sound. renotify: audible, silent: !audible, }); const announced = freshIds.length > 0 ? freshIds : (email ? [email.id] : []); if (announced.length > 0) { await writeNotifiedIds(accountId, notified.concat(announced)); } } // How long the preview may take before the generic notification is shown. const PREVIEW_DEADLINE_MS = 10_000; /** * Every push has to end in showNotification. Safari counts a push without one * as silent and drops the subscription after three (WebKit maxSilentPushCount); * Firefox and Chrome spend a quota or show a notice of their own. A push with * nothing new to say (a redelivery, mail already read or already announced) * re-shows the account's notification as it is, without a sound. With none on * screen, it shows one and takes it away again at once (#1322). */ async function showWithoutAlert(groupTag) { const options = { tag: groupTag, icon: `${BASE_PATH}/api/pwa-icon/192`, badge: `${BASE_PATH}/api/pwa-icon/192`, renotify: false, silent: true, }; try { const existing = (await self.registration.getNotifications({ tag: groupTag }))[0]; if (existing) { await self.registration.showNotification(existing.title, { ...options, body: existing.body, data: existing.data, }); return; } await self.registration.showNotification("New mail", { ...options, data: { kind: "mail-list" } }); const shown = await self.registration.getNotifications({ tag: groupTag }); for (const notification of shown) notification.close(); } catch (_) { // Nothing more a worker can do. } } // One message often lands in several of the user's accounts at once - a list, // a forward, the same newsletter on two logins - and each account has its own // notification. Only the first alert of a burst makes a sound; other accounts' // alerts within the window show up silently. A second alert for the same // account still rings, as before. The window runs from the last audible alert // and is not extended by the silent ones, so a steady trickle still rings // every so often. const QUIET_WINDOW_MS = 30_000; function lastAlertKey() { return `${self.location.origin}${BASE_PATH}/__push-state/last-alert`; } function pushPreferencesKey() { return `${self.location.origin}${BASE_PATH}/__push-state/prefs`; } /** * The webmail's "Notification sound" setting, mirrored into this store by * the page (lib/push-preferences.ts, #1289). Nothing stored: a sound, as * before. */ async function soundPreference() { try { const cache = await caches.open(PUSH_STATE_CACHE); const res = await cache.match(pushPreferencesKey()); const prefs = res ? await res.json() : null; return !prefs || prefs.sound !== false; } catch (_) { return true; } } /** True when another account alerted audibly less than QUIET_WINDOW_MS ago; otherwise records this one. */ async function withinQuietWindow(accountId) { const now = Date.now(); const account = accountId || "default"; try { const cache = await caches.open(PUSH_STATE_CACHE); const res = await cache.match(lastAlertKey()); const lastAlert = res ? await res.json() : null; const last = lastAlert ? Number(lastAlert.at) : 0; if (last && lastAlert.accountId !== account && now - last >= 0 && now - last < QUIET_WINDOW_MS) return true; await cache.put( lastAlertKey(), new Response(JSON.stringify({ at: now, accountId: account }), { headers: { "Content-Type": "application/json" }, }), ); } catch (_) { // Without the store every notification alerts, as before. } return false; } // Per-account list of message ids we have already shown a notification for. // Service workers have no localStorage; the Cache API is the cheapest durable // store that survives the worker being killed between pushes. Failures are // swallowed - dedupe is a nicety, delivering the notification is not. const PUSH_STATE_CACHE = "bulwark-push-state-v1"; const NOTIFIED_IDS_LIMIT = 200; function notifiedIdsKey(accountId) { return `${self.location.origin}${BASE_PATH}/__push-state/notified/${encodeURIComponent(accountId || "default")}`; } async function readNotifiedIds(accountId) { try { const cache = await caches.open(PUSH_STATE_CACHE); const res = await cache.match(notifiedIdsKey(accountId)); if (!res) return []; const data = await res.json(); return Array.isArray(data.ids) ? data.ids.filter((id) => typeof id === "string") : []; } catch (_) { return []; } } async function writeNotifiedIds(accountId, ids) { try { const cache = await caches.open(PUSH_STATE_CACHE); const unique = Array.from(new Set(ids)).slice(-NOTIFIED_IDS_LIMIT); await cache.put( notifiedIdsKey(accountId), new Response(JSON.stringify({ ids: unique }), { headers: { "Content-Type": "application/json" }, }), ); } catch (_) { // Best effort only. } } // The push service dropped or replaced the browser's subscription (expiry, // a key rotation, revoked permission). Re-subscribe with the same relay key // unless the browser already did, then tell the open tabs: only a page has // the login needed to register the new endpoint with the relay and the mail // server (resyncWebPush in lib/web-push.ts). With no tab open, the next page // load's re-sync picks the new subscription up. async function handlePushSubscriptionChange(event) { const old = event.oldSubscription; if (!event.newSubscription && old && old.options) { try { await self.registration.pushManager.subscribe(old.options); } catch (_) { // Permission revoked or push service unreachable: the page's re-sync // subscribes again itself when it can. } } const allClients = await self.clients.matchAll({ type: "window", includeUncontrolled: true, }); for (const client of allClients) { try { client.postMessage({ kind: "pushsubscriptionchange" }); } catch (_) { // Closed or detached client - ignore. } } } async function handleNotificationClick(event) { const data = event.notification.data || {}; const tag = event.notification.tag || ""; if (data.kind === "protocol-mailto-focus") { return handleMailtoFocusNotificationClick(); } const targetUrl = buildClickUrl(data); const allClients = await self.clients.matchAll({ type: "window", includeUncontrolled: true, }); // Notify any in-app clients so plugins listening on toastHooks.onNotificationClick fire. for (const client of allClients) { try { client.postMessage({ kind: "notificationclick", tag, data }); } catch (_) { // Closed or detached client - ignore. } } 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 { // FOCUS FIRST, NAVIGATE SECOND, and the order is the entire fix. // // A notification click grants the service worker a TRANSIENT USER ACTIVATION, and // both focus() and openWindow() refuse to run without one. navigate() SPENDS that // activation, and it also replaces the client's document, which invalidates the // WindowClient handle we are about to use. Navigating first therefore breaks the // click two ways over. Instrumented on Android in a real deployment: // // client 1: navigate ok -> focus() threw NotFoundError (handle stale after nav) // client 2: navigate ok -> focus() threw InvalidAccessError (activation spent) // openWindow() -> threw InvalidAccessError (same) // // Every rejection was swallowed by the catch below, so the toast closed and nothing // came to the front. Focusing first uses the activation while it is still live; // navigate() does not need one of its own once the client is focused. const focused = await client.focus(); if (focused && "navigate" in focused && targetUrl) { try { await focused.navigate(targetUrl); } catch (_) { // Focused but not navigated still beats nothing opening at all. } } return focused; } 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 || `${BASE_PATH}/`); } } async function focusExistingWindowClient(sourceClientId, requireMailtoReady) { const entry = await findReusableWindowClientEntry(sourceClientId, requireMailtoReady); const client = entry && entry.client; if (client && "focus" in client) { return client.focus(); } } async function handleMailtoFocusNotificationClick() { const entry = await findReusableWindowClientEntry(null, true); const client = entry && entry.client; if (client && "focus" in client) { try { return await client.focus(); } catch (_) { // Fall through to opening a new app window if activation is still blocked. } } if (self.clients.openWindow) { return self.clients.openWindow(`${BASE_PATH}/`); } } async function handleOpenMailtoInClient(event) { const data = event.data || {}; const responsePort = event.ports && event.ports[0]; const entry = await findReusableWindowClientEntry(event.source && event.source.id, true); const client = entry && entry.client; const state = entry && entry.state; if (!client || !state || !state.clientId) { responsePort && responsePort.postMessage({ delivered: false }); return; } try { client.postMessage({ type: "mailto-request", id: data.id, clientId: state.clientId, value: data.value }); } catch (_) { responsePort && responsePort.postMessage({ delivered: false }); return; } if ("focus" in client) { try { await client.focus(); } catch (_) { // Delivery succeeded; focusing can still be blocked by browser policy. await showMailtoFocusNotification(state); } } responsePort && responsePort.postMessage({ delivered: true }); } async function showMailtoFocusNotification(state) { try { await self.registration.showNotification(state.focusNotificationTitle || "Bulwark", { body: state.focusNotificationBody || "The request was opened in Bulwark. Click to bring it to the front.", tag: "bulwark-mailto-focus", icon: `${BASE_PATH}/api/pwa-icon/192`, badge: `${BASE_PATH}/api/pwa-icon/192`, data: { kind: "protocol-mailto-focus" }, renotify: true, }); } catch (_) { // Notification permission may be missing; the mailto request was still delivered. } } async function findReusableWindowClientEntry(sourceClientId, requireMailtoReady) { const scopedPath = BASE_PATH ? `${BASE_PATH}/` : "/"; const allClients = await self.clients.matchAll({ type: "window", includeUncontrolled: true, }); const candidates = []; for (const client of allClients) { if (client.id === sourceClientId) continue; const state = MAILTO_CLIENTS.get(client.id); if (requireMailtoReady && !state) continue; try { const url = new URL(client.url); if (url.origin !== self.location.origin) continue; if (!url.pathname.startsWith(scopedPath)) continue; if (url.pathname.includes("/protocol/")) continue; candidates.push({ client, state, score: getReusableClientScore(state) }); } catch (_) { // Detached clients can disappear while iterating. } } candidates.sort((a, b) => a.score - b.score); return candidates[0]; } /** * Whether a client's path is the mail section. Since #733 the mail client * keeps a permalink in the address bar, so an open inbox reads as * `/mail/folder/inbox` (optionally behind a mount prefix and a locale * segment) rather than a bare "/". * * Deliberately duplicated from lib/deep-links.ts: this file is served raw, not * bundled, so it cannot import from the app. The locale segment is matched by * shape (two lowercase letters) because the worker has no locale list - and no * app route is a bare two-letter segment. */ function isMailSectionPath(path) { if (!path) return true; let rest = path; if (BASE_PATH && (rest === BASE_PATH || rest.startsWith(`${BASE_PATH}/`))) { rest = rest.slice(BASE_PATH.length); } const segments = rest.split("/").filter(Boolean); if (segments.length > 0 && /^[a-z]{2}$/.test(segments[0])) segments.shift(); return segments.length === 0 || segments[0] === "mail"; } function getReusableClientScore(state) { if (!state) return 4; const isMailSection = isMailSectionPath(state.path); if (state.standalone && isMailSection) return 0; if (isMailSection) return 1; if (state.standalone) return 2; return 3; } function buildClickUrl(data) { if (!data) return `${BASE_PATH}/`; // `?slot=` names the login the message belongs to (see the preview API). const slotQuery = Number.isInteger(data.slot) ? `?slot=${data.slot}` : ""; if (data.kind === "email" && data.emailId) { // Permalink (#733). Under NEXT_PUBLIC_LOCALE_PREFIX=always the proxy // redirects this to the localised path; the worker has no locale to add. return `${BASE_PATH}/mail/message/${encodeURIComponent(data.emailId)}${slotQuery}`; } // A group of new mail in a known login: that login's Inbox. if (data.kind === "mail-list" && slotQuery) { return `${BASE_PATH}/mail/folder/inbox${slotQuery}`; } // Generic "New mail" toast (preview API failed or returned no email): land // the user on the latest unread message in their Inbox rather than just the // app shell, so the click still feels purposeful. return `${BASE_PATH}/?openLatestUnread=1`; }