Files
webmail/lib/offline/shell.ts
T
Linus Rath 4b60fcc1e6 fix: make the offline outbox never send twice, never lose mail silently and never skip a plugin #523
The review of offline mode found ways to send a message twice, to lose
unsent mail without a word, and to send mail without its signing,
encryption or check plugin. This fixes them and the smaller findings:

- each outbox message is taken for sending in one database transaction
  under a Web Lock per account; only a failure before the submission
  request (SendNotStartedError from the client) puts it back, any other
  one marks it "may have gone out"; read-backs after the submission no
  longer fail a send; a send of a closed tab turns uncertain after a minute
- every sign-out the user didn't confirm names the deleted messages on
  the sign-in page, or in a toast when another account stays signed in
- nothing is queued while an enabled plugin isn't running (after a start
  without the network none can); the composer says why
- the outbox waits for a fresh policy and holds messages the rules have
  since made stricter (a required label, a required send check)
- another tab switching offline mode off sticks: only switching it on
  may create the database
- only attachments of kept messages are kept, filed with their message,
  deleted with it and under the cap between syncs; file names and types
  are sealed; folders that went away leave the scope, and a folder that
  couldn't be asked keeps its mail without holding the others back
- a group scope that forbids offline mode waits until the account's
  groups are known; one server down no longer holds another's mail
- an offline start only when the server is really unreachable, and the
  real sign-in follows by itself once it answers
- the service worker files pages and chunks under the build that served
  them, and the shell list of another build isn't kept under this one
- a raw send counts as the composer's only from the plugin running its
  takeover, for that identity
- errors and refusals are in all languages; the banner tells "offline"
  from "server can't be reached"; outbox buttons name their message;
  changes and messages go in the order they were made
2026-11-01 14:19:54 +01:00

60 lines
2.2 KiB
TypeScript

/**
* The app shell for offline mode (#523): tells the service worker
* (public/sw.js) to keep this build's pages and files while any account
* keeps mail on this device, and to drop them when none does.
*/
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
import { currentBuildId, SW_SCOPE } from '@/lib/app-update';
/** The app's pages kept for a start without the network. */
const SHELL_PAGES = ['/', '/mail', '/settings'];
/**
* The files of this tab's build. After a deploy the server lists the new
* build's files: nothing then, rather than another build's files under this
* build's name.
*/
export async function shellFiles(): Promise<string[]> {
try {
const res = await apiFetch('/api/offline/shell', { cache: 'no-store' });
if (!res.ok) return [];
const data = (await res.json()) as { files?: unknown; buildId?: unknown };
if (data.buildId !== currentBuildId()) return [];
return Array.isArray(data.files) ? data.files.filter((f): f is string => typeof f === 'string') : [];
} catch {
return [];
}
}
async function activeWorker(): Promise<ServiceWorker | null> {
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) return null;
try {
const registration = await navigator.serviceWorker.getRegistration(SW_SCOPE);
return registration?.active ?? navigator.serviceWorker.controller ?? null;
} catch {
return null;
}
}
export async function tellServiceWorker(on: boolean): Promise<void> {
const worker = await activeWorker();
if (!worker) return;
const files = on ? await shellFiles() : [];
// Without this build's files (a deploy happened), not its pages either:
// they would be the new build's.
const urls = on && files.length > 0 ? [...SHELL_PAGES.map((p) => withBasePath(p)), ...files] : [];
worker.postMessage({ type: 'offline-shell', on, urls, buildId: currentBuildId() });
}
/** The service worker's copies of the shell, from the page (also when no worker runs). */
export async function deleteShellCaches(): Promise<void> {
if (typeof caches === 'undefined') return;
try {
const keys = await caches.keys();
await Promise.all(keys.filter((key) => key.startsWith('bulwark-shell-')).map((key) => caches.delete(key)));
} catch {
/* no Cache API */
}
}