From 22ffc36ca9de6204e26d83ae1c1b02bc7f00bf95 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:39:58 +0200 Subject: [PATCH] feat: let plugins run a message-list search with a JMAP filter #1024 --- .../search/__tests__/search-chips.test.tsx | 25 +++ components/search/search-chips.tsx | 3 + lib/__tests__/plugin-search-run.test.ts | 143 ++++++++++++++++++ lib/jmap/search-utils.ts | 48 +++++- lib/plugin-sandbox/host-api.ts | 34 +++++ lib/plugin-sandbox/protocol.ts | 2 + lib/plugin-sandbox/runtime.tsx | 9 ++ stores/message-list-tabs-store.ts | 21 +-- 8 files changed, 265 insertions(+), 20 deletions(-) create mode 100644 components/search/__tests__/search-chips.test.tsx create mode 100644 lib/__tests__/plugin-search-run.test.ts diff --git a/components/search/__tests__/search-chips.test.tsx b/components/search/__tests__/search-chips.test.tsx new file mode 100644 index 000000000..30aefe9ca --- /dev/null +++ b/components/search/__tests__/search-chips.test.tsx @@ -0,0 +1,25 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { SearchChips } from '../search-chips'; +import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; + +describe('SearchChips', () => { + it('shows a plugin search as a removable chip (#1024)', () => { + const onRemoveFilter = vi.fn(); + render( + {}} + />, + ); + + const chip = screen.getByText('From Alice').parentElement!; + fireEvent.click(chip.querySelector('button')!); + expect(onRemoveFilter).toHaveBeenCalledWith('pluginFilter'); + }); +}); diff --git a/components/search/search-chips.tsx b/components/search/search-chips.tsx index 8036f4746..545bbf404 100644 --- a/components/search/search-chips.tsx +++ b/components/search/search-chips.tsx @@ -67,6 +67,9 @@ export function SearchChips({ if (sizeFilterBytes(filters.maxSizeKb) !== null) { chips.push({ key: "maxSizeKb", label: t("size_max"), value: `${filters.maxSizeKb} KB` }); } + if (filters.pluginFilter) { + chips.push({ key: "pluginFilter", label: filters.pluginFilter.label, value: "" }); + } if (chips.length === 0) return null; diff --git a/lib/__tests__/plugin-search-run.test.ts b/lib/__tests__/plugin-search-run.test.ts new file mode 100644 index 000000000..ab3ec3df2 --- /dev/null +++ b/lib/__tests__/plugin-search-run.test.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; +import type { InstalledPlugin } from '../plugin-types'; +import type { IJMAPClient } from '../jmap/client-interface'; +import type { Mailbox } from '../jmap/types'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from '../jmap/search-utils'; + +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'; + +function plugin(overrides: Partial = {}): InstalledPlugin { + return { + id: 'related', + name: 'Related mail', + version: '1.0.0', + author: 'test', + description: '', + type: 'hook', + permissions: ['email:read'], + grantedPermissions: ['email:read'], + entrypoint: 'index.js', + enabled: true, + status: 'running', + settings: {}, + ...overrides, + }; +} + +const inbox = { + id: 'mb-inbox', name: 'Inbox', role: 'inbox', sortOrder: 0, + totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, +} as unknown as Mailbox; + +const QUERY = { + operator: 'AND', + conditions: [ + { from: 'alice@example.com' }, + { header: ['List-Id'] }, + { notKeyword: '$seen' }, + ], +}; + +let advancedSearchEmails: ReturnType; + +beforeEach(() => { + advancedSearchEmails = vi.fn(async () => ({ + emails: [{ id: 'e1', threadId: 't1', mailboxIds: { 'mb-inbox': true }, keywords: {} }], + hasMore: false, + total: 1, + })); + const client = { advancedSearchEmails } as unknown as IJMAPClient; + useAuthStore.setState({ client }); + useEmailStore.setState({ + mailboxes: [inbox], + selectedMailbox: 'mb-inbox', + // A folder picked in the search panel: the search stays in it. + searchMailboxId: 'mb-inbox', + searchQuery: 'typed text', + searchFilters: { ...DEFAULT_SEARCH_FILTERS, subject: 'old' }, + emails: [], + }); +}); + +afterEach(() => { + useAuthStore.setState({ client: null }); + useEmailStore.setState({ + searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, + searchMailboxId: '', + emails: [], + mailboxes: [], + }); +}); + +describe('api.search.run (#1024)', () => { + it('needs email:read', async () => { + await expect(dispatchApiCall(plugin({ permissions: [], grantedPermissions: [] }), 'search.run', [QUERY])) + .rejects.toThrow(/lacks permission "email:read"/); + }); + + it('runs the filter as the list search, replacing the user search', async () => { + await dispatchApiCall(plugin(), 'search.run', [QUERY]); + + expect(advancedSearchEmails).toHaveBeenCalledTimes(1); + expect(advancedSearchEmails.mock.calls[0][0]).toEqual({ + operator: 'AND', + conditions: [QUERY, { inMailbox: 'mb-inbox' }], + }); + + const state = useEmailStore.getState(); + expect(state.searchQuery).toBe(''); + expect(state.searchFilters.subject).toBe(''); + expect(state.searchFilters.pluginFilter).toEqual({ pluginId: 'related', label: 'Related mail', filter: QUERY }); + expect(state.emails.map((e) => e.id)).toEqual(['e1']); + }); + + it('uses the label the plugin passes for the chip', async () => { + await dispatchApiCall(plugin(), 'search.run', [QUERY, { label: ' From Alice ' }]); + expect(useEmailStore.getState().searchFilters.pluginFilter?.label).toBe('From Alice'); + }); + + it('stores a plain copy, so later changes to the object do not reach the request', async () => { + const query = { from: 'alice@example.com' }; + await dispatchApiCall(plugin(), 'search.run', [query]); + query.from = 'mallory@example.com'; + expect(useEmailStore.getState().searchFilters.pluginFilter?.filter).toEqual({ from: 'alice@example.com' }); + }); + + it.each([ + ['an array', [[{ from: 'a' }]], /must be a JMAP filter object/], + ['a string', ['from:alice'], /must be a JMAP filter object/], + ['an empty filter', [{}], /must not be empty/], + ['an oversized filter', [{ text: 'x'.repeat(17 * 1024) }], /exceeds/], + ['a non-string label', [{ from: 'a' }, { label: 5 }], /label must be a string/], + ])('rejects %s without touching the search', async (_name, args, error) => { + await expect(dispatchApiCall(plugin(), 'search.run', args)).rejects.toThrow(error); + expect(advancedSearchEmails).not.toHaveBeenCalled(); + expect(useEmailStore.getState().searchQuery).toBe('typed text'); + }); +}); + +describe('plugin filter in SearchFilters', () => { + const pluginFilter = { pluginId: 'related', label: 'Related', filter: { from: 'alice@example.com' } }; + + it('counts as an active search and is ANDed with the other fields', () => { + const filters = { ...DEFAULT_SEARCH_FILTERS, isUnread: true, pluginFilter }; + expect(isFilterEmpty(filters)).toBe(false); + expect(buildJMAPFilter('', filters, undefined)).toEqual({ + operator: 'AND', + conditions: [{ notKeyword: '$seen' }, { from: 'alice@example.com' }], + }); + }); + + it('is gone after the filters are cleared', () => { + useEmailStore.setState({ searchFilters: { ...DEFAULT_SEARCH_FILTERS, pluginFilter } }); + useEmailStore.getState().clearSearchFilters(); + expect(isFilterEmpty(useEmailStore.getState().searchFilters)).toBe(true); + }); +}); diff --git a/lib/jmap/search-utils.ts b/lib/jmap/search-utils.ts index 19204a867..0aea1cb2d 100644 --- a/lib/jmap/search-utils.ts +++ b/lib/jmap/search-utils.ts @@ -11,6 +11,18 @@ export interface SearchFilters { /** Message size bounds in KB (RFC 8621 §4.4.1 minSize / maxSize); "" = unset. */ minSizeKb: string; maxSizeKb: string; + /** + * A raw JMAP Email filter a plugin ran via `api.search.run` (#1024). ANDed + * into the search like the fields above and cleared with them. + */ + pluginFilter?: PluginSearchFilter | null; +} + +export interface PluginSearchFilter { + pluginId: string; + /** Chip text: the plugin's label for the search, or its name. */ + label: string; + filter: Record; } export const DEFAULT_SEARCH_FILTERS: SearchFilters = { @@ -25,8 +37,36 @@ export const DEFAULT_SEARCH_FILTERS: SearchFilters = { isStarred: null, minSizeKb: "", maxSizeKb: "", + pluginFilter: null, }; +// Upper bound on a serialized plugin filter. A filter tree bigger than this +// is either a bug or an abuse vector for the JMAP request budget. +export const MAX_PLUGIN_FILTER_JSON_BYTES = 16 * 1024; + +/** + * Check a plugin-supplied JMAP Email FilterCondition/FilterOperator and return + * a plain-data copy. `what` names the value in the error message, e.g. + * `search.run: query`. + */ +export function normalizePluginFilter(what: string, query: unknown): Record { + if (typeof query !== "object" || query === null || Array.isArray(query)) { + throw new Error(`${what} must be a JMAP filter object`); + } + let json: string; + try { + json = JSON.stringify(query); + } catch { + throw new Error(`${what} is not serializable`); + } + if (json.length > MAX_PLUGIN_FILTER_JSON_BYTES) { + throw new Error(`${what} exceeds ${MAX_PLUGIN_FILTER_JSON_BYTES} bytes`); + } + // Deep-clone via JSON so the stored filter is plain data (no prototypes, + // functions or getters can cross into request bodies). + return JSON.parse(json) as Record; +} + /** The KB size field as a positive byte count, or null when unset/invalid. */ export function sizeFilterBytes(value: string | undefined): number | null { const kb = Number(value); @@ -107,6 +147,10 @@ export function buildJMAPFilter( conditions.push({ maxSize }); } + if (filters.pluginFilter) { + conditions.push(filters.pluginFilter.filter); + } + if (mailboxId) { conditions.push({ inMailbox: mailboxId }); } @@ -149,7 +193,8 @@ export function isFilterEmpty(filters: SearchFilters): boolean { filters.isUnread === null && filters.isStarred === null && sizeFilterBytes(filters.minSizeKb) === null && - sizeFilterBytes(filters.maxSizeKb) === null + sizeFilterBytes(filters.maxSizeKb) === null && + !filters.pluginFilter ); } @@ -166,5 +211,6 @@ export function activeFilterCount(filters: SearchFilters): number { if (filters.isStarred !== null) count++; if (sizeFilterBytes(filters.minSizeKb) !== null) count++; if (sizeFilterBytes(filters.maxSizeKb) !== null) count++; + if (filters.pluginFilter) count++; return count; } diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 74cc159f3..cec33a952 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -25,6 +25,7 @@ import { DEFAULT_KEYWORD_SCAN_LIMIT } from '../jmap/client'; import { suggestKeywordColor } from '../keyword-discovery'; import { MAX_KEYWORD_LENGTH } from '../keyword-nesting'; import { KEYWORD_PREFIX } from '../thread-utils'; +import { DEFAULT_SEARCH_FILTERS, normalizePluginFilter } from '../jmap/search-utils'; import { awaitDialog, awaitPrompt, awaitCustomDialog, type PromptField } from './host-dialog'; import { fileStorage } from '../plugin-storage'; import { generateUUID } from '../utils'; @@ -159,6 +160,9 @@ const PERM_PER_METHOD: Record = { // categorize rewrites message keywords, so it needs the write permission // (a tabs-only plugin can still render tabs without it). 'tabs.categorize': 'email:write', + // message-list search: the plugin never sees the results, but it decides + // which messages the list shows. + 'search.run': 'email:read', // sieve (delivery-time classification) 'sieve.isSupported': 'filters:read', 'sieve.getActiveScript': 'filters:read', @@ -1340,6 +1344,34 @@ async function doTabsCategorize(emailIds: unknown, tabId: unknown): Promise { + const filter = normalizePluginFilter('search.run: query', query); + if (Object.keys(filter).length === 0) throw new Error('search.run: query must not be empty'); + const rawLabel = options && typeof options === 'object' ? (options as { label?: unknown }).label : undefined; + if (rawLabel !== undefined && typeof rawLabel !== 'string') { + throw new Error('search.run: label must be a string'); + } + const label = (rawLabel?.trim() || plugin.name).slice(0, MAX_SEARCH_LABEL_LENGTH); + + const client = requireClient(); + const store = useEmailStore.getState(); + store.setSearchQuery(''); + store.setSearchFilters({ + ...DEFAULT_SEARCH_FILTERS, + pluginFilter: { pluginId: plugin.id, label, filter }, + }); + await store.advancedSearch(client); +} + // ─── Sieve (delivery-time classification) ───────────────────── async function doSieveGetActiveScript(): Promise<{ id: string; name: string; content: string } | null> { @@ -1636,6 +1668,8 @@ export async function dispatchApiCall( case 'tabs.refreshCounts': return doTabsRefreshCounts(); case 'tabs.categorize': return doTabsCategorize(args[0], args[1]); + case 'search.run': return doSearchRun(plugin, args[0], args[1]); + case 'sieve.isSupported': { const { client } = useAuthStore.getState(); return !!client?.supportsSieve(); diff --git a/lib/plugin-sandbox/protocol.ts b/lib/plugin-sandbox/protocol.ts index ae32ab431..06122ce65 100644 --- a/lib/plugin-sandbox/protocol.ts +++ b/lib/plugin-sandbox/protocol.ts @@ -257,6 +257,8 @@ export const API_METHODS = [ 'keywords.list', 'keywords.add', 'keywords.reorder', 'keywords.discover', 'keywords.getCounts', 'keywords.refreshCounts', // Message-list category tabs (Gmail-style inbox tabs). 'tabs.set', 'tabs.clear', 'tabs.getState', 'tabs.categorize', 'tabs.refreshCounts', + // Run a JMAP Email filter as the message-list search. + 'search.run', // Sieve integration for delivery-time classification plugins. 'sieve.isSupported', 'sieve.getActiveScript', 'sieve.validateScript', 'sieve.regenerate', ] as const; diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index a30dc9fee..cd377451e 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -439,6 +439,15 @@ function buildPluginApi(manifest: PluginManifest) { categorize: (emailIds: string[], tabId: string) => callApi('tabs.categorize', [emailIds, tabId]) as Promise, }, + // Message-list search (permission: email:read). Runs a JMAP Email + // FilterCondition/FilterOperator the way a user's search runs: in the + // scope picked in the search panel, shown as a chip, cleared with the + // search. Same contract as a tab's `query`. The plugin gets no results + // back; the list shows them. + search: { + run: (query: unknown, options?: { label?: string }) => + callApi('search.run', [query, options]) as Promise, + }, // Sieve integration for delivery-time classification (permissions: // filters:read / filters:write). Plugins never write scripts directly - // they register an onSieveScriptGenerate transform hook and call diff --git a/stores/message-list-tabs-store.ts b/stores/message-list-tabs-store.ts index 8bae86d2f..25e8e7591 100644 --- a/stores/message-list-tabs-store.ts +++ b/stores/message-list-tabs-store.ts @@ -16,6 +16,7 @@ import type { MessageListTab, MessageListTabsConfig } from '@/lib/plugin-types'; import { MAX_MESSAGE_LIST_TABS } from '@/lib/plugin-types'; import { messageListTabHooks } from '@/lib/plugin-hooks'; import { keywordPointer } from '@/lib/jmap/patch-pointer'; +import { normalizePluginFilter } from '@/lib/jmap/search-utils'; import { useEmailStore } from './email-store'; // ─── Validation ────────────────────────────────────────────── @@ -32,26 +33,8 @@ const TAB_ID_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/i; // case-insensitively by IMAP servers, so we lowercase on normalization. const KEYWORD_RE = /^\$[a-z0-9][a-z0-9_.-]{0,63}$/; -// Upper bound on a serialized tab query — a filter tree bigger than this is -// either a bug or an abuse vector for the JMAP request budget. -const MAX_QUERY_JSON_BYTES = 16 * 1024; - function validateTabQuery(tabId: string, query: unknown): Record { - if (typeof query !== 'object' || query === null || Array.isArray(query)) { - throw new Error(`tabs.set: tab "${tabId}" query must be a JMAP filter object`); - } - let json: string; - try { - json = JSON.stringify(query); - } catch { - throw new Error(`tabs.set: tab "${tabId}" query is not serializable`); - } - if (json.length > MAX_QUERY_JSON_BYTES) { - throw new Error(`tabs.set: tab "${tabId}" query exceeds ${MAX_QUERY_JSON_BYTES} bytes`); - } - // Deep-clone via JSON so the stored filter is plain data (no prototypes, - // functions or getters can cross into request bodies). - return JSON.parse(json) as Record; + return normalizePluginFilter(`tabs.set: tab "${tabId}" query`, query); } /**