mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
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:
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user