Files
webmail-client/frontend/public/sw.js
T
lukasandClaude Opus 5.5 fba3c947bf Attachment previews, forwarded attachments, row dividers
Attachments in the reading pane are tiles with a preview: images and a PDF's
first page, rendered on the server (Pillow, pypdfium2) as WebP of at most
480 px, cached beside the blob store and in the browser. Tiles are all one
size — at most 230 px wide, narrower so three still fit in a row — and frame
each preview by its shape: close to the tile's it fills it, very tall / wide /
square ones show whole over a blurred copy, small ones are not enlarged, PDFs
show the top of the page. The name bar (checkbox, type, name, size, download)
slides in on hover and adapts to the tile's width; files without a preview
get a large type icon. Previews of opened conversations are kept offline.

Forward now carries the original's attachments: new compose attachments
sharing the stored blobs (POST /messages/{id}/forward-attachments).

Mailbox display / Settings → Appearance: "Show a dividing line between
rows" (off by default, per device), with a new switch. Settings drop the
"On a phone…" note, and "Language" reads "Spellchecking language".

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

489 lines
17 KiB
JavaScript

// Offline access (Settings → Accounts → "Offline access").
//
// 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.
// 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 = 3;
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;
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) => {
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 });
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();
})(),
);
});