From a191ef8b022ce18e37067aee7141c07cd349ad50 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:56:22 +0200 Subject: [PATCH] feat: let plugins search mail with api.email.search --- lib/__tests__/plugin-host-mail-read.test.ts | 24 +++++++++++ lib/plugin-sandbox/host-api-mail.ts | 48 +++++++++++++++++++-- lib/plugin-sandbox/host-api.ts | 4 +- lib/plugin-sandbox/runtime.tsx | 9 ++++ 4 files changed, 81 insertions(+), 4 deletions(-) diff --git a/lib/__tests__/plugin-host-mail-read.test.ts b/lib/__tests__/plugin-host-mail-read.test.ts index c5fcc1fb5..8e9723359 100644 --- a/lib/__tests__/plugin-host-mail-read.test.ts +++ b/lib/__tests__/plugin-host-mail-read.test.ts @@ -102,6 +102,30 @@ describe('api.email.get', () => { }); }); +describe('api.email.search', () => { + it('runs a JMAP filter across all mail, newest first, as summaries', async () => { + const list = await call(READER, 'email.search', { text: 'invoice', from: 'carol', after: '2026-10-01' }) as { id: string }[]; + expect(list.map((e) => e.id)).toEqual(['a', 'b']); + expect(list[0]).not.toHaveProperty('text'); + expect(client.getEmails).toHaveBeenCalledWith(undefined, undefined, 20, 0, undefined, false, { text: 'invoice', from: 'carol', after: '2026-10-01T00:00:00.000Z' }); + }); + + it('searches one folder, a shared one through its owner, with a subject and attachments', async () => { + await call(READER, 'email.search', { subject: 'offer', hasAttachment: true, mailboxId: 'acc2:box', limit: 5 }); + expect(client.getEmails).toHaveBeenCalledWith('box', 'acc2', 5, 0, undefined, false, { subject: 'offer', hasAttachment: true }); + }); + + it('wants a condition, a real date, at most 50 results, and email:read', async () => { + await expect(call(READER, 'email.search', {})).rejects.toThrow(/at least one of/); + await expect(call(READER, 'email.search', { text: ' ' })).rejects.toThrow(/at least one of/); + await expect(call(READER, 'email.search', { text: 'x', before: 'yesterday' })).rejects.toThrow(/before must be a date/); + await expect(call(READER, 'email.search', { text: 'x', limit: 51 })).rejects.toThrow(/limit must be a whole number from 1 to 50/); + await expect(call(READER, 'email.search', { body: 'x' })).rejects.toThrow(/unknown property "body"/); + await expect(call(plugin([]), 'email.search', { text: 'x' })).rejects.toThrow(/lacks permission "email:read"/); + expect(client.getEmails).not.toHaveBeenCalled(); + }); +}); + describe('api.email.list', () => { it('returns summaries of a folder page', async () => { const list = await call(READER, 'email.list', { mailboxId: 'inbox', limit: 2, position: 10 }) as { id: string }[]; diff --git a/lib/plugin-sandbox/host-api-mail.ts b/lib/plugin-sandbox/host-api-mail.ts index 9c27e41f7..209cd797d 100644 --- a/lib/plugin-sandbox/host-api-mail.ts +++ b/lib/plugin-sandbox/host-api-mail.ts @@ -13,9 +13,7 @@ import type { Email, Mailbox } from '../jmap/types'; import type { IJMAPClient } from '../jmap/client-interface'; import { resolveEmailBlobContext, useEmailStore } from '@/stores/email-store'; import { emailSummary, emailToReadView } from '../plugin-projection'; -import { - MAX_PLUGIN_FETCH_BYTES, readId, readInteger, readObject, sessionClient, withinRate, -} from './host-api-input'; +import { MAX_PLUGIN_FETCH_BYTES, readBoolean, readId, readInteger, readObject, readString, sessionClient, withinRate } from './host-api-input'; /** Most messages one api.email.list call returns. */ export const MAX_PLUGIN_EMAIL_PAGE = 100; @@ -92,6 +90,50 @@ export async function doEmailList(options: unknown): Promise { return emails.map(emailSummary); } +/** Most messages one search returns. */ +export const MAX_PLUGIN_SEARCH_RESULTS = 50; +const SEARCH_TEXT_FIELDS = ['text', 'from', 'to', 'subject'] as const; +const SEARCH_DATE_FIELDS = ['after', 'before'] as const; + +/** + * Messages matching a JMAP filter, newest first: `text` (sender, recipients, + * 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). + */ +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 limit = opts.limit === undefined ? 20 : readInteger(opts.limit, method, 'limit', 1, MAX_PLUGIN_SEARCH_RESULTS); + const filter: Record = {}; + for (const key of SEARCH_TEXT_FIELDS) { + if (opts[key] === undefined) continue; + const value = readString(opts[key], method, key, 500).trim(); + if (value) filter[key] = value; + } + for (const key of SEARCH_DATE_FIELDS) { + if (opts[key] === undefined) continue; + const value = readString(opts[key], method, key, 40).trim(); + const date = new Date(value); + if (!value || Number.isNaN(date.getTime())) throw new Error(`${method}: ${key} must be a date like 2026-05-01`); + filter[key] = date.toISOString(); + } + if (opts.hasAttachment !== undefined) filter.hasAttachment = readBoolean(opts.hasAttachment, method, 'hasAttachment'); + if (Object.keys(filter).length === 0) throw new Error(`${method}: give at least one of text, from, to, subject, after, before or hasAttachment`); + const client = sessionClient(method); + let jmapMailboxId: string | undefined; + let accountId: string | undefined; + if (opts.mailboxId !== undefined) { + const id = readId(opts.mailboxId, method, 'mailboxId'); + const mailbox = (await knownMailboxes(client)).find((m) => m.id === id); + if (!mailbox) throw new Error(`${method}: no mailbox with id "${id}"`); + jmapMailboxId = mailbox.originalId || mailbox.id; + accountId = mailbox.isShared ? mailbox.accountId : undefined; + } + const { emails } = await client.getEmails(jmapMailboxId, accountId, limit, 0, undefined, false, filter); + return emails.map(emailSummary); +} + // ─── Bytes ──────────────────────────────────────────────────── /** Every attachment part of a message, nested ones included. */ diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 71c18d504..d058d9b01 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -47,7 +47,7 @@ import { doTemplatesUpdate, doVacationGet, doVacationSet, } from './host-api-settings'; import { - doEmailArchive, doEmailGet, doEmailGetAttachment, doEmailGetRaw, doEmailList, doEmailMove, doMailboxesList, + doEmailArchive, doEmailGet, doEmailGetAttachment, doEmailGetRaw, doEmailList, doEmailMove, doEmailSearch, doMailboxesList, } from './host-api-mail'; import { MAX_PLUGIN_FETCH_BYTES, readFileName, readMediaType, sessionClient } from './host-api-input'; import { doComposeOpen, doComposeReply, doDraftsCreate } from './host-api-compose'; @@ -248,6 +248,7 @@ export const PERM_PER_METHOD: Readonly> = { // reading mail: one message with its text, a page of a folder, the folders 'email.get': 'email:read', 'email.list': 'email:read', + 'email.search': 'email:read', 'mailboxes.list': 'email:read', // The bytes of one attachment of a given message, or of the message // itself. Unlike jmap.fetchBlob these only reach blobs of a message the @@ -2031,6 +2032,7 @@ export async function dispatchApiCall( case 'email.get': return doEmailGet(args[0]); case 'email.list': return doEmailList(args[0]); + case 'email.search': return doEmailSearch(args[0]); case 'mailboxes.list': return doMailboxesList(); case 'email.getAttachment': return doEmailGetAttachment(args[0], args[1]); case 'email.getRaw': return doEmailGetRaw(args[0]); diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 10fa7876f..a627aa1a1 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -596,6 +596,15 @@ function buildPluginApi(manifest: PluginManifest) { */ list: (options?: { mailboxId?: string; limit?: number; position?: number }) => callApi('email.list', [options]) as Promise, + /** + * Messages matching a search, newest first, as summaries without the + * 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). + */ + search: (query: { text?: string; from?: string; to?: string; subject?: string; after?: string; before?: string; hasAttachment?: boolean; mailboxId?: string; limit?: number }) => + callApi('email.search', [query]) as Promise, /** * The bytes of one attachment of a message (email:blob-read, at most * 25 MB). `blobId` must be one of that message's attachments, e.g.