Let go of old message bodies and of exported files

Every message opened kept its full copy for as long as the tab was open.
The store now holds bodies for the 40 messages most recently wanted; older
ones go back to the list properties and are fetched in full again if
opened. The open conversation is never released.

Contact, settings and calendar exports go through downloadFile, which
releases the object URL once the download has started; three of them
never released it.
This commit is contained in:
2026-09-16 10:59:57 -07:00
parent da87925b9c
commit f123467897
6 changed files with 161 additions and 15 deletions
+16
View File
@@ -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);
}
@@ -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: "<p>".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, unknown>, 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);
});
});
+54
View File
@@ -99,6 +99,7 @@ export const useMail = create<MailState>((set, get) => ({
setAccount(accountId) { setAccount(accountId) {
if (accountId === get().accountId) return; if (accountId === get().accountId) return;
resetBodyOrder();
set({ set({
accountId, accountId,
mailboxes: {}, mailboxes: {},
@@ -263,6 +264,10 @@ export const useMail = create<MailState>((set, get) => ({
return { emails: next, fullIds: nextFull, emailState: s.emailState ?? state }; return { emails: next, fullIds: nextFull, emailState: s.emailState ?? state };
}); });
} }
if (full) {
touchBodies(ids);
set((s) => releaseBodies(s));
}
const now = get().emails; const now = get().emails;
return ids.map((id) => now[id]).filter((e): e is Email => Boolean(e)); 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; 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<string>(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<MailState> {
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; let sortRefused = false;
async function runQuery(accountId: Id, q: ListQuery, position: number, limit: number) { async function runQuery(accountId: Id, q: ListQuery, position: number, limit: number) {
+5 -9
View File
@@ -16,6 +16,7 @@ import type { Calendar, Id } from "@/jmap/types";
import { CalendarDialog } from "./CalendarDialog"; import { CalendarDialog } from "./CalendarDialog";
import { ShareDialog } from "../settings/ShareDialog"; import { ShareDialog } from "../settings/ShareDialog";
import { plural, t } from "@/lib/i18n"; import { plural, t } from "@/lib/i18n";
import { downloadFile } from "@/lib/download";
export function CalendarSidebar() { export function CalendarSidebar() {
const [location, navigate] = useLocation(); const [location, navigate] = useLocation();
@@ -42,19 +43,14 @@ export function CalendarSidebar() {
const importInto = useRef<Id | null>(null); const importInto = useRef<Id | null>(null);
/* /*
* Handing the file over, which the browser only does from a click. The * Handing the file over, which the browser only does from a click.
* revoke below is what keeps a calendar's worth of text from sitting in * `downloadFile` releases it once started, so a calendar's worth of text
* memory after the download has started. * does not sit in memory afterwards.
*/ */
const exportFile = async (c: Calendar) => { const exportFile = async (c: Calendar) => {
try { try {
const { text, count } = await cal.exportIcs(c.id); const { text, count } = await cal.exportIcs(c.id);
const url = URL.createObjectURL(new Blob([text], { type: "text/calendar" })); downloadFile(text, "text/calendar", `${c.name.replace(/[^\w.-]+/g, "_") || "calendar"}.ics`);
const a = document.createElement("a");
a.href = url;
a.download = `${c.name.replace(/[^\w.-]+/g, "_") || "calendar"}.ics`;
a.click();
URL.revokeObjectURL(url);
toast.success(plural(count, { one: "Exported {n} event", other: "Exported {n} events" })); toast.success(plural(count, { one: "Exported {n} event", other: "Exported {n} events" }));
} catch (err) { } catch (err) {
toast.error(t("Could not export this calendar: {error}", { error: (err as Error).message })); toast.error(t("Could not export this calendar: {error}", { error: (err as Error).message }));
+3 -5
View File
@@ -15,6 +15,7 @@ import { useSettings } from "@/store/settings";
import { ContactEditor } from "./ContactEditor"; import { ContactEditor } from "./ContactEditor";
import { avatarColor } from "@/lib/address"; import { avatarColor } from "@/lib/address";
import { plural, t as translate } from "@/lib/i18n"; import { plural, t as translate } from "@/lib/i18n";
import { downloadFile } from "@/lib/download";
export function ContactsView({ id }: { id?: string }) { export function ContactsView({ id }: { id?: string }) {
const [, navigate] = useLocation(); const [, navigate] = useLocation();
@@ -149,10 +150,7 @@ export function ContactsView({ id }: { id?: string }) {
toast.error(translate("There is nothing in it to export")); toast.error(translate("There is nothing in it to export"));
return; return;
} }
const a = document.createElement("a"); downloadFile(cards.map(toVCard).join(""), "text/vcard", "contacts.vcf");
a.href = URL.createObjectURL(new Blob([cards.map(toVCard).join("")], { type: "text/vcard" }));
a.download = "contacts.vcf";
a.click();
}; };
const importFile = async (f: File, intoBookId?: string) => { const importFile = async (f: File, intoBookId?: string) => {
@@ -359,7 +357,7 @@ function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: Con
{narrow && <button className="icon-btn" onClick={onBack} aria-label={translate("Back")}><ArrowLeft size={20} /></button>} {narrow && <button className="icon-btn" onClick={onBack} aria-label={translate("Back")}><ArrowLeft size={20} /></button>}
<span className="spacer" /> <span className="spacer" />
<button className="btn btn-sm" onClick={onEdit}><Pencil size={14} /> {translate("Edit")}</button> <button className="btn btn-sm" onClick={onEdit}><Pencil size={14} /> {translate("Edit")}</button>
<button className="btn btn-sm" onClick={() => { const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([toVCard(c)], { type: "text/vcard" })); a.download = `${name.replace(/[^\w.-]+/g, "_")}.vcf`; a.click(); }}><Download size={14} /> {translate("vCard")}</button> <button className="btn btn-sm" onClick={() => downloadFile(toVCard(c), "text/vcard", `${name.replace(/[^\w.-]+/g, "_")}.vcf`)}><Download size={14} /> {translate("vCard")}</button>
<button className="btn btn-sm btn-ghost" style={{ color: "var(--danger)" }} onClick={async () => { if (await confirmDialog({ title: translate("Delete {name}?", { name }), confirmLabel: translate("Delete"), danger: true })) { try { const { destroyed, refused } = await contacts.destroyCards([c.id]); if (!destroyed) { toast.error(refused ? setErrorMessage(refused) : translate("It was not deleted")); return; } toast.success(translate("Contact deleted")); navigate("/contacts"); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={14} /></button> <button className="btn btn-sm btn-ghost" style={{ color: "var(--danger)" }} onClick={async () => { if (await confirmDialog({ title: translate("Delete {name}?", { name }), confirmLabel: translate("Delete"), danger: true })) { try { const { destroyed, refused } = await contacts.destroyCards([c.id]); if (!destroyed) { toast.error(refused ? setErrorMessage(refused) : translate("It was not deleted")); return; } toast.success(translate("Contact deleted")); navigate("/contacts"); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={14} /></button>
</div> </div>
<div className="contact-hero"> <div className="contact-hero">
+2 -1
View File
@@ -25,6 +25,7 @@ import {
type DateFormat, type DateFormat,
} from "@/lib/datetime"; } from "@/lib/datetime";
import { isEnforced } from "@/lib/settingsPolicy"; import { isEnforced } from "@/lib/settingsPolicy";
import { downloadFile } from "@/lib/download";
/** Illustrative instant used for the format previews: 22 Nov 2025, 18:23. */ /** Illustrative instant used for the format previews: 22 Nov 2025, 18:23. */
const SAMPLE = new Date(2025, 10, 22, 18, 23); const SAMPLE = new Date(2025, 10, 22, 18, 23);
@@ -236,7 +237,7 @@ export function GeneralSettings() {
<h2>{t("Backup")}</h2> <h2>{t("Backup")}</h2>
<div className="row wrap"> <div className="row wrap">
<button className="btn" onClick={() => { const blob = new Blob([exportJson()], { type: "application/json" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "ihasmail-settings.json"; a.click(); }}>{t("Export settings")}</button> <button className="btn" onClick={() => downloadFile(exportJson(), "application/json", "ihasmail-settings.json")}>{t("Export settings")}</button>
<label className="btn"> <label className="btn">
{t("Import settings")} {t("Import settings")}
<input type="file" accept="application/json" hidden onChange={async (e) => { const f = e.target.files?.[0]; if (!f) return; const ok = importJson(await f.text()); toast[ok ? "success" : "error"](ok ? t("Settings imported") : t("Invalid settings file")); e.target.value = ""; }} /> <input type="file" accept="application/json" hidden onChange={async (e) => { const f = e.target.files?.[0]; if (!f) return; const ok = importJson(await f.text()); toast[ok ? "success" : "error"](ok ? t("Settings imported") : t("Invalid settings file")); e.target.value = ""; }} />