Files
webmail-client/frontend/public/sw.js
T
lukasandClaude Opus 5.5 9c08cbb140 Offline: newest 100 conversations; notifications no longer wait silently
Offline access keeps the newest 100 Inbox conversations (the list's first two
pages, saved as the app asks for them) plus recently opened ones, up to 130.

Turning notifications on in a browser that holds the permission request
without showing it (Opera, Chrome's quiet prompts) said nothing and never
finished. After a few seconds it now says how to allow notifications for the
site, and carries on by itself as soon as they are allowed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:20:21 +02:00

576 lines
20 KiB
JavaScript

// The app's service worker: notifications about new mail (always), and offline
// access (Settings → Accounts → "Offline access") on devices where it is on.
//
// Offline access is on when this worker was registered as /sw.js?offline=1
// (lib/offline.ts); registered as plain /sw.js it leaves every request alone.
// Turning the setting on or off re-registers under the other URL, which the
// browser treats as an update, so the change takes effect at once.
//
// With offline access on: while the server answers, everything comes from the
// network exactly as before; each answer is also saved here. Only when the
// server cannot be reached is the saved copy served instead — marked with
// `X-WM-Offline: 1`, which tells the page to show that it is offline. Saved
// mail is the newest Inbox conversations (fetched by the page, see
// lib/offlinePrefetch.ts) plus the ones opened recently, at most MAX_THREADS;
// older ones are dropped.
//
// Saved mail is wiped when the server answers a request with 401 (signed out,
// session expired or revoked), when another user signs in, and on request from
// the page (sign-out, or the setting turned off).
//
// Plain JavaScript, served as is from /sw.js: a service worker must live at a
// fixed URL, so it cannot be a hashed Vite chunk.
// What the page expects this worker to understand (lib/offlinePrefetch.ts
// WORKER_VERSION): raise both together when the messages below change, so a
// page never talks to an older worker that would not answer.
const VERSION = 4;
const OFFLINE = new URL(self.location.href).searchParams.get("offline") === "1";
const SHELL = "wm-shell-v1";
const DATA = "wm-data-v1";
const INDEX_KEY = "/__wm/offline-index";
const APP_KEY = "/__wm/app";
const MAX_THREADS = 130; // the newest 100 Inbox conversations + recently opened ones
const MAX_ORPHANS = 50; // message parts whose conversation was never saved
const MAX_LISTS = 40; // thread lists (one per folder / filter / page)
const MAX_AVATARS = 300;
const MAX_PLUGIN = 60; // plugin lookups not tied to one message (the Labels list…)
// How long to wait for the server before falling back to the saved copy. Long
// enough that a slow connection still gets fresh data; a missing one fails at
// once anyway. Once a request has failed we know we are offline, and serve the
// saved copy straight away until a request succeeds again.
const API_TIMEOUT = 15000;
const NAV_TIMEOUT = 8000;
const RETRY_TIMEOUT = 4000;
const META = new Set([
"/api/auth/me",
"/api/accounts",
"/api/mailboxes",
"/api/mailboxes/counts",
"/api/settings",
"/api/settings/quota",
"/api/saved-searches",
"/api/plugins/ui-manifest",
]);
const ID = "[0-9a-fA-F-]{36}";
const THREAD_RE = new RegExp(`^/api/threads/(${ID})$`);
const MESSAGE_RE = new RegExp(
`^/api/messages/(${ID})/(?:html|attachments|attachments/${ID}/thumb|cid/.+)$`,
);
const ID_IN_PATH = new RegExp(ID);
const PLUGIN_UI_RE = /^\/api\/plugins\/[^/]+\/ui\//; // plugin bundles (Labels' sidebar…)
/** What a request is, for saving it; null = never saved (search, downloads, …). */
function classify(url) {
const p = url.pathname;
if (META.has(p) || PLUGIN_UI_RE.test(p)) return { kind: "meta" };
if (p.startsWith("/api/plugins/")) {
// A plugin's lookup about one message (antispam's verdict…) goes with that
// message; anything else is kept a while, most recent first.
const id = p.match(ID_IN_PATH);
return id ? { kind: "message", id: id[0] } : { kind: "plugin" };
}
if (p === "/api/threads") return { kind: "list" };
if (p === "/api/avatar") return { kind: "avatar" };
let m = p.match(THREAD_RE);
if (m) return { kind: "thread", id: m[1] };
m = p.match(MESSAGE_RE);
if (m) return { kind: "message", id: m[1] };
return null;
}
let offline = false;
// ---- the index: what is saved, and when it was last fetched ------------------
const emptyIndex = () => ({ user: null, threads: {}, msgs: {}, lists: {}, avatars: {}, plugin: {} });
let index = null;
let chain = Promise.resolve();
/** Run `fn` on the index, one at a time (fetches finish concurrently). */
function withIndex(fn) {
const run = chain.then(async () => {
if (!index) {
try {
const hit = await (await caches.open(DATA)).match(INDEX_KEY);
index = hit ? await hit.json() : emptyIndex();
} catch {
index = emptyIndex();
}
}
const changed = await fn(index);
if (changed !== false) {
const cache = await caches.open(DATA);
await cache.put(
INDEX_KEY,
new Response(JSON.stringify(index), { headers: { "Content-Type": "application/json" } }),
);
}
});
chain = run.catch(() => {});
return run;
}
/** Forget everything saved (signed out, another user, setting turned off). */
function purge() {
const run = chain.then(async () => {
index = emptyIndex();
await caches.delete(DATA);
});
chain = run.catch(() => {});
return run;
}
function oldestFirst(record) {
return Object.entries(record).sort((a, b) => a[1] - b[1]);
}
/** Record a saved response and drop whatever no longer fits. */
async function record(ix, url, cls, body) {
const now = Date.now();
if (cls.kind === "thread") {
let ids = [];
try {
ids = JSON.parse(new TextDecoder().decode(body)).messages.map((m) => m.id);
} catch {
/* not the shape we know: keep the thread, without its messages */
}
ix.threads[cls.id] = { at: now, msgs: ids };
for (const id of ids) ix.msgs[id] = { t: cls.id, at: now };
} else if (cls.kind === "message") {
ix.msgs[cls.id] = { t: ix.msgs[cls.id]?.t ?? null, at: now };
} else if (cls.kind === "list") {
ix.lists[url.pathname + url.search] = now;
} else if (cls.kind === "avatar") {
ix.avatars[url.pathname + url.search] = now;
} else if (cls.kind === "plugin") {
(ix.plugin ??= {})[url.pathname + url.search] = now;
}
return trim(ix);
}
async function trim(ix) {
const dropThreads = new Set();
const dropMsgs = new Set();
const dropUrls = new Set();
const threads = Object.entries(ix.threads).sort((a, b) => b[1].at - a[1].at);
for (const [id] of threads.slice(MAX_THREADS)) dropThreads.add(id);
const orphans = [];
for (const [id, m] of Object.entries(ix.msgs)) {
if (m.t && (dropThreads.has(m.t) || !ix.threads[m.t])) dropMsgs.add(id);
else if (!m.t) orphans.push([id, m.at]);
}
orphans.sort((a, b) => b[1] - a[1]);
for (const [id] of orphans.slice(MAX_ORPHANS)) dropMsgs.add(id);
for (const [saved, max] of [
[ix.lists, MAX_LISTS],
[ix.avatars, MAX_AVATARS],
[(ix.plugin ??= {}), MAX_PLUGIN],
]) {
const all = oldestFirst(saved);
for (const [u] of all.slice(0, Math.max(0, all.length - max))) {
dropUrls.add(u);
delete saved[u];
}
}
if (!dropThreads.size && !dropMsgs.size && !dropUrls.size) return;
for (const id of dropThreads) delete ix.threads[id];
for (const id of dropMsgs) delete ix.msgs[id];
const cache = await caches.open(DATA);
for (const req of await cache.keys()) {
const u = new URL(req.url);
const p = u.pathname;
const t = p.match(THREAD_RE);
// Message parts, and plugin lookups about a message (see classify).
const m = p.match(MESSAGE_RE) ?? (p.startsWith("/api/plugins/") ? p.match(new RegExp(`(${ID})`)) : null);
if (
dropUrls.has(p + u.search) ||
(t && dropThreads.has(t[1])) ||
(m && dropMsgs.has(m[1]))
) {
await cache.delete(req);
}
}
}
// ---- saving and serving ------------------------------------------------------
/** A full answer — or, for a sender picture, the server's "none" (204). */
function worthSaving(res, cls) {
return res.status === 200 || (cls.kind === "avatar" && res.status === 204);
}
/** Save a copy of a server answer (`res` is a clone the page never reads). */
async function save(req, res, cls) {
const url = new URL(req.url);
const body = await res.arrayBuffer();
if (cls.kind === "meta" && url.pathname === "/api/auth/me") {
// Another user signed in on this browser: the saved mail is not theirs.
let id = null;
try {
id = JSON.parse(new TextDecoder().decode(body)).id ?? null;
} catch {
/* ignore */
}
await withIndex(async (ix) => {
if (ix.user === id) return false;
if (ix.user && id) {
Object.assign(ix, emptyIndex());
await caches.delete(DATA);
}
ix.user = id;
});
}
const headers = new Headers(res.headers);
headers.set("X-WM-Saved-At", String(Date.now()));
const cache = await caches.open(DATA);
const empty = res.status === 204;
await cache.put(url.href, new Response(empty ? null : body, { status: res.status, headers }));
if (cls.kind !== "meta") await withIndex((ix) => record(ix, url, cls, body));
}
/** The saved copy for a request; conversations also match with other options. */
async function lookup(req, cls) {
const cache = await caches.open(DATA);
const hit = await cache.match(req.url, { ignoreVary: true });
if (hit || (cls.kind !== "thread" && cls.kind !== "message")) return hit;
// The same conversation opened from another folder or in the other order, or
// a body asked for with/without remote images: still the same mail.
return cache.match(req.url, { ignoreVary: true, ignoreSearch: true });
}
function fromSaved(hit) {
const headers = new Headers(hit.headers);
headers.set("X-WM-Offline", "1");
return new Response(hit.body, { status: hit.status, statusText: hit.statusText, headers });
}
function notSaved(cls) {
// A sender picture not saved: "no picture" (the initials show), not an error.
if (cls?.kind === "avatar") return new Response(null, { status: 204, headers: { "X-WM-Offline": "1" } });
return new Response(
JSON.stringify({ error: { code: "offline", message: "Not available offline" } }),
{ status: 503, headers: { "Content-Type": "application/json", "X-WM-Offline": "1" } },
);
}
async function network(req, ms) {
if (req.mode === "navigate") {
// A page load cannot be re-issued with a signal: just stop waiting for it.
let timer;
const late = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error("timeout")), ms);
});
try {
return await Promise.race([fetch(req), late]);
} finally {
clearTimeout(timer);
}
}
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), ms);
try {
return await fetch(req, { signal: ctrl.signal });
} finally {
clearTimeout(timer);
}
}
async function fromServer(event, req, cls, ms) {
const res = await network(req, ms);
offline = false;
if (res.status === 401) event.waitUntil(purge());
// Copied now, before the page starts reading the original.
else if (cls && worthSaving(res, cls)) event.waitUntil(save(req, res.clone(), cls).catch(() => {}));
return res;
}
async function apiData(event, req, cls) {
if (offline) {
const hit = await lookup(req, cls);
if (hit) return fromSaved(hit);
// Not saved — and perhaps the connection is back: one quick try.
try {
return await fromServer(event, req, cls, RETRY_TIMEOUT);
} catch {
return notSaved(cls);
}
}
try {
return await fromServer(event, req, cls, API_TIMEOUT);
} catch {
offline = true;
const hit = await lookup(req, cls);
return hit ? fromSaved(hit) : notSaved(cls);
}
}
/** Every other API request: straight to the server, noting whether it answered. */
async function apiOther(event, req) {
try {
const res = await fetch(req);
offline = false;
if (res.status === 401) event.waitUntil(purge());
return res;
} catch {
offline = true;
return notSaved();
}
}
// ---- the app itself ----------------------------------------------------------
/** The files an app page loads up front (its scripts, styles and icons). */
function referencedAssets(html) {
const out = new Set();
for (const m of html.matchAll(/(?:src|href)="(\/[^"]+\.(?:js|css|svg|png|ico|woff2?))"/g)) {
out.add(m[1]);
}
return [...out];
}
// Build files named inside the app's scripts and styles: lazy chunks, fonts,
// images, the PDF worker. Not in the page itself, but needed offline all the same.
const NESTED_RE = /(?:^|[^\w/])\/?(assets\/[\w.-]+\.(?:js|mjs|css|svg|png|jpe?g|webp|ttf|otf|woff2?))/g;
async function withNested(files) {
const all = new Set(files);
const cache = await caches.open(SHELL);
for (const p of files) {
if (!/\.(?:js|css)$/.test(p)) continue;
try {
// Read from the saved copy when there is one: this runs on every app load.
const r = (await cache.match(p)) ?? (await fetch(p));
if (!r.ok) continue;
for (const m of (await r.text()).matchAll(NESTED_RE)) all.add("/" + m[1]);
} catch {
/* next time */
}
}
return [...all];
}
/** Keep the app page, and everything it loads, for opening without a connection. */
async function saveApp(res) {
const html = await res.text();
const cache = await caches.open(SHELL);
const previous = await cache.match(APP_KEY);
const release = !previous || (await previous.text()) !== html;
if (release) await cache.put(APP_KEY, new Response(html, { headers: { "Content-Type": "text/html" } }));
// Every time, not only for a new release: an older worker may have saved the
// page without everything it needs (missing files are fetched, the rest is
// read from the saved copies).
const wanted = await withNested(referencedAssets(html));
// A new release: its files have new names, so the old ones can go.
if (release) {
for (const req of await cache.keys()) {
const p = new URL(req.url).pathname;
if (p.startsWith("/assets/") && !wanted.includes(p)) await cache.delete(req);
}
}
await Promise.all(
wanted.map(async (p) => {
if (await cache.match(p)) return;
try {
const r = await fetch(p);
if (r.ok) await cache.put(p, r);
} catch {
/* next time */
}
}),
);
}
async function appPage(event, req) {
try {
const res = await network(req, offline ? RETRY_TIMEOUT : NAV_TIMEOUT);
offline = false;
if (res.ok && (res.headers.get("Content-Type") || "").includes("text/html")) {
event.waitUntil(saveApp(res.clone()).catch(() => {}));
}
return res;
} catch {
offline = true;
const hit = await (await caches.open(SHELL)).match(APP_KEY);
return hit || Response.error();
}
}
/** Hashed build files never change, so a saved copy is always right. */
async function staticFile(req) {
const cache = await caches.open(SHELL);
const hit = await cache.match(req, { ignoreVary: true });
if (hit) return hit;
const res = await fetch(req);
if (res.ok) await cache.put(req, res.clone());
return res;
}
const STATIC_RE = /^(?:\/assets\/|\/fonts\/|\/[^/]+\.(?:svg|png|ico)$)/;
function isAppPage(url) {
const p = url.pathname;
return !p.startsWith("/api/") && !p.startsWith("/admin") && !(p.endsWith(".html") && p !== "/index.html");
}
self.addEventListener("fetch", (event) => {
if (!OFFLINE) return; // the browser does what it would without a worker
const req = event.request;
if (req.method !== "GET") return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
if (req.mode === "navigate") {
if (isAppPage(url)) event.respondWith(appPage(event, req));
return;
}
if (url.pathname.startsWith("/api/")) {
if (url.pathname === "/api/ws") return;
const cls = classify(url);
event.respondWith(cls ? apiData(event, req, cls) : apiOther(event, req));
return;
}
if (STATIC_RE.test(url.pathname)) event.respondWith(staticFile(req));
});
/**
* Pre-loading from the page (lib/offlinePrefetch.ts): fetch one URL and save it,
* answering on the message port with { status, body } — status 0 when the
* server could not be reached. By message rather than by the page's own fetch,
* so it works for a page this worker does not control (a hard reload, or the
* page that just turned offline access on).
*/
async function fetchForPage(url, port) {
const req = new Request(new URL(url, self.location.origin).href, { credentials: "same-origin" });
const cls = classify(new URL(req.url));
try {
const res = await network(req, API_TIMEOUT);
offline = false;
if (res.status === 401) await purge();
else if (cls && worthSaving(res, cls)) await save(req, res.clone(), cls);
port.postMessage({ status: res.status, body: await res.text() });
} catch {
offline = true;
port.postMessage({ status: 0 });
}
}
self.addEventListener("message", (event) => {
if (event.data?.type === "purge") event.waitUntil(purge());
if (event.data?.type === "version" && event.ports[0]) {
event.ports[0].postMessage({ version: VERSION, offline: OFFLINE });
}
if (event.data?.type === "fetch" && event.ports[0]) {
event.waitUntil(fetchForPage(event.data.url, event.ports[0]));
}
});
self.addEventListener("install", (event) => {
// Save the app straight away, so it opens offline even if the page that
// turned this on is never reloaded.
event.waitUntil(
(async () => {
if (OFFLINE) {
try {
const res = await fetch("/", { cache: "no-store" });
if (res.ok) await saveApp(res);
} catch {
/* saved on the next visit */
}
}
await self.skipWaiting();
})(),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
// Offline access off: nothing may stay saved on this device.
for (const name of await caches.keys()) {
if (name.startsWith("wm-") && (!OFFLINE || (name !== SHELL && name !== DATA))) await caches.delete(name);
}
await self.clients.claim();
})(),
);
});
// ---- notifications about new mail ---------------------------------------------------
//
// The server (app/push/service.py) sends { type: "mail", title, body, tag, url,
// unread } encrypted to this browser. One notification per conversation (`tag`),
// replaced when more arrives in it; the app icon's badge shows unread Inbox mail.
/** A window of the app the user is looking at right now. */
async function lookingAtApp() {
const wins = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
return wins.some((w) => w.visibilityState === "visible" && w.focused);
}
async function showMail(data) {
if (typeof data.unread === "number" && self.navigator.setAppBadge) {
await (data.unread ? self.navigator.setAppBadge(data.unread) : self.navigator.clearAppBadge()).catch(() => {});
}
// The app is open in front of the user: the new mail is already on screen.
// Not on Apple's push service, which withdraws permission from a site whose
// pushes show nothing.
const sub = await self.registration.pushManager.getSubscription();
const apple = Boolean(sub?.endpoint.includes(".push.apple.com"));
if (!apple && (await lookingAtApp())) return;
await self.registration.showNotification(data.title || "New mail", {
body: data.body || "",
tag: data.tag || "new-mail",
renotify: true,
icon: "/icon-192.png",
badge: "/badge-96.png",
timestamp: Date.now(),
data: { url: data.url || "/mail" },
});
}
self.addEventListener("push", (event) => {
let data = {};
try {
data = event.data ? event.data.json() : {};
} catch {
/* not ours to read: still say something, browsers require it */
}
event.waitUntil(showMail(data));
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = new URL(event.notification.data?.url || "/mail", self.location.origin).href;
event.waitUntil(
(async () => {
const wins = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
// An open window of the app: bring it forward and let the app go there
// itself (lib/push.ts), keeping whatever else it has open.
const win = wins.find((w) => w.focused) ?? wins.find((w) => w.visibilityState === "visible") ?? wins[0];
if (win) {
await win.focus().catch(() => {});
win.postMessage({ type: "open", url });
return;
}
await self.clients.openWindow(url);
})(),
);
});
// The push service replaced this browser's subscription (rare; Firefox does it
// after long idle). Subscribe again with the same server key; the app sends the
// new one to the server the next time it starts (lib/push.ts).
self.addEventListener("pushsubscriptionchange", (event) => {
const key = event.oldSubscription?.options?.applicationServerKey;
if (!key) return;
event.waitUntil(
self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key }).catch(() => {}),
);
});