2026-10-05 18:54:17 +02:00
|
|
|
// 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));
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-05 19:27:36 +02:00
|
|
|
/**
|
|
|
|
|
* 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 });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 18:54:17 +02:00
|
|
|
self.addEventListener("message", (event) => {
|
|
|
|
|
if (event.data?.type === "purge") event.waitUntil(purge());
|
2026-10-05 19:27:36 +02:00
|
|
|
if (event.data?.type === "fetch" && event.ports[0]) {
|
|
|
|
|
event.waitUntil(fetchForPage(event.data.url, event.ports[0]));
|
|
|
|
|
}
|
2026-10-05 18:54:17 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
})(),
|
|
|
|
|
);
|
|
|
|
|
});
|