feat: let plugins page through mail search results

This commit is contained in:
Linus Rath
2026-11-02 22:13:59 +01:00
parent 8139214aea
commit f1980821f5
3 changed files with 62 additions and 4 deletions
@@ -0,0 +1,56 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { InstalledPlugin } from '../plugin-types';
import type { IJMAPClient } from '../jmap/client-interface';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
vi.mock('@/stores/toast-store', () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
}));
import { dispatchApiCall } from '../plugin-sandbox/host-api';
// api.email.search pages with `position`, so a plugin can go past the
// first 50 results (the assistant's batches of more than 50 messages).
const READER = {
id: 'reader', name: 'Reader', version: '1.0.0', author: 'test', description: '',
type: 'hook', permissions: ['email:read'], grantedPermissions: ['email:read'], entrypoint: 'index.js',
enabled: true, status: 'running', settings: {},
} as InstalledPlugin;
const search = (query: unknown) => dispatchApiCall(READER, 'email.search', [query]);
let client: Record<string, ReturnType<typeof vi.fn>>;
beforeEach(() => {
client = {
getEmails: vi.fn(async () => ({ emails: [], hasMore: false, total: 0 })),
getMailboxes: vi.fn(async () => []),
getAccountId: vi.fn(() => 'acc1'),
};
useAuthStore.setState({ client: client as unknown as IJMAPClient });
useEmailStore.setState({ mailboxes: [], selectedEmail: null });
});
afterEach(() => {
useAuthStore.setState({ client: null });
});
describe('api.email.search position', () => {
it('asks the server for the page after `position` results', async () => {
await search({ text: 'invoice', limit: 50, position: 50 });
expect(client.getEmails).toHaveBeenCalledWith(undefined, undefined, 50, 50, undefined, false, { text: 'invoice' });
});
it('starts at the first result without it', async () => {
await search({ text: 'invoice' });
expect(client.getEmails).toHaveBeenCalledWith(undefined, undefined, 20, 0, undefined, false, { text: 'invoice' });
});
it('takes whole numbers from 0 to 1,000,000 only', async () => {
await expect(search({ text: 'x', position: -1 })).rejects.toThrow(/position must be a whole number from 0 to 1000000/);
await expect(search({ text: 'x', position: 1.5 })).rejects.toThrow(/position must be a whole number/);
await expect(search({ text: 'x', position: '10' })).rejects.toThrow(/position must be a whole number/);
expect(client.getEmails).not.toHaveBeenCalled();
});
});
+4 -2
View File
@@ -147,11 +147,13 @@ const SEARCH_DATE_FIELDS = ['after', 'before'] as const;
* subject and body, whole words), `from`, `to`, `subject`, `after` and
* `before` (dates), `hasAttachment`, in one folder with `mailboxId`. At
* least one condition; summaries without the body (api.email.get reads one).
* `position` skips that many results, to page past the first `limit`.
*/
export async function doEmailSearch(options: unknown): Promise<EmailSummary[]> {
const method = 'email.search';
const opts = readObject(options, method, 'query', [...SEARCH_TEXT_FIELDS, ...SEARCH_DATE_FIELDS, 'hasAttachment', 'mailboxId', 'limit']);
const opts = readObject(options, method, 'query', [...SEARCH_TEXT_FIELDS, ...SEARCH_DATE_FIELDS, 'hasAttachment', 'mailboxId', 'limit', 'position']);
const limit = opts.limit === undefined ? 20 : readInteger(opts.limit, method, 'limit', 1, MAX_PLUGIN_SEARCH_RESULTS);
const position = opts.position === undefined ? 0 : readInteger(opts.position, method, 'position', 0, 1_000_000);
const filter: Record<string, unknown> = {};
for (const key of SEARCH_TEXT_FIELDS) {
if (opts[key] === undefined) continue;
@@ -177,7 +179,7 @@ export async function doEmailSearch(options: unknown): Promise<EmailSummary[]> {
jmapMailboxId = mailbox.originalId || mailbox.id;
accountId = mailbox.isShared ? mailbox.accountId : undefined;
}
const { emails } = await client.getEmails(jmapMailboxId, accountId, limit, 0, undefined, false, filter);
const { emails } = await client.getEmails(jmapMailboxId, accountId, limit, position, undefined, false, filter);
return emails.map((email) => emailSummary(email, accountId ?? client.getAccountId()));
}
+2 -2
View File
@@ -828,9 +828,9 @@ function buildCoreApi(manifest: PluginManifest) {
* body: `text` (sender, recipients, subject and body, whole words),
* `from`, `to`, `subject`, `after`/`before` (dates), `hasAttachment`,
* in one folder with `mailboxId`. At least one condition; limit 1-50
* (default 20).
* (default 20); `position` skips that many results (paging).
*/
search: (query: { text?: string; from?: string; to?: string; subject?: string; after?: string; before?: string; hasAttachment?: boolean; mailboxId?: string; limit?: number }) =>
search: (query: { text?: string; from?: string; to?: string; subject?: string; after?: string; before?: string; hasAttachment?: boolean; mailboxId?: string; limit?: number; position?: number }) =>
callApi('email.search', [query]) as Promise<EmailSummary[]>,
/**
* The bytes of one attachment of a message (email:blob-read, at most