/* ihasmail service worker. Two jobs: app-shell caching for installability and fast loads (API requests are never cached), and Web Push, which is the only part of ihasmail that runs when no tab is open. */ const VERSION = "ihasmail-v2"; /* * The mount, worked out rather than configured. * * This file is copied to the build verbatim -- Vite's `base` never touches * public/ -- so there is nothing to substitute BASE_PATH into. It does not * need one: the worker is served from the mount, so its own address says * where that is. `/mail/sw.js` gives `/mail`, `/sw.js` gives `""`, which is * the same canonical form the rest of the app uses. * * Deriving it here also means the worker cannot disagree with the page that * registered it, which a second copy of the value in a build-time constant * eventually would. */ const BASE = new URL("./", self.location).pathname.replace(/\/$/, ""); const SHELL = [`${BASE}/`, `${BASE}/manifest.webmanifest`, `${BASE}/img/logo.png`, `${BASE}/img/icon-192.png`, `${BASE}/favicon.ico`]; self.addEventListener("install", (event) => { event.waitUntil(caches.open(VERSION).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting())); }); self.addEventListener("activate", (event) => { event.waitUntil( caches.keys() .then((keys) => Promise.all(keys.filter((k) => k !== VERSION).map((k) => caches.delete(k)))) .then(() => tidy()) .catch(() => {}) .then(() => self.clients.claim()) ); }); /* * Keeping the cache to what the current build uses. * * Build assets are cached on first use and their names change with every * build, and nothing used to take them out again: every deploy's chunks stayed * in the browser for good. Worse, whatever the server answered was kept -- a * 404 for a chunk asked for while a deploy was changing over became that * chunk, from then on, in that browser. * * The rule now: only a successful response is cached, and whenever the app * page changes, the assets it no longer names are dropped. A lazily loaded * chunk the page does not name is dropped too, and fetched again the next time * it is wanted -- a hash that did not change is still on the server. * * The cache name stays as it is. The same cache carries what the worker leaves * for a tab to collect -- a push verification, a share, the facts it notifies * from -- and a new name would throw those away along with the rubbish. */ const ASSETS = `${BASE}/assets/`; const SHELL_KEY = `${BASE}/`; function assetsNamedIn(html) { const out = new Set(); for (const m of html.matchAll(/["']([^"']*\/assets\/[^"']+)["']/g)) { try { out.add(new URL(m[1], self.location).pathname); } catch { /* not a URL */ } } return out; } /** Drop failed responses, and assets the cached app page does not name. */ async function tidy() { const cache = await caches.open(VERSION); const shell = await cache.match(SHELL_KEY); // Without a page to go by, which assets are current is unknown; keep them. const keep = shell ? assetsNamedIn(await shell.text()) : null; for (const req of await cache.keys()) { const path = new URL(req.url).pathname; if (path.startsWith(ASSETS)) { if (keep && !keep.has(path)) { await cache.delete(req); continue; } } const res = await cache.match(req); if (res && !res.ok) await cache.delete(req); } } /** Keep the offline copy of the app page current, and tidy when it changes. */ async function refreshShell(res) { const html = await res.text(); const cache = await caches.open(VERSION); const prev = await cache.match(SHELL_KEY); if (prev && (await prev.text()) === html) return; await cache.put(SHELL_KEY, new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } })); await tidy(); } /* * Where a share from the operating system is left for a tab to collect. * * Absolute and anchored to the mount, for the same reason the verification key * below is: a relative key is resolved against the URL of whoever asks, and the * worker and a tab deep in `/mail/inbox/…` are not at the same place. * * The files go in one entry each and the rest in a JSON index beside them, * because the Cache API stores Responses and a File is already one body. */ const SHARE_KEY = `${BASE}/ihasmail-share`; const SHARE_MAX_FILES = 20; /* * Take delivery of a share. * * This is a POST that navigates: the operating system submits a form at the * app and expects a page back. Nothing in ihasmail can answer it directly -- * the app is a client-side router with no endpoint at that address, and the * server behind it would have to grow one that understood the composer. So the * worker takes the body, puts it where a tab can find it, and redirects to the * app, which then opens a draft holding it. * * The redirect happens whatever went wrong. A share that fails to stash costs * whatever was being shared, which is bad; a share that fails to *respond* * costs that and leaves the reader looking at a browser error page where they * expected their mail, which is worse. * * There is one case this cannot cover, and the server is deliberately not * taught to: an app still installed whose worker has been cleared away. The * POST then reaches the server, which answers 405, and the share is lost * either way -- the payload only ever existed in that request body. A server * route would trade a plain error for a silent nothing, and a share that * vanishes without saying so is the harder of the two to notice. */ async function stashShare(request) { try { const form = await request.formData(); const cache = await caches.open(VERSION); const meta = { at: Date.now(), title: String(form.get("title") ?? ""), text: String(form.get("text") ?? ""), url: String(form.get("url") ?? ""), files: [], }; const files = form.getAll("files").filter((f) => f && typeof f === "object" && "name" in f && f.size > 0); for (const [i, f] of files.slice(0, SHARE_MAX_FILES).entries()) { const key = `${SHARE_KEY}/${i}`; await cache.put(key, new Response(f, { headers: { "content-type": f.type || "application/octet-stream" } })); meta.files.push({ key, name: f.name || `file-${i + 1}`, type: f.type || "application/octet-stream" }); } await cache.put(SHARE_KEY, new Response(JSON.stringify(meta), { headers: { "content-type": "application/json" } })); } catch { /* nothing to hand on: the app opens on an empty inbox rather than an error */ } // Absolute, because `Response.redirect` rejects a bare path outright rather // than resolving it -- so `${BASE}/mail` would throw here and the share // would end at a browser error page instead of the inbox. return Response.redirect(new URL(`${BASE}/mail?share=1`, self.location.origin).href, 303); } self.addEventListener("fetch", (event) => { const req = event.request; if (req.method === "POST" && new URL(req.url).pathname === `${BASE}/share`) { event.respondWith(stashShare(req)); return; } if (req.method !== "GET") return; const url = new URL(req.url); if (url.origin !== self.location.origin) return; if (url.pathname.startsWith(`${BASE}/api/`)) return; // Hashed build assets: cache-first, and only what actually arrived. if (url.pathname.startsWith(ASSETS)) { event.respondWith( caches.match(req).then((hit) => hit || fetch(req).then((res) => { if (res.ok && res.type === "basic") { const copy = res.clone(); event.waitUntil(caches.open(VERSION).then((c) => c.put(req, copy)).catch(() => {})); } return res; })) ); return; } // Navigations & everything else: network-first, fall back to cached shell. if (req.mode === "navigate") { event.respondWith(fetch(req).then((res) => { // Every route is the same app page; a fresh one replaces the offline copy. if (res.ok && (res.headers.get("content-type") || "").startsWith("text/html")) { event.waitUntil(refreshShell(res.clone()).catch(() => {})); } return res; }).catch(() => caches.match(SHELL_KEY))); return; } event.respondWith(fetch(req).catch(() => caches.match(req))); }); /* ------------------------------------------------------------------ */ /* Web Push */ /* ------------------------------------------------------------------ */ /* * Stalwart signs with VAPID and pushes straight to the browser's push service; * nothing here talks to ihasmail's server on the way in. The payload is an * EmailPush object (draft-ietf-jmap-emailpush) carrying enough of the message * to show a useful notification without a round-trip, which is what lets a * notification appear immediately rather than after a request. * * This file used to say that a round-trip was impossible here, and it was * wrong: see the note on `jmap()`. What it can do is ask; what it cannot do is * be sure of an answer, since the session may be gone by the time it does. So * the payload still carries the message and the request is only made when * somebody presses something. * * A JMAP subscription also delivers a PushVerification first, and stays silent * until the client echoes its code back. It is stashed for a tab to confirm * rather than answered here — on the same reasoning, and because a * verification that failed silently would leave push looking broken with * nothing to show for it. Answering it directly is now possible and is worth * revisiting. */ /* * Absolute, and anchored to the mount rather than to whatever page happens to * be open. * * A relative key is resolved against the URL of whoever is asking: the worker * lives at `/sw.js`, so it stored this under `/…`, while a tab at * `/mail/inbox/abc` looked for it under `/mail/inbox/…`. The two only ever * agreed when the open page was the root, so a verification code that arrived * with no tab open was written where the next tab would not look -- and the * subscription stayed silent, which is the same thing push failing looks like. */ const VERIFY_KEY = `${BASE}/ihasmail-push-verification`; /* * What a tab wrote down for this worker: the account, which mailbox is the * archive, and the worker's own text in the reader's language. See * `lib/swFacts.ts` for why any of that has to be handed over rather than * worked out here. * * Everything that depends on it is skipped when it is missing, which is the * state between installing this worker and next opening the app. An action * button with no label, or one that files mail into a mailbox guessed by name, * is worse than the notification that was here before. */ const FACTS_KEY = `${BASE}/ihasmail-worker-facts`; async function readFacts() { try { const hit = await (await caches.open(VERSION)).match(FACTS_KEY); return hit ? await hit.json() : null; } catch { return null; } } /* * A JMAP call, made as the reader. * * This worker was written believing it could not do this -- that acting on * mail needed a session it had no way to hold. It does not: ihasmail's session * is an httpOnly cookie against its own origin, and the only other thing the * API asks for is a fixed `x-requested-with` header that is not a secret and * is not held anywhere. A same-origin fetch from here carries the cookie like * any other, so `Email/set` from a notification is an ordinary request. * * What is genuinely not available is anything the *tab* holds in memory, and * the answer is that the API asks for none of it. * * The session can still be gone -- expired, signed out, or a cookie that did * not survive the browser closing -- which arrives as a 401 and is reported * rather than swallowed. A tap that silently does nothing is the failure worth * avoiding here: the reader has already put the phone down. */ async function jmap(methodCalls) { const res = await fetch(`${BASE}/api/jmap`, { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json", accept: "application/json", "x-requested-with": "ihasmail" }, body: JSON.stringify({ using: ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], methodCalls }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const body = await res.json(); // A JMAP method can fail inside a 200. Treat that as a failure too, rather // than reporting success because the transport was fine. const first = body?.methodResponses?.[0]; if (!first || first[0] === "error") throw new Error(first?.[1]?.type || "error"); const notUpdated = first[1]?.notUpdated; if (notUpdated && Object.keys(notUpdated).length) throw new Error("notUpdated"); return body; } function textOf(email, strings) { const from = email?.from?.[0]; const who = from?.name || from?.email || strings.newMessage; const what = email?.subject || strings.noSubject; return { title: who, body: what, preview: email?.preview || "" }; } /* * Two, because that is what a phone shows. `Notification.maxActions` is 2 on * Android Chrome, and anything past it is dropped silently -- so these are the * two worth having rather than the two that happened to come first. Both are * triage: they are what somebody does to a notification they have read the * whole of on the lock screen and does not need to open. * * Reply is deliberately not among them. It cannot be done from here, so it * would have to open the app -- and an action that opens the app is what * tapping the notification already does. */ function actionsFor(facts) { if (!facts) return []; const actions = []; if (facts.archiveId) actions.push({ action: "archive", title: facts.strings.archive }); actions.push({ action: "read", title: facts.strings.markRead }); return actions; } self.addEventListener("push", (event) => { let data = null; try { data = event.data ? event.data.json() : null; } catch { /* not JSON: fall through to the generic notification below */ } // The verification handshake. No credentials here, so hand it to a tab — // an open one now, or the next one to start. if (data && data["@type"] === "PushVerification") { event.waitUntil((async () => { const payload = { id: data.pushSubscriptionId, code: data.verificationCode }; const clients = await self.clients.matchAll({ includeUncontrolled: true, type: "window" }); if (clients.length) { for (const c of clients) c.postMessage({ type: "push-verification", ...payload }); } else { const cache = await caches.open(VERSION); await cache.put(VERIFY_KEY, new Response(JSON.stringify(payload))); } })()); return; } const emails = (data && data["@type"] === "EmailPush" && Array.isArray(data.emails)) ? data.emails : []; event.waitUntil((async () => { const facts = await readFacts(); const strings = facts?.strings ?? { newMail: "New mail", newMessage: "New message", noSubject: "(no subject)" }; /* * Mark the app icon, without claiming a number. * * `setAppBadge()` with no count shows a dot rather than a figure, which is * the only honest thing to show from here: this worker has no session, so * it cannot ask how many messages are unread, and a push carries the new * mail rather than a total. Counting the payload would badge "2" over an * inbox holding forty. The next time a tab opens, `setUnreadBadge` writes * the real count over the dot. */ if ("setAppBadge" in self.navigator) await self.navigator.setAppBadge().catch(() => {}); if (!emails.length) { // A StateChange, or a payload too large to carry the message. Say // something true rather than inventing a sender. await self.registration.showNotification(strings.newMail, { icon: `${BASE}/img/icon-192.png`, badge: `${BASE}/img/favicon-64.png`, tag: "ihasmail-mail", data: { url: `${BASE}/mail` }, }); return; } // One notification per message, collapsing repeats of the same message by // tag so a re-push does not stack. for (const email of emails.slice(0, 5)) { const { title, body, preview } = textOf(email, strings); await self.registration.showNotification(title, { body: preview ? `${body}\n${preview}` : body, icon: `${BASE}/img/icon-192.png`, badge: `${BASE}/img/favicon-64.png`, tag: `ihasmail-${email.id || body}`, // Only where there is a message to act on: a payload without an id can // be shown but not archived, and a button that cannot work should not // be drawn. actions: email.id ? actionsFor(facts) : [], data: { url: email.id ? `${BASE}/mail/inbox/${email.id}` : `${BASE}/mail`, id: email.id || null, title, accountId: facts?.accountId ?? null, archiveId: facts?.archiveId ?? null, failed: strings.failed ?? null, }, }); } })()); }); /* * Do what the button said, without opening anything. * * The whole point of an action is that the phone goes back in the pocket, so * this must not fall back to opening the app when the call fails -- that is * the same interruption the action existed to avoid. It re-notifies instead, * saying it did not happen, and leaves opening ihasmail to the reader. * * Archiving replaces the mailbox set rather than adding to it, which is what * archiving is: the message leaves the inbox. Marking read is a keyword and * touches nothing else. */ async function runAction(action, data) { const { id, accountId, archiveId } = data; if (!id || !accountId) return; const patch = action === "archive" ? { mailboxIds: { [archiveId]: true } } : { "keywords/$seen": true }; try { if (action === "archive" && !archiveId) throw new Error("no archive mailbox"); await jmap([["Email/set", { accountId, update: { [id]: patch } }, "0"]]); } catch { await self.registration.showNotification(data.title || "ihasmail", { body: data.failed || "Could not do that — open ihasmail and try again", icon: `${BASE}/img/icon-192.png`, badge: `${BASE}/img/favicon-64.png`, tag: `ihasmail-failed-${id}`, data: { url: data.url }, }); } } self.addEventListener("notificationclick", (event) => { event.notification.close(); const data = event.notification.data || {}; if (event.action === "archive" || event.action === "read") { event.waitUntil(runAction(event.action, data)); return; } const url = data.url || `${BASE}/mail`; event.waitUntil((async () => { const clients = await self.clients.matchAll({ includeUncontrolled: true, type: "window" }); // Reuse a tab if one is open rather than piling up windows. Same origin is // not enough under a prefix: `includeUncontrolled` widens the match to the // whole origin, so on a host that also serves something else this would // navigate a stranger's tab to our inbox. for (const c of clients) { const at = new URL(c.url); if (at.origin === self.location.origin && (at.pathname === BASE || at.pathname.startsWith(`${BASE}/`))) { await c.focus(); if ("navigate" in c) await c.navigate(url).catch(() => {}); return; } } await self.clients.openWindow(url); })()); });