feat: let plugins search mail with api.email.search

This commit is contained in:
Linus Rath
2026-10-20 18:16:29 +02:00
parent 1d9e908e63
commit a191ef8b02
4 changed files with 81 additions and 4 deletions
@@ -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 }[];
+45 -3
View File
@@ -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<EmailSummary[]> {
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<EmailSummary[]> {
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<string, unknown> = {};
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. */
+3 -1
View File
@@ -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<Record<string, Permission | null>> = {
// 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]);
+9
View File
@@ -596,6 +596,15 @@ function buildPluginApi(manifest: PluginManifest) {
*/
list: (options?: { mailboxId?: string; limit?: number; position?: number }) =>
callApi('email.list', [options]) as Promise<EmailSummary[]>,
/**
* 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<EmailSummary[]>,
/**
* 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.