Files
ihasmail-inbuxa/web/public/sw.js
T
jcoffey 2740129c6a Keep only the app page as the app page (#396)
The service worker answers app routes from its kept page (#395), and it
kept whatever the mount's root returned at install and whatever HTML a
navigation returned. Where the root is not the app -- demo.ihasmail.com
puts its landing page there -- a returning visitor got the landing page on
every route.

The kept page is now only ever the app page, recognised by the asset list
the build writes into it: install fetches /mail instead of /, a
navigation's page is kept only if it is the app's, and a foreign page left
by the earlier worker is dropped when this one activates. Only the app's
own routes are answered from it; the root and any page in front of the app
go to the network. The reload for a new build primes the kept page from
/mail for the same reason.
2026-09-16 15:07:45 -07:00

573 lines
24 KiB
JavaScript

/* 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}/manifest.webmanifest`, `${BASE}/img/logo.png`, `${BASE}/img/icon-192.png`, `${BASE}/favicon.ico`];
/*
* Only the app page may be kept as the app page.
*
* The mount's root is not always the app: demo.ihasmail.com puts its landing
* page there, and a front door of any kind can. The worker used to cache
* whatever `/` returned at install and whatever HTML a navigation returned,
* and since app routes are answered from that copy first, a demo visitor who
* came back got the landing page on every route, for good. The app page is
* recognised by the asset list the build writes into it.
*/
const APP_PAGE_MARKER = 'id="ihasmail-assets"';
const isAppPage = (html) => typeof html === "string" && html.includes(APP_PAGE_MARKER);
/*
* The routes the app itself owns (App.tsx). Only these are answered from the
* kept page; anything else under the mount -- the root, a landing or farewell
* page in front of the app, a file -- goes to the network as it always did.
*/
const APP_ROUTE = /^\/(mail|search|contacts|calendar|files|settings|admin|login)(\/|$)/;
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(VERSION)
.then((c) => c.addAll(SHELL))
.then(() => fetch(`${BASE}/mail`, { credentials: "same-origin" }).then((res) => (res.ok ? refreshShell(res) : undefined)).catch(() => {}))
.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(() => dropForeignShell())
.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. `also`
* is a page whose assets are kept as well: the one just replaced, which a tab
* opened from the kept copy may still be running.
*/
async function tidy(also = "") {
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;
if (keep) for (const path of assetsNamedIn(also)) keep.add(path);
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);
}
}
/** A kept page that is not the app page -- left by an earlier worker -- is thrown away. */
async function dropForeignShell() {
const cache = await caches.open(VERSION);
const kept = await cache.match(SHELL_KEY);
if (kept && !isAppPage(await kept.text())) await cache.delete(SHELL_KEY);
}
/** Keep the offline copy of the app page current, tidy when it changes, and fill in what it lists. */
async function refreshShell(res) {
const html = await res.text();
if (!isAppPage(html)) return;
const cache = await caches.open(VERSION);
const prev = await cache.match(SHELL_KEY);
const prevHtml = prev ? await prev.text() : "";
if (prevHtml !== html) {
await cache.put(SHELL_KEY, new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } }));
await tidy(prevHtml);
}
await precache(html);
}
/*
* Fetching the rest of the build before it is asked for.
*
* The app page lists every file of its build (see the asset-list plugin in
* vite.config.ts). Without this, the first time after a deploy that a reader
* opened the composer, settings or a viewer, it waited on the server for the
* code -- on a distant link, a visible pause. Now those files are fetched
* quietly once a page names them, a few at a time, and only those not held
* already; a load cut short is carried on at the next navigation, which calls
* this again. Language catalogs are left to be cached when used, and nothing
* is fetched ahead when the reader has asked the browser to save data.
*/
const PRECACHE_PARALLEL = 3;
function precacheList(html) {
const m = html.match(/<script type="application\/json" id="ihasmail-assets">([^<]*)<\/script>/);
if (!m) return [];
try {
const list = JSON.parse(m[1]).precache;
return Array.isArray(list) ? list.filter((p) => typeof p === "string" && p.startsWith(ASSETS)) : [];
} catch {
return [];
}
}
async function precache(html) {
if (self.navigator.connection && self.navigator.connection.saveData) return;
const cache = await caches.open(VERSION);
const wanted = [];
for (const path of precacheList(html)) if (!(await cache.match(path))) wanted.push(path);
const next = async () => {
for (let path = wanted.shift(); path; path = wanted.shift()) {
try {
const res = await fetch(path, { credentials: "same-origin" });
if (res.ok) await cache.put(path, res);
} catch {
/* offline, or a deploy changing over; the next navigation tries again */
}
}
};
await Promise.all(Array.from({ length: PRECACHE_PARALLEL }, next));
}
/*
* 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: the kept app page at once, and the network's behind it.
*
* Every route in the app is the same page, and waiting on the server for it
* cost a full round trip before anything could start -- the longest single
* wait on a distant link. So a route is answered from the kept copy when
* there is one, and the fresh page is fetched alongside to replace it for
* next time. A page that is a build behind is caught the way it always was:
* the version check reloads it (lib/sw/staleBuild.ts), and the assets it
* names are kept for one more build so it can run until then.
*
* Only the app's own routes (APP_ROUTE). The root, a page in front of the
* app, and a file opened in a tab of its own go to the network as before. So
* does the first visit, which has no copy yet.
*/
if (req.mode === "navigate") {
const network = 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;
});
const appRoute = APP_ROUTE.test(url.pathname.slice(BASE.length));
event.respondWith((async () => {
const kept = appRoute ? await caches.match(SHELL_KEY) : undefined;
if (kept) {
event.waitUntil(network.catch(() => {}));
return kept;
}
return network.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 `<base>/sw.js`, so it stored this under `<base>/…`, 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 () => {
/*
* Someone reading the app already knows. A focused, visible window of this
* app gets its new mail from its own event stream, so a notification on
* top of it is a second telling of the same thing (#375). Chrome does not
* require one while the site is in the foreground.
*/
const windows = await self.clients.matchAll({ type: "window" });
if (windows.some((w) => w.focused && w.visibilityState === "visible")) return;
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 delivery from a server that sends StateChange rather than EmailPush
// -- the subscription asks for `EmailDelivery` only, so it is new mail --
// 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: {
// The route names a conversation, and `m` the message in it.
url: email.id && email.threadId ? `${BASE}/mail/inbox/${email.threadId}?m=${encodeURIComponent(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);
})());
});