mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 00:14:12 +00:00
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:
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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>,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user