Files
webmail-client/frontend/public/sw.js
T
lukasandClaude Opus 5.5 01bfbb37d4 Offline access: save through the service worker, not the page's fetch
Pre-loading relied on the page being controlled by the service worker, so
its requests would pass through it and be saved. A page loaded with a hard
reload (Ctrl+Shift+R) never is: pre-loading then waited, gave up silently,
and nothing new was saved — the app still could not start offline, and
Settings kept showing the old sync time.

The page now asks the worker by message to fetch and save each URL, which
works however the page was loaded. Sign-out reaches the worker through its
registration for the same reason. Thread URLs come from shared helpers in
api.ts, so pre-loading saves exactly what the app asks for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:27:36 +02:00

435 lines
14 KiB
JavaScript

// Offline access (Settings → Security → "Keep recent mail on this device").
//
// Registered only on devices where the user turned it 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.
const SHELL = "wm-shell-v1";
const DATA = "wm-data-v1";
const INDEX_KEY = "/__wm/offline-index";
const APP_KEY = "/__wm/app";
const MAX_THREADS = 80; // the newest 50 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;
// 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|cid/.+)$`);
/** What a request is, for saving it; null = never saved (search, downloads, …). */
function classify(url) {
const p = url.pathname;
if (META.has(p)) return { kind: "meta" };
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: {} });
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;
}
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],
]) {
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);
const m = p.match(MESSAGE_RE);
if (
dropUrls.has(p + u.search) ||
(t && dropThreads.has(t[1])) ||
(m && dropMsgs.has(m[1]))
) {
await cache.delete(req);
}
}
}
// ---- saving and serving ------------------------------------------------------
/** 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);
await cache.put(url.href, new Response(body, { status: 200, 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() {
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 && res.status === 200) 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();
}
}
try {
return await fromServer(event, req, cls, API_TIMEOUT);
} catch {
offline = true;
const hit = await lookup(req, cls);
return hit ? fromSaved(hit) : notSaved();
}
}
/** 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];
}
/** 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);
if (previous && (await previous.text()) === html) return;
await cache.put(APP_KEY, new Response(html, { headers: { "Content-Type": "text/html" } }));
const wanted = referencedAssets(html);
// A new release: its files have new names, so the old ones can go. Files
// loaded later (lazy chunks) are saved again the first time they are used.
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) => {
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 && res.status === 200) 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 === "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 () => {
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 () => {
for (const name of await caches.keys()) {
if (name.startsWith("wm-") && name !== SHELL && name !== DATA) await caches.delete(name);
}
await self.clients.claim();
})(),
);
});