Files
webmail/public/sw.js
T
Linus Rath adbec718ef feat: let admins keep sender and subject out of push notifications, enforced by the server
The push preview route now reads the device policy of the login it
serves (its scopes worked out from the login's own JMAP session, every
non-personal account counted as a group) and under
`pushWithoutContent` asks the mail server for the message's ids only,
so sender, subject and text never reach the notification. The service
worker shows "New mail" for such an answer. A policy that can't be read
counts as on.

The three existing push preview route tests now mock the config manager
with the default policy: without it the route can't read a policy in the
test environment and holds the text back, which those tests didn't mean
to cover. No assertion changed.
2026-11-01 08:40:02 +01:00

629 lines
24 KiB
JavaScript

/* 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 && 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`;
}