Files
webmail/public/sw.js
T

941 lines
36 KiB
JavaScript

/* eslint-disable no-undef */
// Bulwark service worker.
//
// This SW does two jobs:
// 1. Satisfy the PWA installability requirement (a fetch handler that
// leaves every request to the network).
// 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.
//
// Builds (#523): the page registers sw.js?v=<build id>, so every deploy
// installs a new worker that knows its build. Anything it caches goes into
// caches named after that build (bulwark-shell-<build id>), never into one
// cache that outlives a deploy, so a tab is never handed chunks of another
// build. A build's cache is deleted once no open tab runs that build.
// 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();
// The build this worker came with (the ?v= the page registered it with).
const BUILD_ID = new URL(self.location.href).searchParams.get("v") || "unversioned";
const SHELL_CACHE_PREFIX = "bulwark-shell-";
const SHELL_CACHE = SHELL_CACHE_PREFIX + BUILD_ID;
// What the worker has to remember across restarts and across builds: which
// build each open tab runs. In the Cache API like the push state, under its
// own name.
const SW_STATE_CACHE = "bulwark-sw-state-v1";
const CLIENT_BUILDS_KEY = "/__bulwark/client-builds";
const OFFLINE_SHELL_KEY = "/__bulwark/offline-shell";
self.addEventListener("install", (event) => {
// With offline mode on, a tab may load a chunk of its build at any time,
// from this worker's caches. A new build's worker waits until those tabs
// are gone or a tab asks it to take over: its "Reload", or a tab that
// runs the new build already (lib/app-update.ts adoptWaitingWorker). The
// old build's tabs keep their files then too: its cache stays while they
// are open, and chunks are answered from every build's cache. Without
// offline mode nothing is cached and it takes over at once, as it always
// did.
event.waitUntil(readOfflineShell().then((on) => {
if (!on) return self.skipWaiting();
return self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((open) => {
if (open.length === 0) return self.skipWaiting();
return undefined;
});
}));
});
self.addEventListener("activate", (event) => {
event.waitUntil(Promise.all([self.clients.claim(), deleteStaleShellCaches()]));
});
// ---- Offline mode (#523): the app shell ----
//
// Only while a tab turned offline mode on (Settings > Offline). Then:
// - pages of the app: network first; the answer is kept in this build's
// cache, and used when the network fails;
// - /_next/static/: content-hashed files, from any build's cache first
// (an open tab of an older build still finds its chunks), else the
// network, kept in this build's cache;
// - /api/config: network first, the last answer when offline.
// Everything else, and everything while offline mode is off, goes to the
// network untouched.
// null until read from the state cache; true or false after.
let offlineShellOn = null;
async function readOfflineShell() {
if (offlineShellOn !== null) return offlineShellOn;
try {
const cache = await caches.open(SW_STATE_CACHE);
const response = await cache.match(OFFLINE_SHELL_KEY);
const data = response ? await response.json() : null;
offlineShellOn = !!(data && data.on === true);
} catch (_) {
offlineShellOn = false;
}
return offlineShellOn;
}
async function writeOfflineShell(on) {
offlineShellOn = on;
try {
const cache = await caches.open(SW_STATE_CACHE);
await cache.put(OFFLINE_SHELL_KEY, new Response(JSON.stringify({ on }), { headers: { "Content-Type": "application/json" } }));
} catch (_) {
// No Cache API: nothing is cached either.
}
}
// Read at start, so the fetch handler mostly knows synchronously.
if (typeof caches !== "undefined" && caches && typeof caches.open === "function") {
readOfflineShell();
}
const APP_SECTIONS = new Set(["", "mail", "settings", "calendar", "contacts", "files", "pro", "apps"]);
/** The page path inside the app ("/mail/inbox"), or null for anything that isn't a page of the app. */
function appPagePath(url) {
if (url.origin !== self.location.origin) return null;
let path = url.pathname;
if (BASE_PATH) {
if (path !== BASE_PATH && !path.startsWith(BASE_PATH + "/")) return null;
path = path.slice(BASE_PATH.length) || "/";
}
const parts = path.split("/").filter(Boolean);
// An optional locale segment (/de/mail, /zh-TW/settings).
if (parts.length > 0 && /^[a-z]{2}(-[A-Za-z]{2,4})?$/.test(parts[0]) && !APP_SECTIONS.has(parts[0])) parts.shift();
return APP_SECTIONS.has(parts[0] || "") ? "/" + parts.join("/") : null;
}
function staticPath(url) {
if (url.origin !== self.location.origin) return false;
return url.pathname.startsWith(BASE_PATH + "/_next/static/");
}
/** Where a page of the app is kept: its address without the query. */
function documentKey(url) {
return url.origin + url.pathname.replace(/\/+$/, "");
}
/** A page the cache has for this address: the page itself, its section, the app's start page. */
async function cachedDocument(url) {
const path = appPagePath(url) || "/";
const section = "/" + (path.split("/").filter(Boolean)[0] || "");
const candidates = [documentKey(url), self.location.origin + BASE_PATH + (section === "/" ? "" : section), self.location.origin + BASE_PATH];
// Only looked into when it exists: opening it would create it again
// after the next build's worker deleted it.
if (await caches.has(SHELL_CACHE)) {
const own = await caches.open(SHELL_CACHE);
for (const key of candidates) {
const hit = await own.match(key);
if (hit) return hit;
}
}
for (const key of candidates) {
const hit = await caches.match(key);
if (hit) return hit;
}
return null;
}
/**
* The cache of the build that answered (the X-Bulwark-Build header): after a
* deploy the old worker still fetches the new build's pages and chunks, which
* belong in the new build's cache, not in its own.
*/
function shellCacheFor(response) {
const build = response.headers.get("x-bulwark-build") || "";
return /^[\w.-]{1,80}$/.test(build) ? SHELL_CACHE_PREFIX + build : SHELL_CACHE;
}
async function respondWithDocument(request) {
const url = new URL(request.url);
try {
const response = await fetch(request);
const type = response.headers.get("content-type") || "";
if (response.ok && response.type === "basic" && !response.redirected && type.includes("text/html")) {
const copy = response.clone();
caches.open(shellCacheFor(response)).then((cache) => cache.put(documentKey(url), copy)).catch(() => {});
}
return response;
} catch (error) {
const hit = await cachedDocument(url);
if (hit) return hit;
throw error;
}
}
async function respondWithStatic(request) {
const hit = await caches.match(request.url);
if (hit) return hit;
const response = await fetch(request);
if (response.ok && response.type === "basic") {
const copy = response.clone();
caches.open(shellCacheFor(response)).then((cache) => cache.put(request.url, copy)).catch(() => {});
}
return response;
}
async function respondWithConfig(request) {
const key = self.location.origin + BASE_PATH + "/api/config";
try {
const response = await fetch(request);
if (response.ok) {
const copy = response.clone();
// Like pages and chunks, under the build that answered: right after a
// deploy this worker still fetches for the new build's tab.
caches.open(shellCacheFor(response)).then((cache) => cache.put(key, copy)).catch(() => {});
}
return response;
} catch (error) {
const hit = await caches.match(key);
if (hit) return hit;
throw error;
}
}
function offlineRoute(request) {
if (request.method !== "GET") return null;
const url = new URL(request.url);
if (request.mode === "navigate" && appPagePath(url) !== null) return respondWithDocument;
if (staticPath(url)) return respondWithStatic;
if (url.origin === self.location.origin && url.pathname === BASE_PATH + "/api/config") return respondWithConfig;
return null;
}
/**
* Keeps the files a tab names (its build's shell) in that build's cache. The
* tab may run a newer build than this worker (right after a deploy).
*/
async function precacheShell(urls, buildId) {
if (!Array.isArray(urls) || urls.length === 0) return;
const name = typeof buildId === "string" && /^[\w.-]{1,80}$/.test(buildId) ? SHELL_CACHE_PREFIX + buildId : SHELL_CACHE;
const cache = await caches.open(name);
const wanted = urls.filter((u) => typeof u === "string");
for (let i = 0; i < wanted.length; i += 8) {
await Promise.all(wanted.slice(i, i + 8).map(async (u) => {
try {
const url = new URL(u, self.location.origin);
if (url.origin !== self.location.origin) return;
const key = appPagePath(url) !== null && !staticPath(url) ? documentKey(url) : url.href;
if (await cache.match(key)) return;
const response = await fetch(url.href, { credentials: "same-origin" });
if (response.ok && response.type === "basic" && !response.redirected) await cache.put(key, response);
} catch (_) {
// Fetched again when the page asks next time.
}
}));
}
}
async function setOfflineShell(on, urls, buildId) {
await writeOfflineShell(on);
if (on) {
await precacheShell(urls, buildId);
return;
}
// Off: nothing of the shell stays.
const keys = await caches.keys();
await Promise.all(keys.filter((key) => key.startsWith(SHELL_CACHE_PREFIX)).map((key) => caches.delete(key)));
}
async function readClientBuilds() {
try {
const cache = await caches.open(SW_STATE_CACHE);
const response = await cache.match(CLIENT_BUILDS_KEY);
const data = response ? await response.json() : null;
return data && typeof data === "object" ? data : {};
} catch (_) {
return {};
}
}
async function writeClientBuilds(map) {
try {
const cache = await caches.open(SW_STATE_CACHE);
await cache.put(CLIENT_BUILDS_KEY, new Response(JSON.stringify(map), { headers: { "Content-Type": "application/json" } }));
} catch (_) {
// Without the Cache API nothing was cached either.
}
}
/** Notes which build an open tab runs, and forgets tabs that are gone. */
async function recordClientBuild(clientId, buildId) {
if (!clientId || typeof buildId !== "string" || !buildId) return;
const open = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
const openIds = new Set(open.map((c) => c.id));
const map = await readClientBuilds();
const next = {};
for (const [id, build] of Object.entries(map)) if (openIds.has(id)) next[id] = build;
next[clientId] = buildId;
await writeClientBuilds(next);
}
/**
* Deletes the shell caches of builds no open tab runs any more. This
* worker's own build is kept, and so is every build a tab still runs: such a
* tab may still load a chunk of its build after a deploy, and the new build
* no longer has it.
*/
async function deleteStaleShellCaches() {
if (typeof caches === "undefined" || typeof caches.keys !== "function") return;
try {
const open = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
const openIds = new Set(open.map((c) => c.id));
const map = await readClientBuilds();
const inUse = new Set([BUILD_ID]);
for (const [id, build] of Object.entries(map)) if (openIds.has(id)) inUse.add(build);
const keys = await caches.keys();
await Promise.all(keys
.filter((key) => key.startsWith(SHELL_CACHE_PREFIX) && !inUse.has(key.slice(SHELL_CACHE_PREFIX.length)))
.map((key) => caches.delete(key)));
} catch (_) {
// Tried again at the next activation.
}
}
self.addEventListener("fetch", (event) => {
// Offline mode off (known): the network, untouched, as without a worker.
if (offlineShellOn === false) return;
const route = offlineRoute(event.request);
if (!route) return;
if (offlineShellOn === true) {
event.respondWith(route(event.request));
return;
}
// Not read yet (the worker just started): decide once it is.
event.respondWith(readOfflineShell().then((on) => (on ? route(event.request) : fetch(event.request))));
});
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 === "client-build") {
event.waitUntil(recordClientBuild(event.source && event.source.id, data.buildId).then(deleteStaleShellCaches));
return;
}
if (data.type === "offline-shell") {
// The switch is kept before the event ends. The shell's files are
// fetched without holding the event open: a worker busy with an event
// can't hand over to a new build's worker, and the fetch is tried again
// whenever a tab asks.
if (data.on !== true) {
event.waitUntil(setOfflineShell(false));
return;
}
event.waitUntil(writeOfflineShell(true));
setOfflineShell(true, data.urls, data.buildId).catch(() => {});
return;
}
if (data.type === "skip-waiting") {
// The tab's "Reload" for a new build: take over now instead of waiting
// for every tab of the old build to close.
self.skipWaiting();
return;
}
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 && preview && preview.redacted) {
// The organisation keeps sender and subject off notifications
// (Organisation > Devices): the route sent neither.
title = accountLabel ? `New mail (${accountLabel})` : "New mail";
body = unreadTotal > 1 ? `${unreadTotal} unread messages` : "You have new mail";
if (unreadTotal <= 1) data = { kind: "email", emailId: email.id, threadId: email.threadId, slot };
} else 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`;
}