Installable app, push notifications about new mail, Install app entry

The webmail is now a PWA: web app manifest, icons (any + maskable, a status-bar
badge), theme-coloured title bar following the chosen theme, and an app-icon
badge with the unread Inbox count. The service worker is always installed;
offline access is on only when it is registered as /sw.js?offline=1, so
turning it on or off is an update that applies at once.

Notifications (Settings → Accounts → Notifications, per device) use Web Push.
Encryption (RFC 8291) and VAPID signing (RFC 8292) are implemented in
app/push/webpush.py — pywebpush needs a newer cryptography than we pin — and
checked against the RFC's worked example. The server makes its key pair on
first use (private half encrypted with MASTER_KEY). New mail found by the live
sync in an Inbox enqueues a job 5 s later that tells the user's devices about
messages still unread there, under 2 hours old and not from the user; more
than three become one "N new messages". A subscription belongs to the session
it was made under: signed out or revoked, nothing more is sent there. Endpoints
are limited to the known push services (no SSRF). Nothing shows while the app
is in front (except on Apple, which requires it). Tested with real pushes
through Google's service in Chromium and Opera.

The mobile drawer gets "Install app": Chrome's own install dialog where the
browser offers one, else short steps for iPhone/iPad, Opera and Firefox, with
"Don't show this again". docs/nas-install.md §5.3 covers installing,
notifications, Public URL and a later Android app (TWA).

Migration 0022: push_subscriptions, app_settings.vapid_*.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 18:52:26 +02:00
co-authored by Claude Opus 5.5
parent 0729dbb691
commit fda4cc6f16
37 changed files with 1765 additions and 42 deletions
+103 -16
View File
@@ -1,12 +1,18 @@
// Offline access (Settings → Accounts → "Offline access").
// The app's service worker: notifications about new mail (always), and offline
// access (Settings → Accounts → "Offline access") on devices where it is on.
//
// 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.
// 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
@@ -18,7 +24,9 @@
// 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 = 3;
const VERSION = 4;
const OFFLINE = new URL(self.location.href).searchParams.get("offline") === "1";
const SHELL = "wm-shell-v1";
const DATA = "wm-data-v1";
@@ -413,6 +421,7 @@ function isAppPage(url) {
}
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);
@@ -454,7 +463,9 @@ async function fetchForPage(url, port) {
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 });
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]));
}
@@ -465,11 +476,13 @@ self.addEventListener("install", (event) => {
// 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 */
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();
})(),
@@ -479,10 +492,84 @@ self.addEventListener("install", (event) => {
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-") && name !== SHELL && name !== DATA) await caches.delete(name);
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(() => {}),
);
});