mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: let plugins run a message-list search with a JMAP filter #1024
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user