feat: let plugins count a conversation's messages, and read only the newest 50

api.email.getThread fetches only the newest 50 messages of a thread
(Thread/get, then Email/get for those ids) instead of all of them with
their bodies. api.email.getThreadCount answers the number of messages
from Thread/get, with its own rate limit.

plugin-host-thread.test.ts now expects getThreadEmails to be called with
{ newest: 50 }, the new option that limits what is fetched.
This commit is contained in:
Linus Rath
2026-11-03 00:38:07 +01:00
parent d68970126e
commit f8de35dd85
8 changed files with 141 additions and 9 deletions
+50
View File
@@ -0,0 +1,50 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { JMAPClient } from '../jmap/client';
// getThreadEmails with `newest`: Thread/get first, then Email/get only for
// the newest messages, so a thread of thousands never loads all its bodies.
function mockFetchResponse(status: number, body?: unknown): Response {
return new Response(body ? JSON.stringify(body) : null, { status, headers: { 'Content-Type': 'application/json' } });
}
const session = {
capabilities: { 'urn:ietf:params:jmap:core': {} },
accounts: { 'acct-1': { name: 'test', isPersonal: true, accountCapabilities: {} } },
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',
};
describe('JMAPClient.getThreadEmails newest', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => { fetchSpy = vi.spyOn(globalThis, 'fetch'); });
afterEach(() => { fetchSpy.mockRestore(); });
async function threadOf(ids: string[], options?: { newest?: number }): Promise<string[][]> {
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, session));
const client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123');
await client.connect();
fetchSpy.mockReset();
const asked: string[][] = [];
fetchSpy.mockImplementation(async (_input: unknown, init?: RequestInit) => {
const [name, args] = JSON.parse((init as RequestInit).body as string).methodCalls[0];
if (name === 'Thread/get') return mockFetchResponse(200, { methodResponses: [['Thread/get', { list: [{ id: 't', emailIds: ids }] }, '0']] });
asked.push(args.ids);
return mockFetchResponse(200, { methodResponses: [['Email/get', { list: [] }, '0']] });
});
await client.getThreadEmails('t', undefined, options);
return asked;
}
it('fetches only the newest messages of the thread', async () => {
const ids = Array.from({ length: 80 }, (_, i) => `e${i}`);
expect((await threadOf(ids, { newest: 50 })).flat()).toEqual(ids.slice(30));
});
it('fetches every message without it, as before', async () => {
expect((await threadOf(['a', 'b', 'c'])).flat()).toEqual(['a', 'b', 'c']);
});
});
+49 -2
View File
@@ -50,6 +50,15 @@ beforeEach(() => {
if (threadId === 'big') return Array.from({ length: 60 }, (_, i) => mail(`b${i}`, i, `Part ${i}`, 'big'));
return [];
}),
getSomeEmails: vi.fn(async (ids: string[], accountId?: string) => {
const all = accountId === 'shared-acc' ? [mail('s1', 1, 'Team news', 'ts')] : [...THREAD, mail('lonely', 5, 'Alone', '')];
return all.filter((e) => ids.includes(e.id));
}),
getThread: vi.fn(async (threadId: string, accountId?: string) => {
if (threadId === 't1' && !accountId) return { id: 't1', emailIds: ['m1', 'm2', 'm3'] };
if (threadId === 'ts' && accountId === 'shared-acc') return { id: 'ts', emailIds: ['s1', 's2'] };
return null;
}),
getAccountId: vi.fn(() => 'acc1'),
getMailboxes: vi.fn(async () => [{ id: 'shared-inbox', name: 'Team', isShared: true, accountId: 'shared-acc' } as unknown as Mailbox]),
};
@@ -80,13 +89,14 @@ describe('api.email.getThread', () => {
['m2', 'Re: Report', 'Re: Report body', 'acc1'],
['m3', 'Re: Re: Report', 'Re: Re: Report body', 'acc1'],
]);
expect(client.getThreadEmails).toHaveBeenCalledWith('t1', undefined);
// Only the newest 50 are fetched from the server.
expect(client.getThreadEmails).toHaveBeenCalledWith('t1', undefined, { newest: 50 });
});
it('reads a shared account\'s conversation from that account', async () => {
const thread = await call(ASSISTANT, 's1', { accountId: 'shared-acc' });
expect(thread.map((e) => [e.id, e.accountId])).toEqual([['s1', 'shared-acc'], ['s2', 'shared-acc']]);
expect(client.getThreadEmails).toHaveBeenCalledWith('ts', 'shared-acc');
expect(client.getThreadEmails).toHaveBeenCalledWith('ts', 'shared-acc', { newest: 50 });
});
it('refuses an account this login does not show, and a missing message', async () => {
@@ -112,3 +122,40 @@ describe('api.email.getThread', () => {
await expect(call(ASSISTANT, 'm1')).rejects.toThrow(/too many reads/);
});
});
describe('api.email.getThreadCount', () => {
const count = (p: InstalledPlugin, ...args: unknown[]) => dispatchApiCall(p, 'email.getThreadCount', args) as Promise<number>;
it('is behind email:read and offered in the runtime', async () => {
expect(PERM_PER_METHOD['email.getThreadCount']).toBe('email:read');
await expect(count(plugin(['email:blob-read']), 'm2')).rejects.toThrow(/lacks permission "email:read"/);
const calls: unknown[][] = [];
const api = withAssistantRuntime({} as { email?: { getThreadCount?: (...a: unknown[]) => unknown } }, async (method, args) => { calls.push([method, args]); return 0; });
await api.email?.getThreadCount?.('m2');
expect(calls).toEqual([['email.getThreadCount', ['m2', undefined]]]);
});
it('counts the messages of the conversation without reading any of them', async () => {
expect(await count(ASSISTANT, 'm2')).toBe(3);
expect(client.getThread).toHaveBeenCalledWith('t1', undefined);
expect(client.getEmail).not.toHaveBeenCalled();
expect(client.getThreadEmails).not.toHaveBeenCalled();
// A message without a thread is a conversation of one.
expect(await count(ASSISTANT, 'lonely')).toBe(1);
});
it('counts in a shared account there, and refuses accounts this login does not show', async () => {
expect(await count(ASSISTANT, 's1', { accountId: 'shared-acc' })).toBe(2);
expect(client.getThread).toHaveBeenCalledWith('ts', 'shared-acc');
await expect(count(ASSISTANT, 'm2', { accountId: 'stranger' })).rejects.toThrow(/no message with id "m2"/);
expect(client.getSomeEmails).not.toHaveBeenCalledWith(['m2'], 'stranger');
await expect(count(ASSISTANT, 'nope')).rejects.toThrow(/no message with id "nope"/);
});
it('has its own limit of 120 a minute, apart from getThread\'s', async () => {
for (let i = 0; i < 60; i++) await call(ASSISTANT, 'm1');
await expect(call(ASSISTANT, 'm1')).rejects.toThrow(/too many reads/);
for (let i = 0; i < 120; i++) await count(ASSISTANT, 'm1');
await expect(count(ASSISTANT, 'm1')).rejects.toThrow(/too many reads/);
});
});
+2 -1
View File
@@ -404,7 +404,8 @@ export interface IJMAPClient {
// ── Threads ───────────────────────────────────────────────────
getThread(threadId: string, accountId?: string): Promise<Thread | null>;
getThreads(threadIds: string[], accountId?: string): Promise<Thread[]>;
getThreadEmails(threadId: string, accountId?: string): Promise<Email[]>;
/** `newest`: only that many of the thread's newest messages are fetched (Thread/get lists them oldest first). */
getThreadEmails(threadId: string, accountId?: string, options?: { newest?: number }): Promise<Email[]>;
// ── Compose / Send ────────────────────────────────────────────
createDraft(
+4 -2
View File
@@ -4609,17 +4609,19 @@ export class JMAPClient implements IJMAPClient {
}
}
async getThreadEmails(threadId: string, accountId?: string): Promise<Email[]> {
async getThreadEmails(threadId: string, accountId?: string, options?: { newest?: number }): Promise<Email[]> {
try {
const targetAccountId = accountId || this.accountId;
const thread = await this.getThread(threadId, accountId);
if (!thread?.emailIds?.length) {
return [];
}
// Thread/get lists a thread's messages oldest first (RFC 8621 3.1).
const ids = options?.newest !== undefined && options.newest > 0 ? thread.emailIds.slice(-options.newest) : thread.emailIds;
const emails: Email[] = [];
for (const batchIds of batched(thread.emailIds, this.getMaxObjectsInGet())) {
for (const batchIds of batched(ids, this.getMaxObjectsInGet())) {
const response = await this.request([
["Email/get", {
accountId: targetAccountId,
@@ -30,6 +30,12 @@ export function runtime(callApi: CallApi): RuntimeAdditions {
*/
getThread: (emailId: string, options?: { accountId?: string }) =>
callApi('email.getThread', [emailId, options], NETWORK_API_TIMEOUT_MS) as Promise<EmailReadView[]>,
/**
* How many messages the conversation of a message has, without
* reading them (email:read, 120 a minute, apart from getThread's).
*/
getThreadCount: (emailId: string, options?: { accountId?: string }) =>
callApi('email.getThreadCount', [emailId, options]) as Promise<number>,
},
};
}
+28 -2
View File
@@ -8,7 +8,7 @@ import type { Attachment, Email } from '../../jmap/types';
import { NOT_MINE } from './not-mine';
import type { AreaHostFeaturesB, AttachmentImageView, AttachmentTextView } from './b.types';
import { attachmentExtras, cidAltTexts } from './b.projection';
import { readNamedEmail } from '../host-api-mail';
import { readNamedEmail, sourceAccount } from '../host-api-mail';
import { readBoolean, readId, readInteger, readObject, sessionClient, withinRate } from '../host-api-input';
// The readers (pdf.js, the Office reader) load only when an attachment is read.
import { DEFAULT_TEXT_CHARS, MAX_TEXT_CHARS, MAX_TEXT_SOURCE_BYTES } from '@/lib/attachment-text/types';
@@ -21,6 +21,7 @@ export const PERMS: Readonly<Record<string, Permission | null>> = {
'email.getAttachmentText': 'email:blob-read',
'email.getAttachmentImage': 'email:blob-read',
'email.getThread': 'email:read',
'email.getThreadCount': 'email:read',
};
/** Attachment reads (text) a plugin may make per minute. */
@@ -29,6 +30,8 @@ export const MAX_ATTACHMENT_TEXT_PER_MINUTE = 30;
export const MAX_ATTACHMENT_IMAGE_PER_MINUTE = 30;
/** Conversations a plugin may read per minute. */
export const MAX_THREADS_PER_MINUTE = 60;
/** Conversation sizes a plugin may ask for per minute (a panel probing whether to offer a thread read). */
export const MAX_THREAD_COUNTS_PER_MINUTE = 120;
/** Most messages api.email.getThread returns (the newest of a longer conversation). */
export const MAX_THREAD_MESSAGES = 50;
@@ -156,7 +159,8 @@ export async function doEmailGetThread(plugin: InstalledPlugin, emailIdArg: unkn
const found = await readNamedEmail(client, emailId, method, opts.accountId);
if (!found) throw new Error(`${method}: no message with id "${emailId}"`);
const accountId = found.accountId ?? client.getAccountId();
const emails = found.email.threadId ? await client.getThreadEmails(found.email.threadId, found.accountId) : [];
// Only the newest messages are fetched, with their bodies; a thread of thousands stays one small read.
const emails = found.email.threadId ? await client.getThreadEmails(found.email.threadId, found.accountId, { newest: MAX_THREAD_MESSAGES }) : [];
// A server that knows no thread for it: the message on its own.
const list = emails.length ? emails : [found.email];
return list
@@ -166,12 +170,34 @@ export async function doEmailGetThread(plugin: InstalledPlugin, emailIdArg: unkn
.map((email) => emailToReadView(email, accountId));
}
/**
* How many messages the conversation of `emailId` has (Thread/get; no
* message bodies are read). 1 for a message without a thread. Routed like
* api.email.get; its own rate limit, so probing it never uses up getThread's.
*/
export async function doEmailGetThreadCount(plugin: InstalledPlugin, emailIdArg: unknown, optionsArg?: unknown): Promise<number> {
const method = 'email.getThreadCount';
const emailId = readId(emailIdArg, method, 'emailId');
const opts = readObject(optionsArg, method, 'options', ['accountId'], true);
if (!withinRate(method, plugin.id, MAX_THREAD_COUNTS_PER_MINUTE)) throw new Error(`${method}: too many reads, try again in a minute`);
const client = sessionClient(method);
const accountId = await sourceAccount(client, emailId, method, opts.accountId);
if (accountId === null) throw new Error(`${method}: no message with id "${emailId}"`);
// The summary only (no bodies), for its thread id.
const [email] = await client.getSomeEmails([emailId], accountId);
if (!email) throw new Error(`${method}: no message with id "${emailId}"`);
if (!email.threadId) return 1;
const thread = await client.getThread(email.threadId, accountId);
return Math.max(1, thread?.emailIds?.length ?? 1);
}
/** Runs a method of this area; NOT_MINE for any other. */
export async function dispatch(plugin: InstalledPlugin, method: string, args: unknown[]): Promise<unknown> {
switch (method) {
case 'email.getAttachmentText': return doEmailGetAttachmentText(plugin, args[0], args[1], args[2]);
case 'email.getAttachmentImage': return doEmailGetAttachmentImage(plugin, args[0], args[1], args[2]);
case 'email.getThread': return doEmailGetThread(plugin, args[0], args[1]);
case 'email.getThreadCount': return doEmailGetThreadCount(plugin, args[0], args[1]);
default: return NOT_MINE;
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ export interface AreaHostFeaturesB {
attachmentText?: boolean;
/** api.email.getAttachmentImage: an image attachment, shrunk for a model. */
attachmentImages?: boolean;
/** api.email.getThread: every message of a conversation. */
/** api.email.getThread: every message of a conversation; api.email.getThreadCount: how many there are. */
threads?: boolean;
/** The reader panel (email-detail-sidebar) gets the messages ticked in the list as `listSelection`. */
listSelection?: boolean;
+1 -1
View File
@@ -40,7 +40,7 @@ async function loadEmail(client: IJMAPClient, emailId: string, accountId?: strin
* The account to read `emailId` from: undefined for the signed-in account,
* null when it is not this login's to read.
*/
async function sourceAccount(client: IJMAPClient, emailId: string, method: string, accountArg: unknown): Promise<string | undefined | null> {
export async function sourceAccount(client: IJMAPClient, emailId: string, method: string, accountArg: unknown): Promise<string | undefined | null> {
const { emails } = useEmailStore.getState();
if (accountArg === undefined) {
// A listed message of a shared account is asked of the account that owns