// 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(() => {}), ); });