Keep list refreshes within the server's limits and stop repeating them

Refresh the list in pages of at most maxObjectsInGet. A list scrolled past
500 rows used to send all of its ids to one Email/get, which the server
refuses whole, and the refresh failed without a word.

Fetch the other messages of listed threads in their own capped requests,
and only those not already held. They used to be back-referenced from
Thread/get with no bound.

Have loadThread fetch bodies only for messages not held in full. Every push
refetched the whole open thread's bodies, and the new attachment objects
made the reading pane redo work it had already done.

Build the list query from the folder names, roles and tree rather than the
mailbox map, which every reload replaces. Each reload used to build a new
query, which query() answered with another full refresh.
This commit is contained in:
2026-09-16 08:25:57 -07:00
parent 47a2477d9f
commit e2b4cc18db
3 changed files with 280 additions and 34 deletions
@@ -0,0 +1,191 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CAP, client } from "@/jmap/client";
import { useMail } from "@/store/mail";
import type { JmapSession } from "@/jmap/types";
/**
* What a list refresh and an open thread cost the server.
*
* A refresh fetches everything already on screen, and in conversation mode
* every message of every listed thread. Both used to go to Email/get in one
* call whatever their number, and Stalwart refuses a whole call over
* `maxObjectsInGet` -- so past a few pages, or with long threads, the refresh
* failed and the list silently stopped updating.
*/
const MAX = 500;
const INBOX = "mbInbox";
type Call = [string, Record<string, unknown>, string];
/**
* A mailbox of `count` messages. With `threadSize` above 1, each listed
* message leads a thread of that many, whose other members are not in the
* list. Enforces MAX on every /get, back-referenced ids included, as the mock
* server and Stalwart do.
*/
function server(count: number, threadSize = 1) {
const listed = Array.from({ length: count }, (_, i) => `e${i}`);
const members = (lead: string) => [lead, ...Array.from({ length: threadSize - 1 }, (_, j) => `${lead}m${j}`)];
const calls: Call[] = [];
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
const body = JSON.parse(init.body as string) as { methodCalls: Call[] };
const responses: Call[] = [];
const resolve = (args: Record<string, unknown>): Record<string, unknown> => {
const ref = args["#ids"] as { resultOf: string; path: string } | undefined;
if (!ref) return args;
const from = responses.find((r) => r[2] === ref.resultOf)![1];
const ids =
ref.path === "/ids" ? (from.ids as string[])
: ref.path === "/list/*/threadId" ? (from.list as { threadId: string }[]).map((e) => e.threadId)
: (from.list as { emailIds: string[] }[]).flatMap((t) => t.emailIds);
const { "#ids": _drop, ...rest } = args;
return { ...rest, ids };
};
for (const [name, raw, id] of body.methodCalls) {
const args = resolve(raw);
calls.push([name, args, id]);
const ids = args.ids as string[] | undefined;
if (name.endsWith("/get") && ids && ids.length > MAX) {
responses.push(["error", { type: "requestTooLarge" }, id]);
continue;
}
if (name === "Email/query") {
const position = args.position as number;
const limit = args.limit as number;
responses.push([name, { accountId: "a1", queryState: "q1", canCalculateChanges: false, position, ids: listed.slice(position, position + limit), total: listed.length }, id]);
} else if (name === "Email/get") {
const list = ids!.map((e) => ({ id: e, threadId: `t${e.replace(/m\d+$/, "")}`, mailboxIds: { [INBOX]: true }, keywords: {}, receivedAt: "2026-09-16T00:00:00Z" }));
responses.push([name, { accountId: "a1", state: "s1", list, notFound: [] }, id]);
} else if (name === "Thread/get") {
const list = ids!.map((t) => ({ id: t, emailIds: members(t.slice(1)) }));
responses.push([name, { accountId: "a1", state: "s1", list, notFound: [] }, id]);
} else {
responses.push([name, { accountId: "a1", state: "s1", list: [], notFound: [] }, id]);
}
}
return { ok: true, status: 200, json: async () => ({ methodResponses: responses, sessionState: "1" }) } as Response;
});
vi.stubGlobal("fetch", fetchMock);
return { calls, listed };
}
const getSizes = (calls: Call[]) => calls.filter(([n]) => n === "Email/get").map(([, a]) => (a.ids as string[]).length);
beforeEach(() => {
client.session = {
capabilities: { [CAP.core]: { maxObjectsInGet: MAX, maxObjectsInSet: MAX }, [CAP.mail]: {} },
accounts: {},
primaryAccounts: {},
state: "s1",
} as unknown as JmapSession;
useMail.setState({
accountId: "a1",
mailboxes: { [INBOX]: { id: INBOX, role: "inbox", name: "Inbox" } } as never,
list: null,
emails: {},
fullIds: {},
threads: {},
emailState: "s1",
loadingThreads: {},
openThreadId: null,
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
const listOf = (ids: string[], collapseThreads = false) => ({
key: "k",
filter: { inMailbox: INBOX },
sort: [],
collapseThreads,
mailboxId: INBOX,
ids,
total: ids.length,
queryState: "q0",
loading: false,
loadingMore: false,
error: null,
exhausted: false,
});
describe("refreshList", () => {
it("refreshes more rows than one Email/get may carry, in pages the server takes", async () => {
const { calls, listed } = server(1300);
useMail.setState({ list: listOf(listed.slice(0, 1200)) as never });
await useMail.getState().refreshList();
const sizes = getSizes(calls);
expect(sizes.every((n) => n <= MAX)).toBe(true);
expect(calls.some(([n]) => n === "error")).toBe(false);
const list = useMail.getState().list!;
expect(list.ids).toEqual(listed.slice(0, 1200));
expect(list.total).toBe(1300);
expect(list.error).toBeNull();
});
it("stops at the end of a folder that shrank", async () => {
const { listed } = server(700);
useMail.setState({ list: listOf([...listed, ...Array.from({ length: 300 }, (_, i) => `gone${i}`)]) as never });
await useMail.getState().refreshList();
const list = useMail.getState().list!;
expect(list.ids).toEqual(listed);
expect(list.exhausted).toBe(true);
});
it("fetches long threads' other messages within the limit", async () => {
// 50 listed threads of 20 messages: 950 members that are not in the list.
const { calls, listed } = server(50, 20);
await useMail.getState().query({ key: "", filter: { inMailbox: INBOX }, sort: [], collapseThreads: true, mailboxId: INBOX });
const list = useMail.getState().list!;
expect(list.error).toBeNull();
expect(list.ids).toEqual(listed);
expect(getSizes(calls).every((n) => n <= MAX)).toBe(true);
const emails = useMail.getState().emails;
expect(Object.keys(emails)).toHaveLength(50 * 20);
// A second refresh does not fetch the members it already holds.
calls.length = 0;
await useMail.getState().refreshList();
expect(getSizes(calls)).toEqual([50]);
});
});
describe("loadThread", () => {
it("fetches no bodies for messages already held in full", async () => {
const { calls } = server(1, 3);
useMail.setState({
emails: { e0: { id: "e0" }, e0m0: { id: "e0m0" }, e0m1: { id: "e0m1" } } as never,
fullIds: { e0: true, e0m0: true, e0m1: true },
});
const before = useMail.getState().emails.e0;
const got = await useMail.getState().loadThread("te0");
expect(got.map((e) => e.id)).toEqual(["e0", "e0m0", "e0m1"]);
expect(calls.map(([n]) => n)).toEqual(["Thread/get"]);
// The same object, so nothing derived from it has to be rebuilt.
expect(useMail.getState().emails.e0).toBe(before);
});
it("fetches in full only the message it does not have", async () => {
const { calls } = server(1, 3);
useMail.setState({
emails: { e0: { id: "e0" }, e0m0: { id: "e0m0" }, e0m1: { id: "e0m1" } } as never,
fullIds: { e0: true, e0m0: true },
});
await useMail.getState().loadThread("te0");
const gets = calls.filter(([n]) => n === "Email/get");
expect(gets).toHaveLength(1);
expect(gets[0]![1].ids).toEqual(["e0m1"]);
expect(gets[0]![1].fetchHTMLBodyValues).toBe(true);
expect(useMail.getState().fullIds.e0m1).toBe(true);
expect(useMail.getState().loadingThreads).toEqual({});
});
it("splits a thread longer than one Email/get may carry", async () => {
const { calls } = server(1, 1200);
const got = await useMail.getState().loadThread("te0");
expect(got).toHaveLength(1200);
expect(getSizes(calls).every((n) => n <= MAX)).toBe(true);
});
});