From f8de35dd851b59f91825bb809ba3246f2bbb1ab0 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:38:07 +0100 Subject: [PATCH] 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. --- lib/__tests__/jmap-thread-newest.test.ts | 50 ++++++++++++++++++++++ lib/__tests__/plugin-host-thread.test.ts | 51 ++++++++++++++++++++++- lib/jmap/client-interface.ts | 3 +- lib/jmap/client.ts | 6 ++- lib/plugin-sandbox/assistant/b.runtime.ts | 6 +++ lib/plugin-sandbox/assistant/b.ts | 30 ++++++++++++- lib/plugin-sandbox/assistant/b.types.ts | 2 +- lib/plugin-sandbox/host-api-mail.ts | 2 +- 8 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 lib/__tests__/jmap-thread-newest.test.ts diff --git a/lib/__tests__/jmap-thread-newest.test.ts b/lib/__tests__/jmap-thread-newest.test.ts new file mode 100644 index 000000000..bc4df7be5 --- /dev/null +++ b/lib/__tests__/jmap-thread-newest.test.ts @@ -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; + beforeEach(() => { fetchSpy = vi.spyOn(globalThis, 'fetch'); }); + afterEach(() => { fetchSpy.mockRestore(); }); + + async function threadOf(ids: string[], options?: { newest?: number }): Promise { + 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']); + }); +}); diff --git a/lib/__tests__/plugin-host-thread.test.ts b/lib/__tests__/plugin-host-thread.test.ts index 4181cb2d1..c24cd1ead 100644 --- a/lib/__tests__/plugin-host-thread.test.ts +++ b/lib/__tests__/plugin-host-thread.test.ts @@ -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; + + 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/); + }); +}); diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index c5419f48e..aafab104e 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -404,7 +404,8 @@ export interface IJMAPClient { // ── Threads ─────────────────────────────────────────────────── getThread(threadId: string, accountId?: string): Promise; getThreads(threadIds: string[], accountId?: string): Promise; - getThreadEmails(threadId: string, accountId?: string): Promise; + /** `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; // ── Compose / Send ──────────────────────────────────────────── createDraft( diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 4893af357..e73666ae4 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -4609,17 +4609,19 @@ export class JMAPClient implements IJMAPClient { } } - async getThreadEmails(threadId: string, accountId?: string): Promise { + async getThreadEmails(threadId: string, accountId?: string, options?: { newest?: number }): Promise { 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, diff --git a/lib/plugin-sandbox/assistant/b.runtime.ts b/lib/plugin-sandbox/assistant/b.runtime.ts index 3862a2e6d..be96b52cb 100644 --- a/lib/plugin-sandbox/assistant/b.runtime.ts +++ b/lib/plugin-sandbox/assistant/b.runtime.ts @@ -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, + /** + * 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, }, }; } diff --git a/lib/plugin-sandbox/assistant/b.ts b/lib/plugin-sandbox/assistant/b.ts index dec46dda8..43e634446 100644 --- a/lib/plugin-sandbox/assistant/b.ts +++ b/lib/plugin-sandbox/assistant/b.ts @@ -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> = { '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 { + 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 { 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; } } diff --git a/lib/plugin-sandbox/assistant/b.types.ts b/lib/plugin-sandbox/assistant/b.types.ts index d55eeefaa..5775dbc33 100644 --- a/lib/plugin-sandbox/assistant/b.types.ts +++ b/lib/plugin-sandbox/assistant/b.types.ts @@ -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; diff --git a/lib/plugin-sandbox/host-api-mail.ts b/lib/plugin-sandbox/host-api-mail.ts index 9e3f4f8a4..e149d7ac0 100644 --- a/lib/plugin-sandbox/host-api-mail.ts +++ b/lib/plugin-sandbox/host-api-mail.ts @@ -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 { +export async function sourceAccount(client: IJMAPClient, emailId: string, method: string, accountArg: unknown): Promise { const { emails } = useEmailStore.getState(); if (accountArg === undefined) { // A listed message of a shared account is asked of the account that owns