Skip to content

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. Its start_url and scope are 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, /users and /license. Its id is 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 that id, so it is kept rather than tidied to /. Shortcuts jump straight to the Inbox or Settings. Icons are the MailDock mark, rasterised from packages/web-shared/brand/favicon.svg by pnpm --filter @mailserver/webmail icons (apps/webmail/scripts/make-icons.mjs) and committed — run it after any change to the mark. favicon.svg is 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-192 and icon-512 are the mark on transparency. icon-maskable-512 is 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-icon is 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.webmanifest and icon-192.png, precached on install, plus the hashed assets/* 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-origin GET outside /api/ is network-first and stored here on success, except a page navigation, which is network-only and falls back to the cached index.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 — successful GET /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 through GET /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 are GETs under /mail/, and they are cleared on sign-out with it.
  • Nothing else is cached, ever: /api/v1/auth/*, the /mail/events SSE stream, any URL carrying access_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 localStorage so 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.onLine reports 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 VERSION in sw.js discards both caches on the next activation — do that whenever the cached shape changes.
  • The worker is not registered by pnpm dev; use pnpm --filter @mailserver/webmail build && pnpm --filter @mailserver/webmail exec vite preview (port 4174, proxying /api like the dev server) to exercise it locally.