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() {