Files
webmail/lib/offline/shell.ts
T
Linus Rath db5ac82f53 feat: keep recent mail on this device and send mail written offline #523
Settings > Backup & reset gets "Keep mail on this device", off until the
user turns it on in this browser. While it is on:

- the JMAP client hands the server's answers to an encrypted cache in
  IndexedDB (AES-GCM, non-extractable key) and answers from it when the
  network is gone: the folder list, the last 30 days of the Inbox and of
  the folders the user opened, bodies and attachments, at most 250 MB,
  oldest out first; a background sync keeps it current
- read, flag, tags and moves made offline are queued and sent in order
  when the server answers again; deleting for good and emptying a folder
  are refused offline
- the composer, quick reply and the S/MIME and PGP plugins' sends that
  complete the composer's go to an outbox once every step up to the
  transport ran (checks, label, signature, plugin send checks); only the
  finished send waits, with files attached offline
- the service worker keeps this build's app shell, so the app also
  starts offline from the kept session and the last policy
- a full sign-out, the idle sign-out and signing one account out delete
  it, a sign-out the user starts asks first when mail is unsent, and the
  login page names what an automatic sign-out deleted; a policy fetch
  that forbids it (devices.noOffline) wipes that account

Not in Lite.
2026-11-01 11:46:11 +01:00

51 lines
1.8 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 { SW_SCOPE } from '@/lib/app-update';
/** The app's pages kept for a start without the network. */
const SHELL_PAGES = ['/', '/mail', '/settings'];
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 };
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 urls = on ? [...SHELL_PAGES.map((p) => withBasePath(p)), ...(await shellFiles())] : [];
worker.postMessage({ type: 'offline-shell', on, urls });
}
/** 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 */
}
}