perf: load conversation sizes with the folder page instead of in a second request

This commit is contained in:
Linus Rath
2026-10-15 00:14:57 +02:00
parent 06d00e10ee
commit db2c0aff3d
7 changed files with 243 additions and 13 deletions
+22
View File
@@ -90,6 +90,28 @@ describe('dev-jmap mock server', () => {
});
});
describe('POST /api - back-references', () => {
it('resolves a wildcard path across a list (RFC 8620 §3.7)', async () => {
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json', host: 'localhost:3000' },
body: JSON.stringify({
methodCalls: [
['Email/query', { accountId: 'dev-account-001', filter: { inMailbox: 'mb-inbox' }, limit: 3 }, '0'],
['Email/get', { accountId: 'dev-account-001', '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['threadId'] }, '1'],
['Thread/get', { accountId: 'dev-account-001', '#ids': { resultOf: '1', name: 'Email/get', path: '/list/*/threadId' } }, '2'],
],
}),
});
const res = await POST(req, { params: Promise.resolve({ path: ['api'] }) });
const data = await res.json();
const threadIds = data.methodResponses[1][1].list.map((e: { threadId: string }) => e.threadId);
expect(threadIds.length).toBeGreaterThan(0);
expect(data.methodResponses[2][0]).toBe('Thread/get');
expect(data.methodResponses[2][1].list.map((t: { id: string }) => t.id).sort()).toEqual([...new Set<string>(threadIds)].sort());
});
});
describe('POST /api - Email/query', () => {
it('should filter by mailbox', async () => {
const req = makeRequest('http://localhost:3000/api/dev-jmap/api', {
@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
// A folder page brings its conversation sizes along: Thread/get rides on the
// list request through a wildcard back-reference (/list/*/threadId), so the
// list does not need a second round trip (and a second render) for them.
type MethodCall = [string, Record<string, unknown>, string];
const session = {
capabilities: { 'urn:ietf:params:jmap:core': {} },
accounts: { 'acct-1': { name: 'test', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:mail': {} } } },
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1' },
apiUrl: 'https://mail.example.com/jmap/api',
downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
eventSourceUrl: 'https://mail.example.com/jmap/eventsource',
};
const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
const page = [
{ id: 'e1', threadId: 't1', keywords: {}, receivedAt: '2026-01-02T00:00:00Z', mailboxIds: {}, size: 1, hasAttachment: false },
{ id: 'e2', threadId: 't2', keywords: {}, receivedAt: '2026-01-01T00:00:00Z', mailboxIds: {}, size: 1, hasAttachment: false },
];
describe('JMAPClient.getEmails thread counts', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
const sent: MethodCall[][] = [];
beforeEach(() => {
sent.length = 0;
fetchSpy = vi.spyOn(globalThis, 'fetch');
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => vi.restoreAllMocks());
async function client(threadAnswer: unknown) {
fetchSpy.mockResolvedValueOnce(json(session));
const c = JMAPClient.withBearer('https://mail.example.com', 'token', 'user@test.com');
await c.connect();
fetchSpy.mockReset();
fetchSpy.mockImplementation((async (_url: string, init: RequestInit) => {
const calls = JSON.parse(init.body as string).methodCalls as MethodCall[];
sent.push(calls);
if (calls[0][2] === 'asc') {
return json({ methodResponses: [['Email/query', { ids: [] }, 'asc'], ['Email/query', { ids: [] }, 'desc'], ['Email/get', { list: [] }, 'get']] });
}
return json({ methodResponses: [['Email/query', { ids: ['e1', 'e2'], total: 2 }, '0'], ['Email/get', { state: 's1', list: page }, '1'], threadAnswer] });
}) as never);
return c;
}
it('asks for the threads of the page in the same request and returns their sizes', async () => {
const c = await client(['Thread/get', { list: [{ id: 't1', emailIds: ['e1', 'x', 'y'] }, { id: 't2', emailIds: ['e2'] }] }, '2']);
const result = await c.getEmails('inbox', undefined, 50, 0);
const list = sent.find((calls) => calls[0][2] === '0')!;
expect(list[2]).toEqual(['Thread/get', {
accountId: 'acct-1',
'#ids': { resultOf: '1', name: 'Email/get', path: '/list/*/threadId' },
}, '2']);
expect(result.threadCounts).toEqual({ t1: 3, t2: 1 });
});
it('returns no counts when the server could not answer the chained call', async () => {
const c = await client(['error', { type: 'invalidResultReference' }, '2']);
const result = await c.getEmails('inbox', undefined, 50, 0);
expect(result.emails.map((e) => e.id)).toEqual(['e1', 'e2']);
expect(result.threadCounts).toBeUndefined();
});
});
+5 -1
View File
@@ -195,7 +195,11 @@ export interface IJMAPClient {
// server-side after pinned-first; see lib/message-list-order.ts.
// `state` is the Email collection state the page was read at (RFC 8620
// §5.1), when the client reports it; the store uses it for Email/changes.
getEmails(mailboxId?: string, accountId?: string, limit?: number, position?: number, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>, order?: SortLevel[]): Promise<{ emails: Email[]; hasMore: boolean; total: number; state?: string }>;
/**
* A page of a view. `threadCounts` (message count per thread id) comes with
* the page when the server answers the Thread/get chained onto it.
*/
getEmails(mailboxId?: string, accountId?: string, limit?: number, position?: number, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>, order?: SortLevel[]): Promise<{ emails: Email[]; hasMore: boolean; total: number; state?: string; threadCounts?: Record<string, number> }>;
/**
* Sort properties the account's server advertises for Email/query
* (`emailQuerySortOptions` in the mail capability, RFC 8621 §1.3), or null
+23 -2
View File
@@ -674,6 +674,20 @@ const FILE_NODE_QUERY_PAGE = 5000;
/** Safety bound on how many FileNode ids one listing pages through. */
const FILE_NODE_MAX_IDS = 200_000;
/**
* Message counts per thread id from the Thread/get chained onto a list
* request; undefined when the server did not answer it (one that cannot
* resolve a wildcard back-reference fails just that call).
*/
function threadCountsFrom(methodResponse: unknown): Record<string, number> | undefined {
if (!Array.isArray(methodResponse) || methodResponse[0] !== "Thread/get") return undefined;
const list = (methodResponse[1] as { list?: Thread[] } | undefined)?.list;
if (!Array.isArray(list)) return undefined;
const counts: Record<string, number> = {};
for (const thread of list) counts[thread.id] = thread.emailIds?.length ?? 0;
return counts;
}
function computeHasMore(position: number, emailCount: number, total: number, limit: number): boolean {
if (total > 0) return (position + emailCount) < total;
return emailCount === limit;
@@ -1991,7 +2005,7 @@ export class JMAPClient implements IJMAPClient {
// Throws when the page cannot be read. Answering with an empty page made a
// failed refresh empty the list, and callers could not tell it from an
// empty folder.
async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>, order: SortLevel[] = []): Promise<{ emails: Email[], hasMore: boolean, total: number, state?: string }> {
async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>, order: SortLevel[] = []): Promise<{ emails: Email[], hasMore: boolean, total: number, state?: string, threadCounts?: Record<string, number> }> {
const targetAccountId = accountId || this.accountId;
const simple: { inMailbox?: string; hasKeyword?: string } = {};
if (mailboxId) {
@@ -2030,6 +2044,13 @@ export class JMAPClient implements IJMAPClient {
"#ids": { resultOf: "0", name: "Email/query", path: "/ids" },
properties: [...EMAIL_LIST_PROPERTIES],
}, "1"],
// The page's conversation sizes, off the thread ids the Email/get
// returns (RFC 8620 §3.7 wildcard path): no second round trip, and the
// list paints with its counts instead of re-rendering for them.
["Thread/get", {
accountId: targetAccountId,
"#ids": { resultOf: "1", name: "Email/get", path: "/list/*/threadId" },
}, "2"],
]);
let response = await query(built.sort);
@@ -2064,7 +2085,7 @@ export class JMAPClient implements IJMAPClient {
// The Email collection state this page was read at; the store keeps
// it so a later push can be applied with Email/changes.
const state = typeof getResponse.state === 'string' ? getResponse.state : undefined;
return { emails, hasMore, total, state };
return { emails, hasMore, total, state, threadCounts: threadCountsFrom(response.methodResponses?.[2]) };
}
throw new Error(methodErrorMessage(response, 'Failed to get emails'));