diff --git a/web/src/lib/download.ts b/web/src/lib/download.ts new file mode 100644 index 0000000..a813daf --- /dev/null +++ b/web/src/lib/download.ts @@ -0,0 +1,16 @@ +/** + * Hand the browser a file the app made, to save. + * + * The object URL is released as soon as the download has been started: a + * click on the link starts it synchronously, and an unreleased URL keeps the + * whole file in memory for as long as the tab is open -- an address book's + * worth of vCards, per export. + */ +export function downloadFile(content: BlobPart, type: string, filename: string): void { + const url = URL.createObjectURL(new Blob([content], { type })); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/web/src/store/__tests__/body-eviction.test.ts b/web/src/store/__tests__/body-eviction.test.ts new file mode 100644 index 0000000..a9a9224 --- /dev/null +++ b/web/src/store/__tests__/body-eviction.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CAP, client } from "@/jmap/client"; +import type { JmapSession } from "@/jmap/types"; +import { BODIES_KEPT, resetBodyOrder, useMail } from "@/store/mail"; + +/** + * A long session used to keep the full copy of every message it opened -- + * bodies, headers, attachment lists -- until the tab closed. + */ + +const full = (id: string, threadId = `t-${id}`) => ({ + id, + threadId, + mailboxIds: { in: true }, + keywords: {}, + subject: `Subject ${id}`, + receivedAt: "2026-09-16T00:00:00Z", + preview: "p", + htmlBody: [{ partId: "1", type: "text/html" }], + bodyValues: { "1": { value: "

".padEnd(10_000, "x") } }, + attachments: [], +}); + +beforeEach(() => { + resetBodyOrder(); + client.session = { capabilities: { [CAP.core]: { maxObjectsInGet: 500 }, [CAP.mail]: {} }, accounts: {}, primaryAccounts: {}, state: "s" } as unknown as JmapSession; + useMail.setState({ accountId: "a1", emails: {}, fullIds: {}, threads: {}, openThreadId: null, emailState: "1" }); + vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => { + const { methodCalls } = JSON.parse(init.body as string) as { methodCalls: [string, Record, string][] }; + const methodResponses = methodCalls.map(([name, args, id]) => [name, { state: "1", list: (args.ids as string[]).map((x) => full(x)), notFound: [] }, id]); + return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "s" }) } as Response; + })); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const open = async (id: string) => { + await useMail.getState().getEmails([id], true); +}; + +describe("message bodies", () => { + it("are let go past the limit, oldest first, back to what the list shows", async () => { + for (let i = 0; i < BODIES_KEPT + 5; i++) await open(`m${i}`); + const { emails, fullIds } = useMail.getState(); + expect(Object.keys(fullIds)).toHaveLength(BODIES_KEPT); + for (let i = 0; i < 5; i++) { + expect(fullIds[`m${i}`]).toBeUndefined(); + expect(emails[`m${i}`]).toMatchObject({ id: `m${i}`, subject: `Subject m${i}`, preview: "p" }); + expect(emails[`m${i}`]).not.toHaveProperty("bodyValues"); + expect(emails[`m${i}`]).not.toHaveProperty("htmlBody"); + } + expect(emails[`m${BODIES_KEPT + 4}`]).toHaveProperty("bodyValues"); + }); + + it("count a message opened again as recent", async () => { + for (let i = 0; i < BODIES_KEPT; i++) await open(`m${i}`); + await open("m0"); + await open("extra"); + const { fullIds } = useMail.getState(); + expect(fullIds.m0).toBe(true); + expect(fullIds.m1).toBeUndefined(); + }); + + it("are never taken from the conversation that is open", async () => { + await open("keep"); + useMail.setState((s) => ({ openThreadId: "t-keep", threads: { ...s.threads, "t-keep": { id: "t-keep", emailIds: ["keep"] } } })); + for (let i = 0; i < BODIES_KEPT + 5; i++) await open(`m${i}`); + expect(useMail.getState().fullIds.keep).toBe(true); + expect(useMail.getState().emails.keep).toHaveProperty("bodyValues"); + }); + + it("are fetched again when a released message is opened", async () => { + for (let i = 0; i < BODIES_KEPT + 1; i++) await open(`m${i}`); + expect(useMail.getState().fullIds.m0).toBeUndefined(); + const [again] = await useMail.getState().getEmails(["m0"], true); + expect(again).toHaveProperty("bodyValues"); + expect(useMail.getState().fullIds.m0).toBe(true); + }); +}); diff --git a/web/src/store/mail/index.ts b/web/src/store/mail/index.ts index 21fadf8..cd25d4c 100644 --- a/web/src/store/mail/index.ts +++ b/web/src/store/mail/index.ts @@ -99,6 +99,7 @@ export const useMail = create((set, get) => ({ setAccount(accountId) { if (accountId === get().accountId) return; + resetBodyOrder(); set({ accountId, mailboxes: {}, @@ -263,6 +264,10 @@ export const useMail = create((set, get) => ({ return { emails: next, fullIds: nextFull, emailState: s.emailState ?? state }; }); } + if (full) { + touchBodies(ids); + set((s) => releaseBodies(s)); + } const now = get().emails; return ids.map((id) => now[id]).filter((e): e is Email => Boolean(e)); }, @@ -1059,6 +1064,55 @@ function mergeEmail(prev: Email | undefined, next: Email): Email { return prev; } +/* + * How many messages are held with their bodies. + * + * Every message opened kept its full copy -- bodies of up to 2 MB each, parsed + * headers, the attachment list -- for as long as the tab was open, so a long + * session's memory grew with every message read. Past this many, the ones read + * longest ago go back to what the list needs, and are fetched in full again if + * they are opened again. The open conversation is never touched. + */ +export const BODIES_KEPT = 40; +/** Messages held in full, least recently wanted first. */ +const bodyOrder: Id[] = []; +const LIST_KEYS = new Set(LIST_PROPS); + +function touchBodies(ids: Id[]): void { + for (const id of ids) { + const at = bodyOrder.indexOf(id); + if (at >= 0) bodyOrder.splice(at, 1); + bodyOrder.push(id); + } +} + +/** The state with bodies past `BODIES_KEPT` let go; the same state when there is nothing to do. */ +export function releaseBodies(s: MailState): MailState | Partial { + if (bodyOrder.length <= BODIES_KEPT) return s; + const open = new Set(s.openThreadId ? (s.threads[s.openThreadId]?.emailIds ?? []) : []); + const emails = { ...s.emails }; + const fullIds = { ...s.fullIds }; + let over = bodyOrder.length - BODIES_KEPT; + for (let i = 0; i < bodyOrder.length && over > 0; ) { + const id = bodyOrder[i]!; + if (open.has(id) || s.emails[id]?.threadId === s.openThreadId) { + i++; + continue; + } + bodyOrder.splice(i, 1); + over--; + delete fullIds[id]; + const e = emails[id]; + if (e) emails[id] = Object.fromEntries(Object.entries(e).filter(([k]) => LIST_KEYS.has(k))) as unknown as Email; + } + return { emails, fullIds }; +} + +/** Forget what is held; for tests, and for an account switch. */ +export function resetBodyOrder(): void { + bodyOrder.length = 0; +} + let sortRefused = false; async function runQuery(accountId: Id, q: ListQuery, position: number, limit: number) { diff --git a/web/src/views/calendar/CalendarSidebar.tsx b/web/src/views/calendar/CalendarSidebar.tsx index 9a51859..1ebb135 100644 --- a/web/src/views/calendar/CalendarSidebar.tsx +++ b/web/src/views/calendar/CalendarSidebar.tsx @@ -16,6 +16,7 @@ import type { Calendar, Id } from "@/jmap/types"; import { CalendarDialog } from "./CalendarDialog"; import { ShareDialog } from "../settings/ShareDialog"; import { plural, t } from "@/lib/i18n"; +import { downloadFile } from "@/lib/download"; export function CalendarSidebar() { const [location, navigate] = useLocation(); @@ -42,19 +43,14 @@ export function CalendarSidebar() { const importInto = useRef(null); /* - * Handing the file over, which the browser only does from a click. The - * revoke below is what keeps a calendar's worth of text from sitting in - * memory after the download has started. + * Handing the file over, which the browser only does from a click. + * `downloadFile` releases it once started, so a calendar's worth of text + * does not sit in memory afterwards. */ const exportFile = async (c: Calendar) => { try { const { text, count } = await cal.exportIcs(c.id); - const url = URL.createObjectURL(new Blob([text], { type: "text/calendar" })); - const a = document.createElement("a"); - a.href = url; - a.download = `${c.name.replace(/[^\w.-]+/g, "_") || "calendar"}.ics`; - a.click(); - URL.revokeObjectURL(url); + downloadFile(text, "text/calendar", `${c.name.replace(/[^\w.-]+/g, "_") || "calendar"}.ics`); toast.success(plural(count, { one: "Exported {n} event", other: "Exported {n} events" })); } catch (err) { toast.error(t("Could not export this calendar: {error}", { error: (err as Error).message })); diff --git a/web/src/views/contacts/ContactsView.tsx b/web/src/views/contacts/ContactsView.tsx index 1b40658..92a74a9 100644 --- a/web/src/views/contacts/ContactsView.tsx +++ b/web/src/views/contacts/ContactsView.tsx @@ -15,6 +15,7 @@ import { useSettings } from "@/store/settings"; import { ContactEditor } from "./ContactEditor"; import { avatarColor } from "@/lib/address"; import { plural, t as translate } from "@/lib/i18n"; +import { downloadFile } from "@/lib/download"; export function ContactsView({ id }: { id?: string }) { const [, navigate] = useLocation(); @@ -149,10 +150,7 @@ export function ContactsView({ id }: { id?: string }) { toast.error(translate("There is nothing in it to export")); return; } - const a = document.createElement("a"); - a.href = URL.createObjectURL(new Blob([cards.map(toVCard).join("")], { type: "text/vcard" })); - a.download = "contacts.vcf"; - a.click(); + downloadFile(cards.map(toVCard).join(""), "text/vcard", "contacts.vcf"); }; const importFile = async (f: File, intoBookId?: string) => { @@ -359,7 +357,7 @@ function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: Con {narrow && } - +

diff --git a/web/src/views/settings/GeneralSettings.tsx b/web/src/views/settings/GeneralSettings.tsx index c7950c8..67d4cfb 100644 --- a/web/src/views/settings/GeneralSettings.tsx +++ b/web/src/views/settings/GeneralSettings.tsx @@ -25,6 +25,7 @@ import { type DateFormat, } from "@/lib/datetime"; import { isEnforced } from "@/lib/settingsPolicy"; +import { downloadFile } from "@/lib/download"; /** Illustrative instant used for the format previews: 22 Nov 2025, 18:23. */ const SAMPLE = new Date(2025, 10, 22, 18, 23); @@ -236,7 +237,7 @@ export function GeneralSettings() {

{t("Backup")}

- +