Files
ihasmail-inbuxa/web/public/sw.js
T
jcoffey-dev 82470e8db0 Be somewhere a phone can share to
ihasmail could hand a file to the share sheet as of #306, and was still
not in it. Share a photo from the gallery, a link from the browser or a
document from a file manager and ihasmail was not among the places it
could go, which is the one piece of operating-system integration a mail
app is expected to have.

A share is a POST that navigates, and there is nothing on this side that
can answer one: the app is a client-side router with no endpoint at that
address, and the server behind it would need a route that understood the
composer. So the service worker intercepts it, takes the form body, puts
the files and text in its cache, and redirects to the app -- which finds
them on start and opens a draft holding them. The subject is the shared
title, the text and the link become the body, and files are attached and
begin uploading. Nothing is addressed: a share says what to send, never
who to.

The body is pushed in above the signature rather than passed to open(),
because open() only fits a signature when it is given no body at all --
the obvious version drops the signature from every message that started
as a share, and nothing about the draft looks wrong afterwards.

Collected on every start rather than when the launch URL says so. A share
to a signed-out ihasmail lands on the sign-in page, and there is no
account to attach to until it is done, so the payload has to outlive a
redirect and a login -- which the query string does not. What that costs
is a stash nobody came back for, so it carries a timestamp and expires
after ten minutes.

`accept` names wildcard families and explicit types and extensions both.
A mail client attaches anything, but wildcards are not in the
specification and operating systems differ over which form they match on,
so the explicit list is what holds if the families are ignored.

The cache name the worker and the app have to agree on now has one home
on the app side. It was written out twice, and a drift would not fail --
a push verification would simply never complete and a share would arrive
at an empty composer.

One case is deliberately left to fail loudly: an app still installed
whose worker has been cleared away POSTs to the server, which answers
405. A server route would trade a plain error for a silent nothing, and
the payload is gone in both -- it only ever existed in that request body.

Verified by test, not on a device: Android is the only place this exists
at all, and the extension driving Chrome is not connected here. The
handoff is pinned from the tab's side against a cache shaped exactly as
the worker leaves it, since the two files never see each other.
2026-09-07 22:40:44 -07:00

244 lines
10 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}/`, `${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(() => self.clients.claim())
);
});
/*
* 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.
if (url.pathname.startsWith(`${BASE}/assets/`)) {
event.respondWith(
caches.match(req).then((hit) => hit || fetch(req).then((res) => {
const copy = res.clone();
caches.open(VERSION).then((c) => c.put(req, copy));
return res;
}))
);
return;
}
// Navigations & everything else: network-first, fall back to cached shell.
if (req.mode === "navigate") {
event.respondWith(fetch(req).catch(() => caches.match(`${BASE}/`)));
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. 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 matters, because when this fires
* there may be no session to make one with.
*
* A JMAP subscription also delivers a PushVerification first, and stays silent
* until the client echoes its code back. That cannot be done from here (no
* credentials), so it is stashed for a tab to collect and confirm.
*/
/*
* 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`;
function textOf(email) {
const from = email?.from?.[0];
const who = from?.name || from?.email || "New message";
const what = email?.subject || "(no subject)";
return { title: who, body: what, preview: email?.preview || "" };
}
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 () => {
/*
* 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("New mail", {
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);
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}`,
data: { url: email.id ? `${BASE}/mail/inbox/${email.id}` : `${BASE}/mail` },
});
}
})());
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = event.notification.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);
})());
});