fix: open a message from the cache and fetch only the open cards' bodies

Every open went to the network for the full message, even for a message
the viewer had just shown, and the conversation fetch then downloaded
every member's full body again, the opened one included. The pager's
three panes each started their own detail and thread fetch, so one open
from the Unified Inbox fetched the body twice and the thread twice.

Messages and conversations now live in a shared cache
(`lib/email-detail-cache`), keyed by server, login and JMAP account:

- a held copy renders at once; bodies never change, so it is only
  checked, with a keywords/folders-only `Email/get`, when the account's
  Email state (from push or our own reads) moved on or the list row the
  message was opened from disagrees with it;
- a copy in the offline cache is shown before anything goes out and is
  checked the same way;
- a conversation is listed from its members' headers (`Thread/get` and
  list properties in one request) and bodies are downloaded only for
  the open cards, in one batched `Email/get`;
- concurrent loads of the same message or thread share one request.

The store's network-first `getEmailDetail` is gone; its offline-copy
refresh moved into the cache.
This commit is contained in:
Linus Rath
2026-09-24 20:12:16 +02:00
parent 96c5a3f046
commit 33e303e248
8 changed files with 1082 additions and 158 deletions
@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
request: vi.fn(),
getMaxObjectsInGet: vi.fn(() => 500),
getMaxObjectsInSet: vi.fn(() => 500),
},
}));
import { jmapClient } from '../jmap-client';
import { getEmailFlags, getFullEmailsWithState, getThreadHeaders } from '../email';
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
});
describe('viewer fetches', () => {
it('lists a conversation from headers only, in thread order, without bodies', async () => {
mockRequest.mockResolvedValue({
methodResponses: [
['Thread/get', { list: [{ id: 't1', emailIds: ['a', 'gone', 'b'] }] }, '0'],
['Email/get', { state: 's9', list: [{ id: 'b', subject: 'Re: hi' }, { id: 'a', subject: 'hi' }] }, '1'],
],
});
const res = await getThreadHeaders('t1', 'group-1');
expect(res.emailIds).toEqual(['a', 'b']);
expect(res.list.map((e) => e.id)).toEqual(['a', 'b']);
expect(res.state).toBe('s9');
const [calls] = mockRequest.mock.calls[0];
expect(calls[0]).toEqual(['Thread/get', { accountId: 'group-1', ids: ['t1'] }, '0']);
const args = calls[1][1];
expect(args.accountId).toBe('group-1');
expect(args['#ids']).toEqual({ resultOf: '0', name: 'Thread/get', path: '/list/*/emailIds' });
expect(args.properties).toContain('preview');
expect(args.properties).toContain('sentAt');
expect(args.properties).not.toContain('bodyValues');
expect(args.fetchHTMLBodyValues).toBeUndefined();
});
it('checks keywords and folders without downloading bodies', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['Email/get', {
state: 's2',
list: [{ id: 'a', keywords: { $seen: true }, mailboxIds: { inbox: true } }],
notFound: ['b'],
}, '0']],
});
const res = await getEmailFlags(['a', 'b']);
expect(res).toEqual({
list: [{ id: 'a', keywords: { $seen: true }, mailboxIds: { inbox: true } }],
notFound: ['b'],
state: 's2',
});
const [[[, args]]] = mockRequest.mock.calls[0];
expect(args).toEqual({ accountId: 'acc-1', ids: ['a', 'b'], properties: ['id', 'keywords', 'mailboxIds'] });
});
it('returns the Email state and the missing ids with the bodies', async () => {
mockRequest.mockResolvedValue({
methodResponses: [['Email/get', { state: 's3', list: [{ id: 'a', bodyValues: {} }], notFound: ['b'] }, '0']],
});
const res = await getFullEmailsWithState(['a', 'b']);
expect(res.list.map((e) => e.id)).toEqual(['a']);
expect(res.notFound).toEqual(['b']);
expect(res.state).toBe('s3');
const [[[, args]]] = mockRequest.mock.calls[0];
expect(args.fetchHTMLBodyValues).toBe(true);
});
});
+86 -4
View File
@@ -752,19 +752,101 @@ export async function getFullEmail(id: string, accountIdOverride?: string): Prom
// Batch variant for offline sync and the thread view. Splits to the server's
// maxObjectsInGet ceiling itself.
export async function getFullEmails(ids: string[], accountIdOverride?: string): Promise<Email[]> {
if (ids.length === 0) return [];
return (await getFullEmailsWithState(ids, accountIdOverride)).list;
}
/** An `Email/get` result: what came back, what the server does not know, and its Email state. */
export interface EmailGetResult<T> {
list: T[];
notFound: string[];
/** The account's Email state the result was read at (RFC 8620 §5.1). */
state?: string;
}
/**
* {@link getFullEmails} with the Email state of the response and the ids the
* server reported missing, for callers that cache what they read.
*/
export async function getFullEmailsWithState(
ids: string[],
accountIdOverride?: string,
): Promise<EmailGetResult<Email>> {
if (ids.length === 0) return { list: [], notFound: [] };
const accountId = accountIdOverride ?? jmapClient.accountId;
const out: Email[] = [];
const out: EmailGetResult<Email> = { list: [], notFound: [] };
for (const slice of batched(ids, maxInGet())) {
const res = await jmapClient.request([
['Email/get', { accountId, ids: slice, ...FULL_BODY_ARGS }, '0'],
]);
out.push(...((requireMethodResult(res, '0', 'Email/get').list as Email[]) ?? []));
const body = requireMethodResult(res, '0', 'Email/get');
out.list.push(...((body.list as Email[]) ?? []));
out.notFound.push(...((body.notFound as string[] | undefined) ?? []));
out.state = body.state as string | undefined;
}
await refetchTruncatedBodyValues(out, accountId);
await refetchTruncatedBodyValues(out.list, accountId);
return out;
}
/** The two mutable properties of an Email (RFC 8621 §4.1); nothing else ever changes. */
export type EmailFlags = Pick<Email, 'id' | 'keywords' | 'mailboxIds'>;
/**
* Only the keywords and folders of messages, to bring a copy read earlier up
* to date without downloading its body again.
*/
export async function getEmailFlags(
ids: string[],
accountIdOverride?: string,
): Promise<EmailGetResult<EmailFlags>> {
if (ids.length === 0) return { list: [], notFound: [] };
const accountId = accountIdOverride ?? jmapClient.accountId;
const out: EmailGetResult<EmailFlags> = { list: [], notFound: [] };
for (const slice of batched(ids, maxInGet())) {
const res = await jmapClient.request([
['Email/get', { accountId, ids: slice, properties: ['id', 'keywords', 'mailboxIds'] }, '0'],
]);
const body = requireMethodResult(res, '0', 'Email/get');
out.list.push(...((body.list as EmailFlags[]) ?? []));
out.notFound.push(...((body.notFound as string[] | undefined) ?? []));
out.state = body.state as string | undefined;
}
return out;
}
// What a collapsed conversation card shows; `sentAt` is the date it prints.
export const THREAD_HEADER_PROPERTIES = [...EMAIL_LIST_PROPERTIES, 'sentAt'];
/**
* The messages of a thread without their bodies, oldest first, in one
* back-referenced Thread/get → Email/get. The viewer lists a conversation from
* these and downloads bodies only for the cards it opens.
*/
export async function getThreadHeaders(
threadId: string,
accountIdOverride?: string,
): Promise<{ emailIds: string[]; list: Email[]; state?: string }> {
const accountId = accountIdOverride ?? jmapClient.accountId;
const res = await jmapClient.request([
['Thread/get', { accountId, ids: [threadId] }, '0'],
['Email/get', {
accountId,
'#ids': { resultOf: '0', name: 'Thread/get', path: '/list/*/emailIds' },
properties: THREAD_HEADER_PROPERTIES,
}, '1'],
]);
const thread = (requireMethodResult(res, '0', 'Thread/get').list as Thread[] | undefined)?.[0];
if (!thread) throw new Error(`Thread ${threadId} not found`);
const body = requireMethodResult(res, '1', 'Email/get');
const byId = new Map(((body.list as Email[]) ?? []).map((e) => [e.id, e]));
// Members the server did not return (no access to them) are left out.
const emailIds = thread.emailIds.filter((id) => byId.has(id));
return {
emailIds,
list: emailIds.map((id) => byId.get(id)!),
state: body.state as string | undefined,
};
}
/**
* Import an already-uploaded raw MIME message (a `.eml` blob) into a mailbox
* via JMAP `Email/import`. Returns the new email id. Mirrors the webmail's