diff --git a/docker/nginx-app.conf b/docker/nginx-app.conf index 6c2f962..d44eccd 100644 --- a/docker/nginx-app.conf +++ b/docker/nginx-app.conf @@ -76,3 +76,10 @@ location = /healthz { location = /index.html { add_header Cache-Control "no-store, must-revalidate"; } + +# The offline-access service worker (frontend/public/sw.js): a fixed URL the +# browser re-checks for a new version, so it must not be cached either. +location = /sw.js { + add_header Cache-Control "no-cache"; + try_files $uri =404; +} diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..8fca783 --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,409 @@ +// Offline access (Settings → Security → "Keep recent mail on this device"). +// +// 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. +// +// 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 +// the page (sign-out, or the setting turned off). +// +// Plain JavaScript, served as is from /sw.js: a service worker must live at a +// fixed URL, so it cannot be a hashed Vite chunk. + +const SHELL = "wm-shell-v1"; +const DATA = "wm-data-v1"; +const INDEX_KEY = "/__wm/offline-index"; +const APP_KEY = "/__wm/app"; + +const MAX_THREADS = 80; // the newest 50 Inbox conversations + recently opened ones +const MAX_ORPHANS = 50; // message parts whose conversation was never saved +const MAX_LISTS = 40; // thread lists (one per folder / filter / page) +const MAX_AVATARS = 300; + +// How long to wait for the server before falling back to the saved copy. Long +// enough that a slow connection still gets fresh data; a missing one fails at +// once anyway. Once a request has failed we know we are offline, and serve the +// saved copy straight away until a request succeeds again. +const API_TIMEOUT = 15000; +const NAV_TIMEOUT = 8000; +const RETRY_TIMEOUT = 4000; + +const META = new Set([ + "/api/auth/me", + "/api/accounts", + "/api/mailboxes", + "/api/mailboxes/counts", + "/api/settings", + "/api/settings/quota", + "/api/saved-searches", + "/api/plugins/ui-manifest", +]); +const ID = "[0-9a-fA-F-]{36}"; +const THREAD_RE = new RegExp(`^/api/threads/(${ID})$`); +const MESSAGE_RE = new RegExp(`^/api/messages/(${ID})/(?:html|attachments|cid/.+)$`); + +/** What a request is, for saving it; null = never saved (search, downloads, …). */ +function classify(url) { + const p = url.pathname; + if (META.has(p)) return { kind: "meta" }; + if (p === "/api/threads") return { kind: "list" }; + if (p === "/api/avatar") return { kind: "avatar" }; + let m = p.match(THREAD_RE); + if (m) return { kind: "thread", id: m[1] }; + m = p.match(MESSAGE_RE); + if (m) return { kind: "message", id: m[1] }; + return null; +} + +let offline = false; + +// ---- the index: what is saved, and when it was last fetched ------------------ + +const emptyIndex = () => ({ user: null, threads: {}, msgs: {}, lists: {}, avatars: {} }); +let index = null; +let chain = Promise.resolve(); + +/** Run `fn` on the index, one at a time (fetches finish concurrently). */ +function withIndex(fn) { + const run = chain.then(async () => { + if (!index) { + try { + const hit = await (await caches.open(DATA)).match(INDEX_KEY); + index = hit ? await hit.json() : emptyIndex(); + } catch { + index = emptyIndex(); + } + } + const changed = await fn(index); + if (changed !== false) { + const cache = await caches.open(DATA); + await cache.put( + INDEX_KEY, + new Response(JSON.stringify(index), { headers: { "Content-Type": "application/json" } }), + ); + } + }); + chain = run.catch(() => {}); + return run; +} + +/** Forget everything saved (signed out, another user, setting turned off). */ +function purge() { + const run = chain.then(async () => { + index = emptyIndex(); + await caches.delete(DATA); + }); + chain = run.catch(() => {}); + return run; +} + +function oldestFirst(record) { + return Object.entries(record).sort((a, b) => a[1] - b[1]); +} + +/** Record a saved response and drop whatever no longer fits. */ +async function record(ix, url, cls, body) { + const now = Date.now(); + if (cls.kind === "thread") { + let ids = []; + try { + ids = JSON.parse(new TextDecoder().decode(body)).messages.map((m) => m.id); + } catch { + /* not the shape we know: keep the thread, without its messages */ + } + ix.threads[cls.id] = { at: now, msgs: ids }; + for (const id of ids) ix.msgs[id] = { t: cls.id, at: now }; + } else if (cls.kind === "message") { + ix.msgs[cls.id] = { t: ix.msgs[cls.id]?.t ?? null, at: now }; + } else if (cls.kind === "list") { + ix.lists[url.pathname + url.search] = now; + } else if (cls.kind === "avatar") { + ix.avatars[url.pathname + url.search] = now; + } + return trim(ix); +} + +async function trim(ix) { + const dropThreads = new Set(); + const dropMsgs = new Set(); + const dropUrls = new Set(); + + const threads = Object.entries(ix.threads).sort((a, b) => b[1].at - a[1].at); + for (const [id] of threads.slice(MAX_THREADS)) dropThreads.add(id); + const orphans = []; + for (const [id, m] of Object.entries(ix.msgs)) { + if (m.t && (dropThreads.has(m.t) || !ix.threads[m.t])) dropMsgs.add(id); + else if (!m.t) orphans.push([id, m.at]); + } + orphans.sort((a, b) => b[1] - a[1]); + for (const [id] of orphans.slice(MAX_ORPHANS)) dropMsgs.add(id); + for (const [saved, max] of [ + [ix.lists, MAX_LISTS], + [ix.avatars, MAX_AVATARS], + ]) { + const all = oldestFirst(saved); + for (const [u] of all.slice(0, Math.max(0, all.length - max))) { + dropUrls.add(u); + delete saved[u]; + } + } + if (!dropThreads.size && !dropMsgs.size && !dropUrls.size) return; + + for (const id of dropThreads) delete ix.threads[id]; + for (const id of dropMsgs) delete ix.msgs[id]; + const cache = await caches.open(DATA); + for (const req of await cache.keys()) { + const u = new URL(req.url); + const p = u.pathname; + const t = p.match(THREAD_RE); + const m = p.match(MESSAGE_RE); + if ( + dropUrls.has(p + u.search) || + (t && dropThreads.has(t[1])) || + (m && dropMsgs.has(m[1])) + ) { + await cache.delete(req); + } + } +} + +// ---- saving and serving ------------------------------------------------------ + +/** Save a copy of a server answer (`res` is a clone the page never reads). */ +async function save(req, res, cls) { + const url = new URL(req.url); + const body = await res.arrayBuffer(); + if (cls.kind === "meta" && url.pathname === "/api/auth/me") { + // Another user signed in on this browser: the saved mail is not theirs. + let id = null; + try { + id = JSON.parse(new TextDecoder().decode(body)).id ?? null; + } catch { + /* ignore */ + } + await withIndex(async (ix) => { + if (ix.user === id) return false; + if (ix.user && id) { + Object.assign(ix, emptyIndex()); + await caches.delete(DATA); + } + ix.user = id; + }); + } + const headers = new Headers(res.headers); + headers.set("X-WM-Saved-At", String(Date.now())); + const cache = await caches.open(DATA); + await cache.put(url.href, new Response(body, { status: 200, headers })); + if (cls.kind !== "meta") await withIndex((ix) => record(ix, url, cls, body)); +} + +/** The saved copy for a request; conversations also match with other options. */ +async function lookup(req, cls) { + const cache = await caches.open(DATA); + const hit = await cache.match(req.url, { ignoreVary: true }); + if (hit || (cls.kind !== "thread" && cls.kind !== "message")) return hit; + // The same conversation opened from another folder or in the other order, or + // a body asked for with/without remote images: still the same mail. + return cache.match(req.url, { ignoreVary: true, ignoreSearch: true }); +} + +function fromSaved(hit) { + const headers = new Headers(hit.headers); + headers.set("X-WM-Offline", "1"); + return new Response(hit.body, { status: hit.status, statusText: hit.statusText, headers }); +} + +function notSaved() { + return new Response( + JSON.stringify({ error: { code: "offline", message: "Not available offline" } }), + { status: 503, headers: { "Content-Type": "application/json", "X-WM-Offline": "1" } }, + ); +} + +async function network(req, ms) { + if (req.mode === "navigate") { + // A page load cannot be re-issued with a signal: just stop waiting for it. + let timer; + const late = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("timeout")), ms); + }); + try { + return await Promise.race([fetch(req), late]); + } finally { + clearTimeout(timer); + } + } + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), ms); + try { + return await fetch(req, { signal: ctrl.signal }); + } finally { + clearTimeout(timer); + } +} + +async function fromServer(event, req, cls, ms) { + const res = await network(req, ms); + offline = false; + if (res.status === 401) event.waitUntil(purge()); + // Copied now, before the page starts reading the original. + else if (cls && res.status === 200) event.waitUntil(save(req, res.clone(), cls).catch(() => {})); + return res; +} + +async function apiData(event, req, cls) { + if (offline) { + const hit = await lookup(req, cls); + if (hit) return fromSaved(hit); + // Not saved — and perhaps the connection is back: one quick try. + try { + return await fromServer(event, req, cls, RETRY_TIMEOUT); + } catch { + return notSaved(); + } + } + try { + return await fromServer(event, req, cls, API_TIMEOUT); + } catch { + offline = true; + const hit = await lookup(req, cls); + return hit ? fromSaved(hit) : notSaved(); + } +} + +/** Every other API request: straight to the server, noting whether it answered. */ +async function apiOther(event, req) { + try { + const res = await fetch(req); + offline = false; + if (res.status === 401) event.waitUntil(purge()); + return res; + } catch { + offline = true; + return notSaved(); + } +} + +// ---- the app itself ---------------------------------------------------------- + +/** The files an app page loads up front (its scripts, styles and icons). */ +function referencedAssets(html) { + const out = new Set(); + for (const m of html.matchAll(/(?:src|href)="(\/[^"]+\.(?:js|css|svg|png|ico|woff2?))"/g)) { + out.add(m[1]); + } + return [...out]; +} + +/** Keep the app page, and everything it loads, for opening without a connection. */ +async function saveApp(res) { + const html = await res.text(); + const cache = await caches.open(SHELL); + const previous = await cache.match(APP_KEY); + if (previous && (await previous.text()) === html) return; + await cache.put(APP_KEY, new Response(html, { headers: { "Content-Type": "text/html" } })); + const wanted = referencedAssets(html); + // A new release: its files have new names, so the old ones can go. Files + // loaded later (lazy chunks) are saved again the first time they are used. + for (const req of await cache.keys()) { + const p = new URL(req.url).pathname; + if (p.startsWith("/assets/") && !wanted.includes(p)) await cache.delete(req); + } + await Promise.all( + wanted.map(async (p) => { + if (await cache.match(p)) return; + try { + const r = await fetch(p); + if (r.ok) await cache.put(p, r); + } catch { + /* next time */ + } + }), + ); +} + +async function appPage(event, req) { + try { + const res = await network(req, offline ? RETRY_TIMEOUT : NAV_TIMEOUT); + offline = false; + if (res.ok && (res.headers.get("Content-Type") || "").includes("text/html")) { + event.waitUntil(saveApp(res.clone()).catch(() => {})); + } + return res; + } catch { + offline = true; + const hit = await (await caches.open(SHELL)).match(APP_KEY); + return hit || Response.error(); + } +} + +/** Hashed build files never change, so a saved copy is always right. */ +async function staticFile(req) { + const cache = await caches.open(SHELL); + const hit = await cache.match(req, { ignoreVary: true }); + if (hit) return hit; + const res = await fetch(req); + if (res.ok) await cache.put(req, res.clone()); + return res; +} + +const STATIC_RE = /^(?:\/assets\/|\/fonts\/|\/[^/]+\.(?:svg|png|ico)$)/; + +function isAppPage(url) { + const p = url.pathname; + return !p.startsWith("/api/") && !p.startsWith("/admin") && !(p.endsWith(".html") && p !== "/index.html"); +} + +self.addEventListener("fetch", (event) => { + const req = event.request; + if (req.method !== "GET") return; + const url = new URL(req.url); + if (url.origin !== self.location.origin) return; + if (req.mode === "navigate") { + if (isAppPage(url)) event.respondWith(appPage(event, req)); + return; + } + if (url.pathname.startsWith("/api/")) { + if (url.pathname === "/api/ws") return; + const cls = classify(url); + event.respondWith(cls ? apiData(event, req, cls) : apiOther(event, req)); + return; + } + if (STATIC_RE.test(url.pathname)) event.respondWith(staticFile(req)); +}); + +self.addEventListener("message", (event) => { + if (event.data?.type === "purge") event.waitUntil(purge()); +}); + +self.addEventListener("install", (event) => { + // Save the app straight away, so it opens offline even if the page that + // 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 */ + } + await self.skipWaiting(); + })(), + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + (async () => { + for (const name of await caches.keys()) { + if (name.startsWith("wm-") && name !== SHELL && name !== DATA) await caches.delete(name); + } + await self.clients.claim(); + })(), + ); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7ac4235..2f6140d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,9 @@ import { useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; import { Navigate, Route, Routes, useLocation } from "react-router-dom"; +import { OfflineController } from "./components/Offline"; +import { useT } from "./i18n"; +import { isOfflineError } from "./lib/api"; import { useMe } from "./lib/auth"; import { connectWs } from "./lib/ws"; import { PluginPage } from "./plugins/PluginPage"; @@ -14,7 +17,8 @@ import { ResetPassword } from "./routes/ResetPassword"; import { Settings } from "./routes/settings/Settings"; function RequireAuth({ children }: { children: JSX.Element }) { - const { data, isLoading, isError } = useMe(); + const { data, isLoading, isError, error, refetch } = useMe(); + const t = useT(); const location = useLocation(); const queryClient = useQueryClient(); @@ -26,10 +30,29 @@ function RequireAuth({ children }: { children: JSX.Element }) { if (isLoading) { return