feat: let plugins run a message-list search with a JMAP filter #1024

This commit is contained in:
Linus Rath
2026-10-11 19:39:58 +02:00
parent 67c5191b2c
commit 22ffc36ca9
8 changed files with 265 additions and 20 deletions
@@ -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(
<SearchChips
filters={{
...DEFAULT_SEARCH_FILTERS,
pluginFilter: { pluginId: 'related', label: 'From Alice', filter: { from: 'alice@example.com' } },
}}
onRemoveFilter={onRemoveFilter}
onClearAll={() => {}}
/>,
);
const chip = screen.getByText('From Alice').parentElement!;
fireEvent.click(chip.querySelector('button')!);
expect(onRemoveFilter).toHaveBeenCalledWith('pluginFilter');
});
});
+3
View File
@@ -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;
+143
View File
@@ -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> = {}): 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<typeof vi.fn>;
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);
});
});
+47 -1
View File
@@ -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<string, unknown>;
}
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<string, unknown> {
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<string, unknown>;
}
/** 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;
}
+34
View File
@@ -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<string, Permission | null> = {
// 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<bool
return moved;
}
// ─── Message-list search ──────────────────────────────────────
const MAX_SEARCH_LABEL_LENGTH = 64;
/**
* Run a plugin's JMAP Email filter as the message-list search (#1024). It
* replaces the current search like a user's search does, keeps the scope
* picked in the search panel, and shows up as a removable chip.
*/
async function doSearchRun(plugin: InstalledPlugin, query: unknown, options: unknown): Promise<void> {
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();
+2
View File
@@ -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;
+9
View File
@@ -439,6 +439,15 @@ function buildPluginApi(manifest: PluginManifest) {
categorize: (emailIds: string[], tabId: string) =>
callApi('tabs.categorize', [emailIds, tabId]) as Promise<boolean>,
},
// 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<void>,
},
// Sieve integration for delivery-time classification (permissions:
// filters:read / filters:write). Plugins never write scripts directly -
// they register an onSieveScriptGenerate transform hook and call
+2 -19
View File
@@ -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<string, unknown> {
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<string, unknown>;
return normalizePluginFilter(`tabs.set: tab "${tabId}" query`, query);
}
/**