mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 08:15:38 +00:00
A plugin's results to a search came all at once after its handler answered, with nothing before: the block then appeared above the message list and pushed it down, under a pointer about to click a row. onProvideSearchResults handlers that search elsewhere (a CRM's search by meaning) take seconds. Each plugin's answer now shows as it comes, and the store keeps the plugins still asked. One that has not answered after 300 ms gets the block at a fixed height with placeholder rows under "Searching for more results…"; its entries then fill that height, so the list moves once, with its own results. A plugin that answers at once (one not searching for this query) shows nothing.
92 lines
3.6 KiB
TypeScript
92 lines
3.6 KiB
TypeScript
// 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. Returns the plugins asked, in
|
|
* their order; each answer goes to `onAnswer` as it comes, checked (empty for
|
|
* a plugin that throws or runs out of time), so a slow plugin keeps no other
|
|
* waiting.
|
|
*/
|
|
export function provideSearchResults(
|
|
query: string,
|
|
filters: unknown,
|
|
onAnswer: (pluginId: string, results: ProvidedSearchResult[]) => void,
|
|
): string[] {
|
|
if (emailHooks.onProvideSearchResults.size === 0) return [];
|
|
const asked = emailHooks.onProvideSearchResults.collect([] as ExternalSearchResult[], { query, filters });
|
|
for (const { pluginId, answer } of asked) {
|
|
void answer.then((given) => onAnswer(pluginId, given ? checkSearchResults(pluginId, given.value) : []));
|
|
}
|
|
return asked.map(({ pluginId }) => pluginId);
|
|
}
|