mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
Merge pull request #1338 from HOLOEYE-JZ/feat/plugin-search-results
This commit is contained in:
@@ -37,7 +37,7 @@ const sources = ['app', 'components', 'contexts', 'hooks', 'lib', 'stores'].flat
|
||||
|
||||
/** A call site that fires the hook: `xHooks.onFoo.emit(` and friends. */
|
||||
function isFired(hookName: string, groupName: string): boolean {
|
||||
const re = new RegExp(`\\b${groupName}\\.${hookName}\\s*\\.(emit|emitSync|emitTo|intercept|interceptOutcome|interceptWithValue|transform|takeOver)\\b`);
|
||||
const re = new RegExp(`\\b${groupName}\\.${hookName}\\s*\\.(emit|emitSync|emitTo|intercept|interceptOutcome|interceptWithValue|transform|takeOver|collect)\\b`);
|
||||
return re.test(sources);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { checkSearchResults, MAX_RESULTS_PER_PLUGIN, provideSearchResults } from '../plugin-search-results';
|
||||
import { clearAllHooks, emailHooks } from '../plugin-hooks';
|
||||
|
||||
afterEach(() => {
|
||||
clearAllHooks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('checkSearchResults', () => {
|
||||
it('keeps entries with a key and a title, as plain text, with the plugin', () => {
|
||||
expect(checkSearchResults('p', [
|
||||
{ key: 'a', title: ' Pixel grid\n too coarse ', snippet: 'For our AR prototype', source: 'CRM', onClick: 'x', extra: 1 },
|
||||
])).toEqual([{ pluginId: 'p', key: 'a', title: 'Pixel grid too coarse', snippet: 'For our AR prototype', source: 'CRM' }]);
|
||||
});
|
||||
|
||||
it('drops what is not an entry, and a key given twice', () => {
|
||||
expect(checkSearchResults('p', [
|
||||
null, 'text', { title: 'no key' }, { key: 'a' }, { key: 7, title: 'number key' },
|
||||
{ key: 'b', title: 'B' }, { key: 'b', title: 'B again' },
|
||||
]).map((r) => r.key)).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('keeps the message an entry names, with its account', () => {
|
||||
expect(checkSearchResults('p', [
|
||||
{ key: 'a', title: 'A', email: { id: 'M1', accountId: 'group-1', extra: true } },
|
||||
{ key: 'b', title: 'B', email: { id: 'M2' } },
|
||||
]).map((r) => r.email)).toEqual([{ id: 'M1', accountId: 'group-1' }, { id: 'M2' }]);
|
||||
});
|
||||
|
||||
it('keeps an entry whose message is not one, without it', () => {
|
||||
const checked = checkSearchResults('p', [
|
||||
{ key: 'a', title: 'A', email: 'M1' },
|
||||
{ key: 'b', title: 'B', email: { id: '' } },
|
||||
{ key: 'c', title: 'C', email: { id: 'M3', accountId: 7 } },
|
||||
{ key: 'd', title: 'D', email: { id: 'x'.repeat(256) } },
|
||||
]);
|
||||
expect(checked.map((r) => r.key)).toEqual(['a', 'b', 'c', 'd']);
|
||||
expect(checked.every((r) => !('email' in r))).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores an answer that is not a list', () => {
|
||||
expect(checkSearchResults('p', { key: 'a', title: 'A' })).toEqual([]);
|
||||
expect(checkSearchResults('p', undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('cuts long texts and takes at most the limit per plugin', () => {
|
||||
const many = Array.from({ length: MAX_RESULTS_PER_PLUGIN + 5 }, (_, i) => ({ key: `k${i}`, title: 'x'.repeat(500), snippet: 'y'.repeat(900) }));
|
||||
const checked = checkSearchResults('p', many);
|
||||
expect(checked).toHaveLength(MAX_RESULTS_PER_PLUGIN);
|
||||
expect(checked[0].title.length).toBe(200);
|
||||
expect(checked[0].title.endsWith('…')).toBe(true);
|
||||
expect(checked[0].snippet.length).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe('provideSearchResults', () => {
|
||||
it('asks no one without handlers', async () => {
|
||||
expect(await provideSearchResults('q', {})).toEqual([]);
|
||||
});
|
||||
|
||||
it('asks the plugins at once, each with an empty list', async () => {
|
||||
const order: string[] = [];
|
||||
emailHooks.onProvideSearchResults.register('slow', async (initial: unknown[]) => {
|
||||
order.push(`slow ${initial.length}`);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
return [{ key: 's', title: 'S' }];
|
||||
});
|
||||
emailHooks.onProvideSearchResults.register('fast', (initial: unknown[]) => {
|
||||
order.push(`fast ${initial.length}`);
|
||||
return [{ key: 'f', title: 'F' }];
|
||||
});
|
||||
|
||||
const results = await provideSearchResults('q', {});
|
||||
|
||||
expect(order).toEqual(['slow 0', 'fast 0']);
|
||||
expect(results.map((r) => `${r.pluginId}:${r.key}`)).toEqual(['slow:s', 'fast:f']);
|
||||
});
|
||||
|
||||
it('leaves out a plugin that throws', async () => {
|
||||
emailHooks.onProvideSearchResults.register('broken', () => { throw new Error('down'); });
|
||||
emailHooks.onProvideSearchResults.register('ok', () => [{ key: 'a', title: 'A' }]);
|
||||
|
||||
expect((await provideSearchResults('q', {})).map((r) => r.pluginId)).toEqual(['ok']);
|
||||
});
|
||||
|
||||
it('leaves out a plugin that runs out of time', async () => {
|
||||
vi.useFakeTimers();
|
||||
emailHooks.onProvideSearchResults.register('hung', () => new Promise(() => {}));
|
||||
emailHooks.onProvideSearchResults.register('ok', () => [{ key: 'a', title: 'A' }]);
|
||||
|
||||
const pending = provideSearchResults('q', {});
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
|
||||
expect((await pending).map((r) => r.pluginId)).toEqual(['ok']);
|
||||
});
|
||||
});
|
||||
@@ -116,7 +116,7 @@ export const HOOK_CATALOG = {
|
||||
// ─── Mail: search ──────────────────────────────────────────
|
||||
onSearch: H('mail-search', 'transform', 'email:read', '{ query, filters: SearchFilters }', 'before a search runs; may change the query and filters'),
|
||||
onSearchResults: H('mail-search', 'transform', 'email:read', 'string[], { query, filters, emailIds }', 'after search results arrived; return ids of further messages to add to them (at most 100)'),
|
||||
onProvideSearchResults: H('mail-search', 'transform', 'email:read', 'ExternalSearchResult[], { query, filters }', 'to add results from elsewhere to a search'),
|
||||
onProvideSearchResults: H('mail-search', 'transform', 'email:read', 'ExternalSearchResult[], { query, filters }', 'to add results from elsewhere to a search; drawn above its message list; an entry naming a message (email) opens it on a click, any other click comes back as onAction with surface "search-results" and the entry\'s key'),
|
||||
|
||||
// ─── Mail: attachments ─────────────────────────────────────
|
||||
onBeforeAttachmentUpload: H('mail-attachments', 'intercept', 'email:read', 'AttachmentInfo', 'before a file is attached in the composer'),
|
||||
|
||||
+24
-3
@@ -303,6 +303,26 @@ export class HookBus<T extends (...args: any[]) => any> {
|
||||
return { allowed: true, value };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask every handler at once, each with the same initial value, and keep
|
||||
* each answer with the plugin that gave it, for hooks whose results the
|
||||
* host shows per plugin (onProvideSearchResults). A handler that throws or
|
||||
* runs out of time gives nothing.
|
||||
*/
|
||||
async collect<V>(initial: V, ...rest: unknown[]): Promise<{ pluginId: string; value: unknown }[]> {
|
||||
const asked = this.handlers.filter(({ pluginId }) => !pluginErrorTracker.isDisabled(pluginId));
|
||||
const answers = await Promise.all(asked.map(async ({ pluginId, handler }) => {
|
||||
try {
|
||||
const value: unknown = await withTimeout(pluginId, handler(initial, ...rest), this.timeoutMs);
|
||||
return { pluginId, value };
|
||||
} catch (err) {
|
||||
pluginErrorTracker.record(pluginId, err);
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
return answers.filter((a): a is { pluginId: string; value: unknown } => a !== null);
|
||||
}
|
||||
|
||||
/** Fire handlers as transforms - each receives the output of the previous */
|
||||
async transform<V>(initial: V, ...rest: unknown[]): Promise<V> {
|
||||
let value = initial;
|
||||
@@ -464,9 +484,10 @@ export const emailHooks = {
|
||||
// renderer for an attachment. Initial value: AttachmentPreview, second
|
||||
// argument: AttachmentInfo.
|
||||
onAttachmentPreview: new HookBus(),
|
||||
// Transform hook - lets plugins contribute additional results to the global
|
||||
// search panel. Initial value: ExternalSearchResult[]. Second argument:
|
||||
// { query: string, filters: SearchFilters }.
|
||||
// Lets plugins contribute results from elsewhere to a search, drawn above
|
||||
// its message list (lib/plugin-search-results.ts). Each handler is asked
|
||||
// on its own with an empty ExternalSearchResult[] and { query, filters },
|
||||
// so a click can go back to the plugin that gave the entry.
|
||||
onProvideSearchResults: new HookBus(),
|
||||
// Observer fired (debounced) when the composer draft body, subject, or
|
||||
// recipients change. Handler receives a DraftView snapshot. Use for AI
|
||||
|
||||
@@ -196,6 +196,11 @@ export function setActionDispatcher(next: ActionDispatcher | null): void {
|
||||
}
|
||||
|
||||
export async function runAction(action: RegisteredAction, surface: string, context: Record<string, unknown>): Promise<void> {
|
||||
await runPluginAction(action.pluginId, surface, action.id, context);
|
||||
}
|
||||
|
||||
/** A click on something a plugin gave as data elsewhere (a search result's entry). */
|
||||
export async function runPluginAction(pluginId: string, surface: string, actionId: string, context: Record<string, unknown>): Promise<void> {
|
||||
if (!dispatcher) return;
|
||||
await dispatcher(action.pluginId, surface, action.id, context);
|
||||
await dispatcher(pluginId, surface, actionId, context);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Results plugins add to a search through onProvideSearchResults, drawn as a
|
||||
// block above the search's message list (components/email/plugin-search-results.tsx).
|
||||
// Each plugin is asked on its own, so every entry keeps the plugin it came
|
||||
// from: a click goes back to that plugin as onAction, unless the entry names a
|
||||
// message, which the click opens. What a plugin returns crosses from its
|
||||
// sandbox, so it is checked and cut here, never trusted.
|
||||
|
||||
import { emailHooks } from './plugin-hooks';
|
||||
import type { ExternalSearchResult, SearchResultEmail } from './plugin-types';
|
||||
|
||||
/** An entry as the host keeps it: checked, with the plugin that gave it. */
|
||||
export interface ProvidedSearchResult extends ExternalSearchResult {
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
/** Most entries one plugin may add to one search. */
|
||||
export const MAX_RESULTS_PER_PLUGIN = 20;
|
||||
const MAX_KEY = 200;
|
||||
const MAX_TITLE = 200;
|
||||
const MAX_SNIPPET = 300;
|
||||
const MAX_SOURCE = 40;
|
||||
/** JMAP ids are at most 255 octets (RFC 8620 §1.2). */
|
||||
const MAX_ID = 255;
|
||||
|
||||
function text(value: unknown, max: number): string {
|
||||
if (typeof value !== 'string') return '';
|
||||
const flat = value.replace(/\s+/g, ' ').trim();
|
||||
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
||||
}
|
||||
|
||||
function jmapId(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length > 0 && value.length <= MAX_ID ? value : null;
|
||||
}
|
||||
|
||||
/** The message an entry names, or null for anything that is not one. */
|
||||
function email(value: unknown): SearchResultEmail | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const raw = value as Record<string, unknown>;
|
||||
const id = jmapId(raw.id);
|
||||
if (!id) return null;
|
||||
if (raw.accountId === undefined) return { id };
|
||||
const accountId = jmapId(raw.accountId);
|
||||
return accountId ? { id, accountId } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One plugin's answer, checked: a list of entries with a key and a title,
|
||||
* plain text cut to its limits, keys unique, at most MAX_RESULTS_PER_PLUGIN.
|
||||
* Anything else is dropped without failing the rest.
|
||||
*/
|
||||
export function checkSearchResults(pluginId: string, value: unknown): ProvidedSearchResult[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: ProvidedSearchResult[] = [];
|
||||
for (const raw of value) {
|
||||
if (out.length >= MAX_RESULTS_PER_PLUGIN) break;
|
||||
if (!raw || typeof raw !== 'object') continue;
|
||||
const entry = raw as Record<string, unknown>;
|
||||
const key = typeof entry.key === 'string' ? entry.key : '';
|
||||
const title = text(entry.title, MAX_TITLE);
|
||||
if (!key || key.length > MAX_KEY || seen.has(key) || !title) continue;
|
||||
seen.add(key);
|
||||
const source = text(entry.source, MAX_SOURCE);
|
||||
const message = email(entry.email);
|
||||
out.push({
|
||||
pluginId, key, title, snippet: text(entry.snippet, MAX_SNIPPET),
|
||||
...(source ? { source } : {}),
|
||||
...(message ? { email: message } : {}),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Ask every plugin for results to a search; each answer checked, in the plugins' order. */
|
||||
export async function provideSearchResults(query: string, filters: unknown): Promise<ProvidedSearchResult[]> {
|
||||
if (emailHooks.onProvideSearchResults.size === 0) return [];
|
||||
const answers = await emailHooks.onProvideSearchResults.collect([] as ExternalSearchResult[], { query, filters });
|
||||
return answers.flatMap(({ pluginId, value }) => checkSearchResults(pluginId, value));
|
||||
}
|
||||
@@ -62,6 +62,7 @@ export const SLOT_CATALOG = {
|
||||
'message-bulk-actions': S('mail-list', 'action', ['ui:message-list'], { emailIds: 'email:read' }, 'buttons in the toolbar shown while messages are selected', true),
|
||||
'context-menu-email': S('mail-list', 'frame', ['ui:context-menu'], {}, 'the right-click menu of a message'),
|
||||
'toolbar-actions': S('mail-list', 'frame', ['ui:toolbar'], {}, 'the message toolbar', true),
|
||||
'search-results': S('mail-list', 'data', ['email:read'], { query: 'email:read' }, 'entries a plugin returns from onProvideSearchResults, in a block above a search\'s message list; a click opens the message an entry names, any other reaches the plugin as onAction with the entry\'s key as actionId'),
|
||||
|
||||
// ─── Reader ────────────────────────────────────────────────
|
||||
'reader-header': S('reader', 'frame', ['ui:email-banner'], { email: 'email:read' }, 'at the top of the open message, above the subject'),
|
||||
|
||||
+21
-7
@@ -1199,19 +1199,33 @@ export interface ConflictWarning {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returned by onProvideSearchResults transform handlers. Plugins extend the
|
||||
* initial array with their own results (CRM hits, Slack messages, etc.).
|
||||
* The host renders these in a grouped section below native email results.
|
||||
* Returned by onProvideSearchResults handlers: results from elsewhere (CRM
|
||||
* hits, chat messages, ...) for the search that is running. The host draws
|
||||
* them as a block above the search's message list, grouped by `source`.
|
||||
* A click on an entry that names a message the user can reach (`email`)
|
||||
* opens it in the reader, as a click on a row of the list does. Any other
|
||||
* click reaches the plugin that returned the entry as `onAction`, with
|
||||
* `surface: 'search-results'`, the entry's `key` as `actionId` and the
|
||||
* search's `query` in the context. Plain text only; see
|
||||
* lib/plugin-search-results.ts for the limits.
|
||||
*/
|
||||
export interface ExternalSearchResult {
|
||||
/** Stable unique key */
|
||||
/** Unique among the plugin's entries for this search; comes back as actionId. */
|
||||
key: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
/** Plugin-handled action when the result row is clicked */
|
||||
onClick: () => void;
|
||||
/** Optional source label, e.g. "Slack", "Notion" */
|
||||
/** Group heading, e.g. "CRM", "Slack"; the plugin's name when missing. */
|
||||
source?: string;
|
||||
/** The message the entry stands for, opened in the reader on a click. */
|
||||
email?: SearchResultEmail;
|
||||
}
|
||||
|
||||
/** A message named by a search result. */
|
||||
export interface SearchResultEmail {
|
||||
/** The message's JMAP id. */
|
||||
id: string;
|
||||
/** The JMAP account holding it (a shared or group account); the signed-in account's own when missing. */
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user