diff --git a/FEATURES.md b/FEATURES.md index 0d550f7..bf67894 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1414,8 +1414,13 @@ server settings is deliberately out of scope. # Platform - **Installable PWA** with a service worker: the app shell is cached for - installability and fast loads, API requests never are, and navigations are - network-first with the shell as fallback. + installability and fast loads, API requests never are. An app route is + answered from the kept shell at once while a fresh copy is fetched behind it; + a shell a build behind is caught by the version check at start and reloaded. + After a new version is seen, the rest of its code (composer, settings, + viewers) is fetched in the background, so opening them later does not wait on + the server; language catalogs are cached when first used, and nothing is + fetched ahead when the browser is set to save data. - **Manifest shortcuts** for Compose, Calendar and Contacts. - **One window, not one per launch.** A `mailto:` link, a shortcut or a notification opened while ihasmail is already running arrives in the copy @@ -1545,15 +1550,23 @@ costs something to get wrong is the one that assumes the machine is yours. | --- | --- | --- | | Stays signed in | until the browser closes | up to 30 days (`SESSION_REMEMBER_TTL`) | | Idle sign-out | after 5 minutes | none | -| Kept on the computer | nothing | settings cache, recent addresses, username | +| Kept on the computer | nothing | settings cache, recent addresses, username, and the folder list with the first page of recently read folders (list rows only: sender, subject, preview, flags — no message bodies) | | Background notifications | refused | available | | Administration | unavailable | available, if the role allows it | Local storage is gated on that answer for **reads** as well as writes — a machine trusted once still has residue, and honoring it would let a previous session's data surface in a later untrusted one. Signing out clears the settings -cache and recent addresses and tears down the push subscription, whichever -answer was given. +cache, recent addresses and kept folder list, and tears down the push +subscription, whichever answer was given. + +What a ticked device keeps is what makes it **start quickly on a distant +link**: once the server has confirmed the session, the folders and the inbox +paint from the kept copy straight away, and the request for the open folder +goes out without waiting on the folder list first. The server's answers +replace the copy a round trip later. **Nothing kept is shown before the session +is confirmed** — until then the app shows a spinner, so a session that has +ended goes from the spinner to the sign-in form and never past a mailbox. The idle timer exists because the alternative does not work: `beforeunload` text was removed from browsers years ago, and **no event fires at all** for walking diff --git a/web/public/sw.js b/web/public/sw.js index 5d2f8cd..8b31854 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -67,12 +67,17 @@ function assetsNamedIn(html) { return out; } -/** Drop failed responses, and assets the cached app page does not name. */ -async function tidy() { +/** + * Drop failed responses, and assets the cached app page does not name. `also` + * is a page whose assets are kept as well: the one just replaced, which a tab + * opened from the kept copy may still be running. + */ +async function tidy(also = "") { const cache = await caches.open(VERSION); const shell = await cache.match(SHELL_KEY); // Without a page to go by, which assets are current is unknown; keep them. const keep = shell ? assetsNamedIn(await shell.text()) : null; + if (keep) for (const path of assetsNamedIn(also)) keep.add(path); for (const req of await cache.keys()) { const path = new URL(req.url).pathname; if (path.startsWith(ASSETS)) { @@ -91,9 +96,10 @@ async function refreshShell(res) { const html = await res.text(); const cache = await caches.open(VERSION); const prev = await cache.match(SHELL_KEY); - if (!prev || (await prev.text()) !== html) { + const prevHtml = prev ? await prev.text() : ""; + if (prevHtml !== html) { await cache.put(SHELL_KEY, new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } })); - await tidy(); + await tidy(prevHtml); } await precache(html); } @@ -228,15 +234,38 @@ self.addEventListener("fetch", (event) => { return; } - // Navigations & everything else: network-first, fall back to cached shell. + /* + * Navigations: the kept app page at once, and the network's behind it. + * + * Every route in the app is the same page, and waiting on the server for it + * cost a full round trip before anything could start -- the longest single + * wait on a distant link. So a route is answered from the kept copy when + * there is one, and the fresh page is fetched alongside to replace it for + * next time. A page that is a build behind is caught the way it always was: + * the version check reloads it (lib/sw/staleBuild.ts), and the assets it + * names are kept for one more build so it can run until then. + * + * Only app routes. An address ending in a file name -- an image or the + * manifest opened in a tab of its own -- is not the app page, and goes to + * the network as before. So does the first visit, which has no copy yet. + */ if (req.mode === "navigate") { - event.respondWith(fetch(req).then((res) => { + const network = fetch(req).then((res) => { // Every route is the same app page; a fresh one replaces the offline copy. if (res.ok && (res.headers.get("content-type") || "").startsWith("text/html")) { event.waitUntil(refreshShell(res.clone()).catch(() => {})); } return res; - }).catch(() => caches.match(SHELL_KEY))); + }); + const appRoute = !/\.[a-z0-9]+$/i.test(url.pathname); + event.respondWith((async () => { + const kept = appRoute ? await caches.match(SHELL_KEY) : undefined; + if (kept) { + event.waitUntil(network.catch(() => {})); + return kept; + } + return network.catch(() => caches.match(SHELL_KEY)); + })()); return; } event.respondWith(fetch(req).catch(() => caches.match(req))); diff --git a/web/src/lib/sw/staleBuild.ts b/web/src/lib/sw/staleBuild.ts index 34c929c..7d307d0 100644 --- a/web/src/lib/sw/staleBuild.ts +++ b/web/src/lib/sw/staleBuild.ts @@ -1,5 +1,6 @@ import { APP_VERSION } from "../version"; import { withBase } from "../basePath"; +import { SW_CACHE_NAME } from "./swCache"; import { push, type PushState } from "@/jmap/push"; /** @@ -93,10 +94,30 @@ async function check(): Promise { // deploy -- this stops the two of them reloading each other in a loop. if (tried() === serverVersion) return false; remember(serverVersion); + await primeShell(); window.location.reload(); return true; } +/* + * The service worker answers a navigation from its kept copy of the app page + * and refreshes that copy behind it. A reload for a new build must not get the + * old copy back, so the new page is put in place first. Best effort: if this + * fails, the loop guard above still stops a second reload. + */ +async function primeShell(): Promise { + if (!("caches" in window) || !navigator.serviceWorker?.controller) return; + try { + const res = await fetch(withBase("/"), { credentials: "same-origin", cache: "no-store" }); + if (!res.ok || !(res.headers.get("content-type") ?? "").startsWith("text/html")) return; + const html = await res.text(); + const cache = await caches.open(SW_CACHE_NAME); + await cache.put(withBase("/"), new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } })); + } catch { + /* reload anyway */ + } +} + /** * Watch for a deploy without waiting to be asked. * @@ -139,6 +160,10 @@ export function makeConnectionWatcher(): (state: PushState) => void { export function startBuildWatch(): void { push.onConnection(makeConnectionWatcher()); + // A page the service worker answered from its kept copy may be a build + // behind; ask now rather than a minute from now. + if (navigator.serviceWorker?.controller) void reloadIfServerRebuilt(); + window.setInterval(() => { // A hidden tab is not being read, and will be checked when it surfaces. if (document.visibilityState === "visible") void reloadIfServerRebuilt(); diff --git a/web/src/store/__tests__/startup-snapshot.test.ts b/web/src/store/__tests__/startup-snapshot.test.ts new file mode 100644 index 0000000..fdbd3b6 --- /dev/null +++ b/web/src/store/__tests__/startup-snapshot.test.ts @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CAP } from "@/jmap/client"; +import type { JmapSession } from "@/jmap/types"; +import { buildSnapshot, useMail } from "@/store/mail"; +import { useSession } from "@/store/session"; +import { clearSignedInData, setDeviceTrusted } from "@/lib/storage"; + +/** + * Starting from what a trusted device kept: the folder list and the first page + * of recent folders, applied once the server has confirmed the session. On a + * distant link each of those was a round trip before the inbox could show. + */ + +const INBOX = "mbInbox"; +const session = (remember: boolean) => + ({ + capabilities: { [CAP.core]: { maxObjectsInGet: 500 }, [CAP.mail]: {} }, + accounts: { a1: { name: "me", isPersonal: true, isReadOnly: false, accountCapabilities: { [CAP.mail]: {} } } }, + primaryAccounts: { [CAP.mail]: "a1" }, + state: "s", + username: "me", + apiUrl: "", + downloadUrl: "", + uploadUrl: "", + eventSourceUrl: "", + ihasmail: { remember, sessionId: "public-id" }, + }) as unknown as JmapSession; + +const email = (id: string) => ({ + id, + threadId: `t${id}`, + mailboxIds: { [INBOX]: true }, + keywords: {}, + receivedAt: "2026-09-16T00:00:00Z", + subject: `Subject ${id}`, + preview: "p", + bodyValues: { "1": { value: "secret body" } }, +}); + +const inboxQuery = { key: "", filter: { inMailbox: INBOX }, sort: [], collapseThreads: false, mailboxId: INBOX }; + +/** The key the store gives the inbox list, learned by asking for it. */ +function inboxKey(): string { + useMail.getState().setAccount("probe"); + void useMail.getState().query(inboxQuery); + const key = useMail.getState().list!.key; + useMail.getState().setAccount(null); + return key; +} + +function signedInWithList() { + const key = inboxKey(); + useMail.getState().setAccount("a1"); + useMail.setState({ + mailboxes: { [INBOX]: { id: INBOX, role: "inbox", name: "Inbox" } } as never, + mailboxesLoaded: true, + emails: { e1: email("e1"), e2: email("e2") } as never, + list: { key, filter: { inMailbox: INBOX }, sort: [], collapseThreads: false, mailboxId: INBOX, ids: ["e1", "e2"], total: 7, queryState: "q", loading: false, loadingMore: false, error: null, exhausted: false }, + }); +} + +let pending: ((v: Response) => void) | null; + +beforeEach(() => { + localStorage.clear(); + vi.useFakeTimers(); + pending = null; + // The session request stays unanswered unless a test answers it. + vi.stubGlobal("fetch", vi.fn(() => new Promise((resolve) => { pending = resolve; }))); + useMail.getState().setAccount(null); + useSession.setState({ status: "loading", session: null, accountId: null }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + setDeviceTrusted(false); + localStorage.clear(); +}); + +describe("the kept mail snapshot", () => { + it("holds folders and list rows without bodies", () => { + signedInWithList(); + const snap = buildSnapshot(useMail.getState())!; + expect(snap.mailboxes.map((m) => m.id)).toEqual([INBOX]); + expect(snap.lists).toEqual([{ key: inboxKey(), ids: ["e1", "e2"], total: 7 }]); + expect(snap.emails.map((e) => e.id)).toEqual(["e1", "e2"]); + expect(JSON.stringify(snap)).not.toContain("secret body"); + }); + + it("is not made before the server's folder list has arrived", () => { + signedInWithList(); + useMail.setState({ mailboxesLoaded: false }); + expect(buildSnapshot(useMail.getState())).toBeNull(); + }); + + it("paints the folders and the inbox on the next start of a trusted device", () => { + setDeviceTrusted(true); + useSession.setState({ status: "authenticated", accountId: "a1" }); + signedInWithList(); + // A change to the list schedules the save. + useMail.setState((s) => ({ list: { ...s.list!, total: 8 } })); + vi.advanceTimersByTime(3500); + expect(localStorage.getItem("ihasmail:mail-snapshot")).toContain('"e1"'); + + // Next start. + useMail.getState().setAccount(null); + useMail.getState().setAccount("a1"); + const s = useMail.getState(); + expect(s.mailboxesCached).toBe(true); + expect(s.mailboxesLoaded).toBe(false); + expect(s.mailboxes[INBOX]?.name).toBe("Inbox"); + void s.query(inboxQuery); + expect(useMail.getState().list).toMatchObject({ ids: ["e1", "e2"], total: 8, loading: true }); + }); + + it("is neither written nor read on a device not marked as the reader's own", () => { + setDeviceTrusted(true); + useSession.setState({ status: "authenticated", accountId: "a1" }); + signedInWithList(); + useMail.setState((s) => ({ list: { ...s.list!, total: 8 } })); + vi.advanceTimersByTime(3500); + setDeviceTrusted(false); + useMail.getState().setAccount(null); + useMail.getState().setAccount("a1"); + expect(useMail.getState().mailboxesCached).toBe(false); + expect(useMail.getState().mailboxes).toEqual({}); + + localStorage.clear(); + useSession.setState({ status: "authenticated", accountId: "a1" }); + signedInWithList(); + useMail.setState((s) => ({ list: { ...s.list!, total: 9 } })); + vi.advanceTimersByTime(3500); + expect(localStorage.getItem("ihasmail:mail-snapshot")).toBeNull(); + }); + + it("is gone after signing out, and a save scheduled before it does not bring it back", () => { + setDeviceTrusted(true); + useSession.setState({ status: "authenticated", accountId: "a1" }); + signedInWithList(); + useMail.setState((s) => ({ list: { ...s.list!, total: 8 } })); + vi.advanceTimersByTime(3500); + useMail.setState((s) => ({ list: { ...s.list!, total: 9 } })); + clearSignedInData(); + useSession.setState({ status: "anonymous", accountId: null }); + vi.advanceTimersByTime(3500); + expect(localStorage.getItem("ihasmail:mail-snapshot")).toBeNull(); + }); + + it("belongs to one account", () => { + setDeviceTrusted(true); + useSession.setState({ status: "authenticated", accountId: "a1" }); + signedInWithList(); + useMail.setState((s) => ({ list: { ...s.list!, total: 8 } })); + vi.advanceTimersByTime(3500); + useMail.getState().setAccount("a2"); + expect(useMail.getState().mailboxesCached).toBe(false); + expect(useMail.getState().emails).toEqual({}); + }); +}); + +describe("before the server has confirmed the session", () => { + it("shows nothing kept: the spinner stays until the answer, and the kept folders arrive with it", async () => { + setDeviceTrusted(true); + useSession.setState({ status: "authenticated", accountId: "a1" }); + signedInWithList(); + useMail.setState((s) => ({ list: { ...s.list!, total: 8 } })); + vi.advanceTimersByTime(3500); + // Next start. + useMail.getState().setAccount(null); + useSession.setState({ status: "loading", session: null, accountId: null }); + + const boot = useSession.getState().bootstrap(); + expect(useSession.getState().status).toBe("loading"); + expect(useMail.getState().accountId).toBeNull(); + expect(useMail.getState().mailboxes).toEqual({}); + expect(useMail.getState().emails).toEqual({}); + + pending!({ ok: true, status: 200, json: async () => session(true) } as Response); + await boot; + expect(useSession.getState().status).toBe("authenticated"); + expect(useMail.getState().mailboxesCached).toBe(true); + expect(useMail.getState().mailboxes[INBOX]?.name).toBe("Inbox"); + }); + + it("keeps no session of its own", async () => { + setDeviceTrusted(true); + const boot = useSession.getState().bootstrap(); + pending!({ ok: true, status: 200, json: async () => session(true) } as Response); + await boot; + expect(localStorage.getItem("ihasmail:session")).toBeNull(); + }); +}); diff --git a/web/src/store/mail/index.ts b/web/src/store/mail/index.ts index 1741959..78ace03 100644 --- a/web/src/store/mail/index.ts +++ b/web/src/store/mail/index.ts @@ -27,6 +27,7 @@ import { useSession } from "../session"; import { mailboxDisplayName } from "@/lib/mailbox/mailboxName"; import { plural, t } from "@/lib/i18n"; import { withBase } from "@/lib/basePath"; +import { isDeviceTrusted, loadRaw, saveJson } from "@/lib/storage"; import { MAILBOX_PROPS, LIST_PROPS, FULL_PROPS, BODY_PROPS } from "./props"; import { type ListQuery, type MailState } from "./types"; import { playNewMailSound, showNotification } from "@/lib/notify/notify"; @@ -79,6 +80,7 @@ export const useMail = create((set, get) => ({ mailboxes: {}, mailboxState: null, mailboxesLoaded: false, + mailboxesCached: false, emails: {}, fullIds: {}, emailState: null, @@ -108,6 +110,7 @@ export const useMail = create((set, get) => ({ mailboxes: {}, mailboxState: null, mailboxesLoaded: false, + mailboxesCached: false, emails: {}, fullIds: {}, emailState: null, @@ -121,6 +124,7 @@ export const useMail = create((set, get) => ({ anchorId: null, lastSeenInboxEmailIds: null, }); + if (accountId) restoreSnapshot(accountId); }, async loadMailboxes() { @@ -129,7 +133,7 @@ export const useMail = create((set, get) => ({ const res = await client.call>("Mailbox/get", { accountId, ids: null, properties: MAILBOX_PROPS }); const mailboxes: Record = {}; for (const m of res.list) mailboxes[m.id] = m; - set({ mailboxes, mailboxState: res.state, mailboxesLoaded: true }); + set({ mailboxes, mailboxState: res.state, mailboxesLoaded: true, mailboxesCached: false }); // Label counts move for the same reasons folder counts do -- something was // read, moved or deleted -- so they are refreshed on the same beat rather // than on a timer of their own. Not awaited: the folder tree should not @@ -1247,6 +1251,83 @@ function snapshotFor(key: string, filter: EmailFilter, emails: Record return { ids, total: snap.total - (snap.ids.length - ids.length) }; } +/* + * What a device marked as the reader's own keeps between visits. + * + * Opening the app used to wait on the folder list before it could ask for a + * folder, and on that before anything showed: on a distant link, a second or + * so of skeleton on every start. A trusted device now keeps the folder list + * and the first page of the last few folders, list properties only -- no + * bodies -- and starts from them: the folders and the inbox paint as soon as + * the server has confirmed the session, and the query for the open folder goes + * out then without waiting for the folder list, which corrects both a round + * trip later. + * + * Never sooner. This is applied from `setAccount`, which runs only once the + * session is confirmed, so a session that has ended shows the spinner and then + * the sign-in form, and none of this in between. + * + * It is written through the same gated storage as the settings cache: nothing + * is kept on a device not marked as the reader's own, nothing is read there + * either, and signing out clears it with everything else. + */ +const SNAPSHOT_KEY = "mail-snapshot"; +const SNAPSHOT_LISTS = 4; +const SNAPSHOT_ROWS = 50; +const SNAPSHOT_MAX_CHARS = 400_000; + +export interface MailSnapshot { + v: 1; + accountId: Id; + mailboxes: Mailbox[]; + lists: { key: string; ids: Id[]; total: number }[]; + emails: Email[]; +} + +export function buildSnapshot(s: MailState): MailSnapshot | null { + if (!s.accountId || !s.mailboxesLoaded) return null; + const lists: MailSnapshot["lists"] = []; + const cur = s.list; + if (cur && !cur.loading && !cur.error && folderOf(cur.filter)) lists.push({ key: cur.key, ids: cur.ids, total: cur.total }); + for (const [key, snap] of [...snapshots].reverse()) { + if (lists.length >= SNAPSHOT_LISTS) break; + if (!lists.some((l) => l.key === key)) lists.push({ key, ...snap }); + } + const ids = new Set(); + const kept = lists.map((l) => { + const rows = l.ids.filter((id) => s.emails[id]).slice(0, SNAPSHOT_ROWS); + rows.forEach((id) => ids.add(id)); + return { key: l.key, ids: rows, total: l.total }; + }); + const emails = [...ids].map((id) => Object.fromEntries(Object.entries(s.emails[id]!).filter(([k]) => LIST_KEYS.has(k))) as unknown as Email); + return { v: 1, accountId: s.accountId, mailboxes: Object.values(s.mailboxes), lists: kept, emails }; +} + +let snapshotTimer: ReturnType | null = null; + +function saveSnapshot(): void { + if (snapshotTimer) clearTimeout(snapshotTimer); + snapshotTimer = null; + if (!isDeviceTrusted() || useSession.getState().status !== "authenticated") return; + const s = useMail.getState(); + if (s.accountId !== useSession.getState().accountId) return; + const snap = buildSnapshot(s); + if (!snap) return; + if (JSON.stringify(snap).length > SNAPSHOT_MAX_CHARS) return; + saveJson(SNAPSHOT_KEY, snap); +} + +function restoreSnapshot(accountId: Id): void { + const snap = loadRaw(SNAPSHOT_KEY, null); + if (!snap || snap.v !== 1 || snap.accountId !== accountId || !Array.isArray(snap.mailboxes) || !snap.mailboxes.length) return; + const mailboxes: Record = {}; + for (const m of snap.mailboxes) mailboxes[m.id] = m; + const emails: Record = {}; + for (const e of snap.emails ?? []) emails[e.id] = e; + for (const l of snap.lists ?? []) snapshots.set(l.key, { ids: l.ids, total: l.total }); + useMail.setState({ mailboxes, mailboxesCached: true, emails }); +} + let sortRefused = false; async function runQuery(accountId: Id, q: ListQuery, position: number, limit: number) { @@ -1513,3 +1594,19 @@ async function followFolders(before: FolderRef[]): Promise { toast.error(t("Folder changed, but its filter rules could not be updated: {error}", { error: (err as Error).message })); } } + +/* + * Kept a few seconds after the folders or the list last changed, and when the + * page is being put away, which is the last chance a closing tab gets. + */ +useMail.subscribe((s, prev) => { + if (s.mailboxes === prev.mailboxes && s.list === prev.list) return; + if (!isDeviceTrusted()) return; + if (snapshotTimer) clearTimeout(snapshotTimer); + snapshotTimer = setTimeout(saveSnapshot, 3000); +}); +if (typeof window !== "undefined") { + window.addEventListener("pagehide", () => { + if (snapshotTimer) saveSnapshot(); + }); +} diff --git a/web/src/store/mail/types.ts b/web/src/store/mail/types.ts index 2bbca7a..1121d57 100644 --- a/web/src/store/mail/types.ts +++ b/web/src/store/mail/types.ts @@ -36,6 +36,8 @@ export interface MailState { mailboxes: Record; mailboxState: string | null; mailboxesLoaded: boolean; + /** The folder list shown is the copy this device kept, not yet confirmed by the server. */ + mailboxesCached: boolean; emails: Record; fullIds: Record; emailState: string | null; diff --git a/web/src/views/mail/MailView.tsx b/web/src/views/mail/MailView.tsx index 8fbb21c..6d5ef47 100644 --- a/web/src/views/mail/MailView.tsx +++ b/web/src/views/mail/MailView.tsx @@ -27,6 +27,9 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; const searchStr = useSearch(); const mailboxes = useMail((s) => s.mailboxes); const mailboxesLoaded = useMail((s) => s.mailboxesLoaded); + // Enough to ask for a folder: the kept copy of the folder list will do, and + // staying true as the server's copy replaces it keeps the query from repeating. + const mailboxesKnown = useMail((s) => s.mailboxesLoaded || s.mailboxesCached); const inboxId = useMail((s) => s.roleId("inbox")); const query = useMail((s) => s.query); const list = useMail((s) => s.list); @@ -114,8 +117,8 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; }, [search, q, mailboxId, folderShape, settings.conversationMode, scheduledId]); useEffect(() => { - if (listQuery && mailboxesLoaded) void query(listQuery); - }, [listQuery, query, mailboxesLoaded]); + if (listQuery && mailboxesKnown) void query(listQuery); + }, [listQuery, query, mailboxesKnown]); // Nothing moves a message out of Scheduled when its hold expires, so settle // the folder up on the way in: sent messages to Sent, canceled ones back to diff --git a/web/src/views/mail/MailboxTree.tsx b/web/src/views/mail/MailboxTree.tsx index 222a139..4e4b1ce 100644 --- a/web/src/views/mail/MailboxTree.tsx +++ b/web/src/views/mail/MailboxTree.tsx @@ -38,7 +38,7 @@ const FOLDER_MIME = "application/x-ihasmail-folder"; export function MailboxTree() { const mailboxes = useMail((s) => s.mailboxes); - const loaded = useMail((s) => s.mailboxesLoaded); + const loaded = useMail((s) => s.mailboxesLoaded || s.mailboxesCached); const [location] = useLocation(); const currentId = location.startsWith("/mail/") ? location.split("/")[2] : undefined; const showHidden = useSettings((s) => s.settings.showHiddenFolders);