diff --git a/lib/__tests__/plugin-host-search-position.test.ts b/lib/__tests__/plugin-host-search-position.test.ts new file mode 100644 index 000000000..452a2ea20 --- /dev/null +++ b/lib/__tests__/plugin-host-search-position.test.ts @@ -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>; + +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(); + }); +}); diff --git a/lib/plugin-sandbox/host-api-mail.ts b/lib/plugin-sandbox/host-api-mail.ts index bf36629e7..9e3f4f8a4 100644 --- a/lib/plugin-sandbox/host-api-mail.ts +++ b/lib/plugin-sandbox/host-api-mail.ts @@ -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 { 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 = {}; for (const key of SEARCH_TEXT_FIELDS) { if (opts[key] === undefined) continue; @@ -177,7 +179,7 @@ export async function doEmailSearch(options: unknown): Promise { 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())); } diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 64e65bb8a..bf8569a6d 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -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, /** * The bytes of one attachment of a message (email:blob-read, at most