Installable webmail & offline reading
Purpose. The webmail installs as an app (its own window, icon and launcher entry) and stays readable when the network does not — on a train, in a lift, or while the server is being upgraded.
Behaviour.
- Install:
public/manifest.webmanifest(display: standalone, maskable icon) makes the browser offer Install. Itsstart_urlandscopeare relative (./), so what gets installed is the mail client and nothing else — until 2026-09-12 the scope was/, and installing the mail app installed one whose scope covered/domains,/usersand/license. Itsidis still/mail/, the path the app was installed from before the webmail had a host of its own; a browser recognises an installed app by thatid, so it is kept rather than tidied to/. Shortcuts jump straight to the Inbox or Settings. Icons are the MailDock mark, rasterised frompackages/web-shared/brand/favicon.svgbypnpm --filter @mailserver/webmail icons(apps/webmail/scripts/make-icons.mjs) and committed — run it after any change to the mark.favicon.svgis the same drawing as the website repo’s, which is the one file that must not drift between the two; the two rasterisers are deliberately separate copies, because the repositories ship separately and a build-time dependency between them would be a new coupling for forty lines. Four files come out of it.icon-192andicon-512are the mark on transparency.icon-maskable-512is inset to the middle 60% on an opaque ground, because Android may crop a maskable icon to any shape inside the safe zone and most launchers composite a transparent one onto black.apple-touch-iconis opaque too, at a 14% inset — iOS does not honour transparency on a home-screen icon and would land the mark on whatever the OS picks. - Service worker (
public/sw.js, registered only in a production build, from this app’s own base so its scope is this app): two versioned caches.shell— the app’s base URL,index.html,manifest.webmanifestandicon-192.png, precached on install, plus the hashedassets/*bundles as they are fetched, so the app boots with no network. Hashed assets are served cache-first (the filename changes when the content does); every other same-originGEToutside/api/is network-first and stored here on success, except a page navigation, which is network-only and falls back to the cachedindex.html— so any page or file the app has fetched, not only the precached four, is in this cache. No API path is in it.mail— successfulGET /api/v1/mail/*responses, newest 400 entries, network-first. Folders, message lists and any message body you have opened stay readable offline — and so do the inline images of any message you have opened, any attachment you have downloaded from the reading pane, and the attachments of any draft you have reopened (up to 25 MB each, the upload limit), all read throughGET /mail/messages/{uid}/parts/{part}. A downloaded attachment is cached at whatever size it is; an inline image only up to the 5 MB the reading pane embeds. All of them count in the 400 entries and are cleared on sign-out with the rest. The same cache holds the pictures of other mailboxes the webmail has shown (GET /mail/avatars/{address}) and the Scheduled list (GET /mail/scheduled), since both areGETs under/mail/, and they are cleared on sign-out with it.
- Nothing else is cached, ever:
/api/v1/auth/*, the/mail/eventsSSE stream, any URL carryingaccess_token, every non-GET request, and all other API paths (the admin surface included) go straight to the network. - Offline session: the refresh cookie cannot be exchanged for an access token without a network, and every route gates on a session. The signed-in user (never a token) is kept in
localStorageso an offline reload still reaches the cached mail; a rejected session — a real 401 — still signs out and clears it. - Offline indicator: the mail header shows an Offline badge from
navigator.onLine. - Writes fail rather than queue. React Query runs in
networkMode: 'offlineFirst'for mutations, so sending, moving or flagging while offline reports an error immediately. The alternative — the default — parks the mutation and replays it when the connection returns, which for a mail client means a message sending itself minutes later from a window nobody is looking at.
Configuration. None. The worker is same-origin and needs no build step; docker/web/Caddyfile already serves everything outside the hashed-asset paths with Cache-Control: no-cache, which is what lets a new sw.js be picked up on the next visit.
Upgrading from a pre-split install. A browser that used the webmail before 2026-09-12 holds a registration at /sw.js scoped to the whole origin, and the upgrade does not replace it — nothing is served at that URL any more, so the browser’s update check gets HTML, fails, and keeps running the old worker in front of both applications. Both apps call dropStaleWorkers() on boot, which unregisters it and empties the caches it filled. Nothing has to be done by hand, and a user who wants to confirm can check that Application → Service workers lists one worker, scoped to the mail client.
Limits & edge cases.
- Only what you have already read is available offline. A message you never opened shows “This message has not been downloaded and you are offline.” Search, sending and folder changes need the server.
navigator.onLinereports the network, not the server. If the machine is online but the server is unreachable, no badge appears and requests fail normally — reads fall back to the cache only when the request genuinely cannot leave the machine.- The mail cache is cleared on sign-out. It is not cleared if the browser is simply closed, so on a shared device mail already read remains readable offline until someone signs out or clears site data. Sign out on a machine that is not yours.
- Cache entries are keyed by URL alone; two accounts on one browser share those keys, which is the other reason sign-out empties the cache.
- Bumping
VERSIONinsw.jsdiscards both caches on the next activation — do that whenever the cached shape changes. - The worker is not registered by
pnpm dev; usepnpm --filter @mailserver/webmail build && pnpm --filter @mailserver/webmail exec vite preview(port 4174, proxying/apilike the dev server) to exercise it locally.