From d02988f431fba0967bdb8f874ed84759944c678b Mon Sep 17 00:00:00 2001 From: Jacob Zeitz Date: Sun, 25 Oct 2026 07:32:11 +0100 Subject: [PATCH 1/2] feat: show the results plugins provide through onProvideSearchResults The hook was called on every search, but nothing showed its entries, the list waited for it, and an entry's onClick could not cross from the plugin's sandbox. Its entries are now drawn as a block above the search's message list, a group per plugin and source. Each plugin is asked on its own (HookBus.collect) beside the search, so the list no longer waits, and the answer to a search that was replaced is dropped. The answer is checked: at most 20 entries, a key and a title, plain text cut to its limits. A click goes to the plugin that gave the entry as onAction with surface "search-results", the entry's key and the query; ExternalSearchResult loses onClick. Closes #1336 Co-Authored-By: Claude Opus 5.5 --- .../__tests__/plugin-search-results.test.tsx | 79 +++++++++++ components/email/email-list.tsx | 3 + components/email/plugin-search-results.tsx | 108 +++++++++++++++ lib/__tests__/plugin-hook-catalog.test.ts | 2 +- lib/__tests__/plugin-search-results.test.ts | 80 +++++++++++ lib/plugin-hook-catalog.ts | 2 +- lib/plugin-hooks.ts | 27 +++- lib/plugin-sandbox/actions.ts | 7 +- lib/plugin-search-results.ts | 56 ++++++++ lib/plugin-slot-catalog.ts | 1 + lib/plugin-types.ts | 16 ++- .../email-store-plugin-search-results.test.ts | 126 ++++++++++++++++++ stores/email-store.ts | 49 ++++--- 13 files changed, 524 insertions(+), 32 deletions(-) create mode 100644 components/email/__tests__/plugin-search-results.test.tsx create mode 100644 components/email/plugin-search-results.tsx create mode 100644 lib/__tests__/plugin-search-results.test.ts create mode 100644 lib/plugin-search-results.ts create mode 100644 stores/__tests__/email-store-plugin-search-results.test.ts diff --git a/components/email/__tests__/plugin-search-results.test.tsx b/components/email/__tests__/plugin-search-results.test.tsx new file mode 100644 index 000000000..41f18c2b6 --- /dev/null +++ b/components/email/__tests__/plugin-search-results.test.tsx @@ -0,0 +1,79 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; +import { setActionDispatcher } from '@/lib/plugin-sandbox/actions'; +import type { ProvidedSearchResult } from '@/lib/plugin-search-results'; +import { useEmailStore } from '@/stores/email-store'; +import { usePluginStore } from '@/stores/plugin-store'; +import { PluginSearchResults } from '../plugin-search-results'; + +// The block above a search's message list with the entries plugins returned +// from onProvideSearchResults; a click goes back to the plugin as onAction. + +const result = (pluginId: string, key: string, source?: string): ProvidedSearchResult => + ({ pluginId, key, title: `Title ${key}`, snippet: `Snippet ${key}`, ...(source ? { source } : {}) }); + +const dispatch = vi.fn(async () => {}); + +beforeEach(() => { + setActionDispatcher(dispatch); + usePluginStore.setState({ plugins: [{ id: 'chat', name: 'Team chat' }] } as never); + useEmailStore.setState({ searchFilters: { ...DEFAULT_SEARCH_FILTERS } }); +}); + +afterEach(() => { + setActionDispatcher(null); + dispatch.mockClear(); +}); + +describe('PluginSearchResults', () => { + it('shows nothing outside a search', () => { + useEmailStore.setState({ searchQuery: '', externalSearchResults: [result('crm', 'a', 'CRM')] }); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('shows the entries of a search by filters alone', () => { + useEmailStore.setState({ + searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS, from: 'bob@x.test' }, + externalSearchResults: [result('crm', 'a', 'CRM')], + }); + render(); + expect(screen.getByRole('region', { name: 'CRM' })).toHaveTextContent('Title a'); + }); + + it('groups the entries by source, the plugin\'s name without one', () => { + useEmailStore.setState({ + searchQuery: 'resolution', + externalSearchResults: [result('crm', 'a', 'CRM'), result('chat', 'b'), result('crm', 'c', 'CRM')], + }); + render(); + + expect(screen.getByRole('region', { name: 'CRM' })).toHaveTextContent('Title a'); + expect(screen.getByRole('region', { name: 'CRM' })).toHaveTextContent('Title c'); + expect(screen.getByRole('region', { name: 'Team chat' })).toHaveTextContent('Snippet b'); + }); + + it('sends a click to the plugin that gave the entry, with the query', () => { + useEmailStore.setState({ searchQuery: 'resolution', externalSearchResults: [result('crm', 'mail-123', 'CRM')] }); + render(); + + fireEvent.click(screen.getByRole('button', { name: /Title mail-123/ })); + + expect(dispatch).toHaveBeenCalledWith('crm', 'search-results', 'mail-123', { query: 'resolution' }); + }); + + it('folds a group', () => { + useEmailStore.setState({ searchQuery: 'resolution', externalSearchResults: [result('crm', 'a', 'CRM')] }); + render(); + + const heading = screen.getByRole('button', { name: /^CRM/ }); + expect(heading).toHaveAttribute('aria-expanded', 'true'); + fireEvent.click(heading); + + expect(screen.queryByText('Title a')).toBeNull(); + expect(heading).toHaveAttribute('aria-expanded', 'false'); + }); +}); diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index e182206b3..020bbc19f 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -42,6 +42,7 @@ import { PluginActions } from "@/components/plugins/plugin-actions"; import { useUnreadFirstSection } from "@/hooks/use-unread-first-section"; import { unreadSectionRowCount } from "@/lib/unread-first"; import { MvListSectionHeader } from "./mv-list-section-header"; +import { PluginSearchResults } from "./plugin-search-results"; interface EmailListProps { emails: Email[]; @@ -966,6 +967,8 @@ export function EmailList({ + + {listColumns && ( )} diff --git a/components/email/plugin-search-results.tsx b/components/email/plugin-search-results.tsx new file mode 100644 index 000000000..01adca7a4 --- /dev/null +++ b/components/email/plugin-search-results.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useId, useMemo, useState } from "react"; +import { ChevronDown, ChevronRight } from "@/components/icons"; +import { cn } from "@/lib/utils"; +import { isFilterEmpty } from "@/lib/jmap/search-utils"; +import { runPluginAction } from "@/lib/plugin-sandbox/actions"; +import type { ProvidedSearchResult } from "@/lib/plugin-search-results"; +import { useEmailStore } from "@/stores/email-store"; +import { usePluginStore } from "@/stores/plugin-store"; + +interface Group { + id: string; + heading: string; + results: ProvidedSearchResult[]; +} + +/** One group per plugin and source, in the order the entries came. */ +function groupResults(results: ProvidedSearchResult[], pluginName: (id: string) => string): Group[] { + const groups = new Map(); + for (const r of results) { + const id = `${r.pluginId}\u0000${r.source ?? ""}`; + let group = groups.get(id); + if (!group) { + group = { id, heading: r.source ?? pluginName(r.pluginId), results: [] }; + groups.set(id, group); + } + group.results.push(r); + } + return [...groups.values()]; +} + +interface PluginSearchResultsProps { + /** Entries plugins return from onProvideSearchResults; a click is the plugin's onAction. */ + surface: "search-results"; +} + +/** + * Results from elsewhere for the search that is running (CRM hits, chat + * messages, ...), above its message list. Renders nothing outside a search or + * without entries. A click goes back to the plugin that gave the entry. + */ +export function PluginSearchResults({ surface }: PluginSearchResultsProps) { + const results = useEmailStore((s) => s.externalSearchResults); + const query = useEmailStore((s) => s.searchQuery); + const searching = useEmailStore((s) => !!s.searchQuery.trim() || !isFilterEmpty(s.searchFilters)); + const plugins = usePluginStore((s) => s.plugins); + const idPrefix = useId(); + const [collapsed, setCollapsed] = useState>(() => new Set()); + + const groups = useMemo( + () => groupResults(results, (id) => plugins.find((p) => p.id === id)?.name ?? id), + [results, plugins], + ); + + if (!searching || groups.length === 0) return null; + + const toggle = (id: string) => + setCollapsed((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + + return ( +
+ {groups.map((group, index) => { + const open = !collapsed.has(group.id); + const listId = `${idPrefix}-${index}`; + return ( +
+ + {open && ( +
    + {group.results.map((r) => ( +
  • + +
  • + ))} +
+ )} +
+ ); + })} +
+ ); +} diff --git a/lib/__tests__/plugin-hook-catalog.test.ts b/lib/__tests__/plugin-hook-catalog.test.ts index b9c1cbfdb..892c4887e 100644 --- a/lib/__tests__/plugin-hook-catalog.test.ts +++ b/lib/__tests__/plugin-hook-catalog.test.ts @@ -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); } diff --git a/lib/__tests__/plugin-search-results.test.ts b/lib/__tests__/plugin-search-results.test.ts new file mode 100644 index 000000000..10dc696dc --- /dev/null +++ b/lib/__tests__/plugin-search-results.test.ts @@ -0,0 +1,80 @@ +// @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('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']); + }); +}); diff --git a/lib/plugin-hook-catalog.ts b/lib/plugin-hook-catalog.ts index 2598fc23c..16935bebb 100644 --- a/lib/plugin-hook-catalog.ts +++ b/lib/plugin-hook-catalog.ts @@ -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, a 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'), diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts index 03bbbb699..5d16dbf46 100644 --- a/lib/plugin-hooks.ts +++ b/lib/plugin-hooks.ts @@ -303,6 +303,26 @@ export class HookBus 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(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(initial: V, ...rest: unknown[]): Promise { 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 diff --git a/lib/plugin-sandbox/actions.ts b/lib/plugin-sandbox/actions.ts index e69da38da..571e3411f 100644 --- a/lib/plugin-sandbox/actions.ts +++ b/lib/plugin-sandbox/actions.ts @@ -181,6 +181,11 @@ export function setActionDispatcher(next: ActionDispatcher | null): void { } export async function runAction(action: RegisteredAction, surface: string, context: Record): Promise { + 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): Promise { if (!dispatcher) return; - await dispatcher(action.pluginId, surface, action.id, context); + await dispatcher(pluginId, surface, actionId, context); } diff --git a/lib/plugin-search-results.ts b/lib/plugin-search-results.ts new file mode 100644 index 000000000..4c8de6f01 --- /dev/null +++ b/lib/plugin-search-results.ts @@ -0,0 +1,56 @@ +// 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. 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 } 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; + +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; +} + +/** + * 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(); + 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; + 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); + out.push({ pluginId, key, title, snippet: text(entry.snippet, MAX_SNIPPET), ...(source ? { source } : {}) }); + } + 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 { + if (emailHooks.onProvideSearchResults.size === 0) return []; + const answers = await emailHooks.onProvideSearchResults.collect([] as ExternalSearchResult[], { query, filters }); + return answers.flatMap(({ pluginId, value }) => checkSearchResults(pluginId, value)); +} diff --git a/lib/plugin-slot-catalog.ts b/lib/plugin-slot-catalog.ts index 977ff2b91..5940d0055 100644 --- a/lib/plugin-slot-catalog.ts +++ b/lib/plugin-slot-catalog.ts @@ -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 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'), diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 4df1b48c9..c523453ce 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -1188,18 +1188,20 @@ 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 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; } diff --git a/stores/__tests__/email-store-plugin-search-results.test.ts b/stores/__tests__/email-store-plugin-search-results.test.ts new file mode 100644 index 000000000..1ad4ab937 --- /dev/null +++ b/stores/__tests__/email-store-plugin-search-results.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useEmailStore } from '../email-store'; +import { useAuthStore } from '../auth-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; +import { clearAllHooks, emailHooks } from '@/lib/plugin-hooks'; +import type { ExternalSearchResult } from '@/lib/plugin-types'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// onProvideSearchResults: plugins' results from elsewhere, asked beside the +// search, never before its list is shown. + +const hit = (id: string): Email => ({ + id, threadId: `t-${id}`, mailboxIds: { inbox: true }, keywords: {}, subject: `Subject ${id}`, + receivedAt: '2026-10-01T00:00:00Z', from: [], to: [], preview: '', size: 1, hasAttachment: false, +} as Email); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { resolve = r; }); + return { promise, resolve }; +} + +const entry = (key: string): ExternalSearchResult => ({ key, title: `Title ${key}`, snippet: 'text', source: 'CRM' }); + +let client: IJMAPClient; + +beforeEach(() => { + clearAllHooks(); + client = { + getAccountId: () => 'me', + searchEmails: vi.fn().mockResolvedValue({ emails: [hit('e1')], hasMore: false, total: 1 }), + advancedSearchEmails: vi.fn().mockResolvedValue({ emails: [hit('e1')], hasMore: false, total: 1 }), + getSomeEmails: vi.fn().mockResolvedValue([]), + } as unknown as IJMAPClient; + useAuthStore.setState({ activeAccountId: 'acct', getClientForAccount: () => client as never } as never); + useSettingsStore.setState({ emailsPerPage: 50 } as never); + useEmailStore.setState({ + mailboxes: [{ id: 'inbox', name: 'Inbox', role: 'inbox', isShared: false } as Mailbox], + accountMailboxes: {}, + viewingAccountId: null, + selectedMailbox: 'inbox', + searchMailboxId: 'inbox', + selectedKeyword: null, + isUnifiedView: false, + unifiedRole: null, + crossView: null, + searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, + searchAbortController: null, + externalSearchResults: [], + emails: [], + }); +}); + +afterEach(() => clearAllHooks()); + +describe('onProvideSearchResults', () => { + it('lists the messages before a slow plugin answers, then shows its entries with the plugin', async () => { + const answer = deferred(); + const handler = vi.fn(() => answer.promise); + emailHooks.onProvideSearchResults.register('crm', handler); + + await useEmailStore.getState().searchEmails(client, 'resolution'); + + expect(handler).toHaveBeenCalledWith([], { query: 'resolution', filters: DEFAULT_SEARCH_FILTERS }); + expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['e1']); + expect(useEmailStore.getState().externalSearchResults).toEqual([]); + + answer.resolve([entry('m1')]); + await vi.waitFor(() => expect(useEmailStore.getState().externalSearchResults).toHaveLength(1)); + expect(useEmailStore.getState().externalSearchResults[0]).toEqual({ ...entry('m1'), pluginId: 'crm' }); + }); + + it('asks every plugin on its own and keeps each entry with the plugin that gave it', async () => { + emailHooks.onProvideSearchResults.register('a', (results: ExternalSearchResult[]) => [...results, entry('x')]); + emailHooks.onProvideSearchResults.register('b', (results: ExternalSearchResult[]) => [...results, entry('x')]); + + await useEmailStore.getState().searchEmails(client, 'resolution'); + + await vi.waitFor(() => expect(useEmailStore.getState().externalSearchResults).toHaveLength(2)); + expect(useEmailStore.getState().externalSearchResults.map((r) => r.pluginId)).toEqual(['a', 'b']); + }); + + it('drops the answer to a search that was replaced', async () => { + const first = deferred(); + const second = deferred(); + const answers = [first, second]; + emailHooks.onProvideSearchResults.register('crm', () => answers.shift()!.promise); + + await useEmailStore.getState().searchEmails(client, 'one'); + await useEmailStore.getState().searchEmails(client, 'two'); + second.resolve([entry('two')]); + await vi.waitFor(() => expect(useEmailStore.getState().externalSearchResults).toHaveLength(1)); + first.resolve([entry('one')]); + await first.promise; + await new Promise((r) => setTimeout(r, 0)); + + expect(useEmailStore.getState().externalSearchResults.map((r) => r.key)).toEqual(['two']); + }); + + it('clears the last search\'s entries when a new search starts', async () => { + emailHooks.onProvideSearchResults.register('crm', (_results: ExternalSearchResult[], { query }: { query: string }) => + (query === 'one' ? [entry('one')] : new Promise(() => {}))); + + await useEmailStore.getState().searchEmails(client, 'one'); + await vi.waitFor(() => expect(useEmailStore.getState().externalSearchResults).toHaveLength(1)); + await useEmailStore.getState().searchEmails(client, 'two'); + + expect(useEmailStore.getState().externalSearchResults).toEqual([]); + }); + + it('does the same on the advanced search', async () => { + const answer = deferred(); + emailHooks.onProvideSearchResults.register('crm', () => answer.promise); + useEmailStore.setState({ searchQuery: 'resolution', searchFilters: { ...DEFAULT_SEARCH_FILTERS, from: 'bob@x.test' } }); + + await useEmailStore.getState().advancedSearch(client); + + expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['e1']); + answer.resolve([entry('m1')]); + await vi.waitFor(() => expect(useEmailStore.getState().externalSearchResults).toHaveLength(1)); + expect(useEmailStore.getState().externalSearchResults[0].pluginId).toBe('crm'); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index e67954b60..bad0c0811 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -18,7 +18,7 @@ import { allowEmailMove, createMailboxWithHooks, emitEmailMove, idsFromSelection import { emailSummary, emailToReadView } from "@/lib/plugin-projection"; import { resolveThreadRoute } from "@/lib/thread-routing"; import { emailKeyFor, threadKeyFor, threadIdFromKey, KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils"; -import type { ExternalSearchResult } from "@/lib/plugin-types"; +import { provideSearchResults, type ProvidedSearchResult } from "@/lib/plugin-search-results"; import { positionsByAccount, fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, fetchCrossViewEmails, searchCrossViewEmails, advancedSearchCrossViewEmails, fetchTagEmails, trashAndJunkIds, searchAcrossAccounts, advancedSearchAcrossAccounts, getCrossUnreadTotal, dedupeUnifiedAccounts, unifiedServerKey, type AcrossAccountsSearchOptions, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox"; import { defaultSearchScopeFor, isAllFoldersSearchScope, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders"; import { useAuthStore } from "@/stores/auth-store"; @@ -197,8 +197,8 @@ interface EmailStore { searchScopeFollowsFolder: boolean; isAdvancedSearchOpen: boolean; searchAbortController: AbortController | null; - /** Plugin-contributed search results (CRM hits, Slack messages, etc.) populated by emailHooks.onProvideSearchResults. */ - externalSearchResults: ExternalSearchResult[]; + /** Plugins' results from elsewhere for the search running (onProvideSearchResults), drawn above its list. */ + externalSearchResults: ProvidedSearchResult[]; // Unified mailbox state // Bumped by any action that changes which mailbox/keyword/unified view is @@ -2299,6 +2299,31 @@ async function pluginSearchResultIds(query: string, filters: unknown, emails: Em .slice(0, MAX_PLUGIN_SEARCH_IDS); } +/** + * Plugins' results for the search that is running (onProvideSearchResults). + * Asked beside the search, not before its list is shown: a slow plugin never + * delays the messages, and the answer to a search that was replaced is dropped. + */ +let providedSearchRun: object | null = null; + +function startProvidedSearchResults( + query: string, + filters: unknown, + store: { get: () => EmailStore; set: (partial: Partial) => void }, +): void { + const run = {}; + providedSearchRun = run; + // Written only when it changes: a search without plugin results renders as before. + const show = (results: ProvidedSearchResult[]) => { + if (results.length > 0 || store.get().externalSearchResults.length > 0) store.set({ externalSearchResults: results }); + }; + show([]); + if (emailHooks.onProvideSearchResults.size === 0) return; + void provideSearchResults(query, filters).then((results) => { + if (providedSearchRun === run) show(results); + }); +} + export const useEmailStore = create((set, get) => ({ emails: [], mailboxes: [], @@ -4171,6 +4196,7 @@ export const useEmailStore = create((set, get) => ({ } const query = search.query; const { isUnifiedView, unifiedRole, crossView, searchMailboxId, searchFilters, selectedKeyword } = get(); + startProvidedSearchResults(query, searchFilters, { get, set }); const emailsPerPage = useSettingsStore.getState().emailsPerPage; let result; @@ -4233,18 +4259,10 @@ export const useEmailStore = create((set, get) => ({ result.total += newEmails.length; } - if (controller.signal.aborted) return; - - const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { - query, - filters: searchFilters - }); - if (controller.signal.aborted) return; result.emails = await emailHooks.onEmailsFetched.transform(result.emails); set({ emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId), - externalSearchResults: externals, hasMoreEmails: result.hasMore, totalEmails: result.total, // Also here: a fast answer can land before the deferred loading write. @@ -4288,6 +4306,7 @@ export const useEmailStore = create((set, get) => ({ const { query: searchQuery, filters: searchFilters } = transformed ?? (emailHooks.onSearch.size > 0 ? await transformSearch(typedQuery, typedFilters) : { query: typedQuery, filters: typedFilters }); if (controller.signal.aborted) return; + startProvidedSearchResults(searchQuery, searchFilters, { get, set }); const emailsPerPage = useSettingsStore.getState().emailsPerPage; let result; let accountId; @@ -4354,18 +4373,10 @@ export const useEmailStore = create((set, get) => ({ result.total += newEmails.length; } - if (controller.signal.aborted) return; - - const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { - query: searchQuery, - filters: searchFilters - }); - if (controller.signal.aborted) return; result.emails = await emailHooks.onEmailsFetched.transform(result.emails); set({ emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId), - externalSearchResults: externals, hasMoreEmails: result.hasMore, totalEmails: result.total, isLoading: false, From fc438c275f0bcc5bcb514d7c8b03262d1335214e Mon Sep 17 00:00:00 2001 From: Jacob Zeitz Date: Sun, 25 Oct 2026 08:46:58 +0100 Subject: [PATCH 2/2] feat: open the message a plugin's search result names An entry of onProvideSearchResults may carry email: { id, accountId? }. A click on it opens the message in the reader through the login that reaches the account, as a list row does, and marks the entry while it is open; any other click stays onAction. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/plugin-search-results.test.tsx | 75 ++++++++++++++- components/email/email-list.tsx | 7 +- components/email/plugin-search-results.tsx | 94 +++++++++++++++---- components/mail/mail-app.tsx | 1 + lib/__tests__/plugin-search-results.test.ts | 18 ++++ lib/plugin-hook-catalog.ts | 2 +- lib/plugin-search-results.ts | 31 +++++- lib/plugin-slot-catalog.ts | 2 +- lib/plugin-types.ts | 14 ++- 9 files changed, 215 insertions(+), 29 deletions(-) diff --git a/components/email/__tests__/plugin-search-results.test.tsx b/components/email/__tests__/plugin-search-results.test.tsx index 41f18c2b6..b7f80893d 100644 --- a/components/email/__tests__/plugin-search-results.test.tsx +++ b/components/email/__tests__/plugin-search-results.test.tsx @@ -1,25 +1,41 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import React from 'react'; import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; import { setActionDispatcher } from '@/lib/plugin-sandbox/actions'; import type { ProvidedSearchResult } from '@/lib/plugin-search-results'; +import type { Email } from '@/lib/jmap/types'; +import { useAuthStore } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; import { usePluginStore } from '@/stores/plugin-store'; import { PluginSearchResults } from '../plugin-search-results'; // The block above a search's message list with the entries plugins returned -// from onProvideSearchResults; a click goes back to the plugin as onAction. +// from onProvideSearchResults; a click goes back to the plugin as onAction, +// or opens the message an entry names. const result = (pluginId: string, key: string, source?: string): ProvidedSearchResult => ({ pluginId, key, title: `Title ${key}`, snippet: `Snippet ${key}`, ...(source ? { source } : {}) }); const dispatch = vi.fn(async () => {}); +/** A login whose primary account is `primary`, reading `shared` accounts too. */ +const client = (primary: string, shared: string[] = []) => ({ + getAccountId: () => primary, + getSharedAccounts: () => [primary, ...shared].map((id) => ({ id, name: id, isPrimary: id === primary, capabilities: { mail: true } })), +}); + +const withEmail = (key: string, email: { id: string; accountId?: string }): ProvidedSearchResult => + ({ ...result('crm', key, 'CRM'), email }); + beforeEach(() => { setActionDispatcher(dispatch); usePluginStore.setState({ plugins: [{ id: 'chat', name: 'Team chat' }] } as never); - useEmailStore.setState({ searchFilters: { ...DEFAULT_SEARCH_FILTERS } }); + useEmailStore.setState({ searchFilters: { ...DEFAULT_SEARCH_FILTERS }, selectedEmail: null }); + useAuthStore.setState({ + activeAccountId: 'login-1', + getAllConnectedClients: () => new Map([['login-2', client('acc-2')], ['login-1', client('acc-1', ['group-1'])]]), + } as never); }); afterEach(() => { @@ -65,6 +81,59 @@ describe('PluginSearchResults', () => { expect(dispatch).toHaveBeenCalledWith('crm', 'search-results', 'mail-123', { query: 'resolution' }); }); + it('opens the message an entry names in the reader, not the plugin', () => { + const open = vi.fn(); + useEmailStore.setState({ searchQuery: 'resolution', externalSearchResults: [withEmail('mail-1', { id: 'M1' })] }); + render(); + + fireEvent.click(screen.getByRole('button', { name: /Title mail-1/ })); + + expect(open).toHaveBeenCalledWith({ id: 'M1', sourceClientAccountId: 'login-1', sourceAccountId: 'acc-1' }); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('opens a message of a shared account through the login reaching it, the active one first', () => { + const open = vi.fn(); + useEmailStore.setState({ + searchQuery: 'resolution', + externalSearchResults: [withEmail('g', { id: 'M1', accountId: 'group-1' }), withEmail('o', { id: 'M2', accountId: 'acc-2' })], + }); + render(); + + fireEvent.click(screen.getByRole('button', { name: /Title g/ })); + fireEvent.click(screen.getByRole('button', { name: /Title o/ })); + + expect(open).toHaveBeenNthCalledWith(1, { id: 'M1', sourceClientAccountId: 'login-1', sourceAccountId: 'group-1' }); + expect(open).toHaveBeenNthCalledWith(2, { id: 'M2', sourceClientAccountId: 'login-2', sourceAccountId: 'acc-2' }); + }); + + it('sends the click to the plugin when no login reaches the message\'s account', () => { + const open = vi.fn(); + useEmailStore.setState({ searchQuery: 'resolution', externalSearchResults: [withEmail('mail-1', { id: 'M1', accountId: 'elsewhere' })] }); + render(); + + fireEvent.click(screen.getByRole('button', { name: /Title mail-1/ })); + + expect(open).not.toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledWith('crm', 'search-results', 'mail-1', { query: 'resolution' }); + }); + + it('marks the entry whose message is open, in the same account', () => { + useEmailStore.setState({ + searchQuery: 'resolution', + externalSearchResults: [withEmail('a', { id: 'M1', accountId: 'group-1' }), withEmail('b', { id: 'M1' })], + selectedEmail: { id: 'M1', sourceAccountId: 'group-1' } as Email, + }); + render(); + + expect(screen.getByRole('button', { name: /Title a/ })).toHaveAttribute('aria-current', 'true'); + expect(screen.getByRole('button', { name: /Title b/ })).not.toHaveAttribute('aria-current'); + + act(() => useEmailStore.setState({ selectedEmail: { id: 'M1' } as Email })); + expect(screen.getByRole('button', { name: /Title a/ })).not.toHaveAttribute('aria-current'); + expect(screen.getByRole('button', { name: /Title b/ })).toHaveAttribute('aria-current', 'true'); + }); + it('folds a group', () => { useEmailStore.setState({ searchQuery: 'resolution', externalSearchResults: [result('crm', 'a', 'CRM')] }); render(); diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 020bbc19f..143278c4a 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -42,12 +42,14 @@ import { PluginActions } from "@/components/plugins/plugin-actions"; import { useUnreadFirstSection } from "@/hooks/use-unread-first-section"; import { unreadSectionRowCount } from "@/lib/unread-first"; import { MvListSectionHeader } from "./mv-list-section-header"; -import { PluginSearchResults } from "./plugin-search-results"; +import { type OpenableEmail, PluginSearchResults } from "./plugin-search-results"; interface EmailListProps { emails: Email[]; selectedEmailId?: string; onEmailSelect?: (email: Email) => void; + /** Opens a message a plugin's search result names (PluginSearchResults). */ + onOpenSearchResult?: (email: OpenableEmail) => void; /** A mouse was pressed on a row: the click that follows will open it. */ onEmailPrefetch?: (email: Email) => void; onEmailDoubleClick?: (email: Email) => void; @@ -128,6 +130,7 @@ export function EmailList({ emails, selectedEmailId, onEmailSelect, + onOpenSearchResult, onEmailPrefetch, onEmailDoubleClick, onFindFromSender, @@ -967,7 +970,7 @@ export function EmailList({ - + {listColumns && ( diff --git a/components/email/plugin-search-results.tsx b/components/email/plugin-search-results.tsx index 01adca7a4..ec41e5b09 100644 --- a/components/email/plugin-search-results.tsx +++ b/components/email/plugin-search-results.tsx @@ -6,6 +6,9 @@ import { cn } from "@/lib/utils"; import { isFilterEmpty } from "@/lib/jmap/search-utils"; import { runPluginAction } from "@/lib/plugin-sandbox/actions"; import type { ProvidedSearchResult } from "@/lib/plugin-search-results"; +import type { SearchResultEmail } from "@/lib/plugin-types"; +import type { Email } from "@/lib/jmap/types"; +import { useAuthStore } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; import { usePluginStore } from "@/stores/plugin-store"; @@ -30,18 +33,64 @@ function groupResults(results: ProvidedSearchResult[], pluginName: (id: string) return [...groups.values()]; } +/** A message as the reader opens one that is not a row of the list: the login reaching it and its account. */ +export type OpenableEmail = Required>; + +/** + * The login through which the user reaches a message an entry names, the + * active one first; null when none does (an account the user cannot read). + * Without an account the message is the active login's own. + */ +export function reachableEmail(email: SearchResultEmail): OpenableEmail | null { + const auth = useAuthStore.getState(); + const clients = [...auth.getAllConnectedClients()] + .sort(([a], [b]) => Number(b === auth.activeAccountId) - Number(a === auth.activeAccountId)); + for (const [login, client] of clients) { + if (!email.accountId) { + return login === auth.activeAccountId + ? { id: email.id, sourceClientAccountId: login, sourceAccountId: client.getAccountId() } + : null; + } + const reaches = client.getAccountId() === email.accountId + || client.getSharedAccounts().some((a) => a.id === email.accountId && a.capabilities.mail); + if (reaches) return { id: email.id, sourceClientAccountId: login, sourceAccountId: email.accountId }; + } + return null; +} + +/** The active login's own account. */ +function ownAccountId(): string | undefined { + const auth = useAuthStore.getState(); + return auth.activeAccountId ? auth.getAllConnectedClients().get(auth.activeAccountId)?.getAccountId() : undefined; +} + +/** + * Whether the message open in the reader is the one an entry names. JMAP ids + * are unique per account only, so the accounts are compared too, a missing + * one being the active login's own. + */ +function isOpen(email: SearchResultEmail | undefined, open: Email | null): boolean { + if (!email || !open || open.id !== email.id) return false; + const own = ownAccountId(); + return (open.sourceAccountId ?? own) === (email.accountId ?? own); +} + interface PluginSearchResultsProps { /** Entries plugins return from onProvideSearchResults; a click is the plugin's onAction. */ surface: "search-results"; + /** Opens a message in the reader, as a click on a list row does; without it every click is onAction. */ + onOpenEmail?: (email: OpenableEmail) => void; } /** * Results from elsewhere for the search that is running (CRM hits, chat * messages, ...), above its message list. Renders nothing outside a search or - * without entries. A click goes back to the plugin that gave the entry. + * without entries. A click on an entry naming a message the user reaches opens + * it; any other goes back to the plugin that gave the entry. */ -export function PluginSearchResults({ surface }: PluginSearchResultsProps) { +export function PluginSearchResults({ surface, onOpenEmail }: PluginSearchResultsProps) { const results = useEmailStore((s) => s.externalSearchResults); + const openEmail = useEmailStore((s) => s.selectedEmail); const query = useEmailStore((s) => s.searchQuery); const searching = useEmailStore((s) => !!s.searchQuery.trim() || !isFilterEmpty(s.searchFilters)); const plugins = usePluginStore((s) => s.plugins); @@ -55,6 +104,12 @@ export function PluginSearchResults({ surface }: PluginSearchResultsProps) { if (!searching || groups.length === 0) return null; + const click = (r: ProvidedSearchResult) => { + const reachable = r.email && onOpenEmail ? reachableEmail(r.email) : null; + if (reachable) onOpenEmail?.(reachable); + else void runPluginAction(r.pluginId, surface, r.key, { query }); + }; + const toggle = (id: string) => setCollapsed((prev) => { const next = new Set(prev); @@ -83,21 +138,26 @@ export function PluginSearchResults({ surface }: PluginSearchResultsProps) { {open && (
    - {group.results.map((r) => ( -
  • - -
  • - ))} + {group.results.map((r) => { + const current = isOpen(r.email, openEmail); + return ( +
  • + +
  • + ); + })}
)} diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 5031ad456..e7d634eaf 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -5079,6 +5079,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } }} onEmailSelect={handleEmailSelect} + onOpenSearchResult={handleEmailSelect} onEmailPrefetch={handleEmailPrefetch} onEmailDoubleClick={isEmbedded ? ((email) => { const store = useProTabStore.getState(); diff --git a/lib/__tests__/plugin-search-results.test.ts b/lib/__tests__/plugin-search-results.test.ts index 10dc696dc..1b92e9c62 100644 --- a/lib/__tests__/plugin-search-results.test.ts +++ b/lib/__tests__/plugin-search-results.test.ts @@ -22,6 +22,24 @@ describe('checkSearchResults', () => { ]).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([]); diff --git a/lib/plugin-hook-catalog.ts b/lib/plugin-hook-catalog.ts index 16935bebb..dad312b2a 100644 --- a/lib/plugin-hook-catalog.ts +++ b/lib/plugin-hook-catalog.ts @@ -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; drawn above its message list, a click comes back as onAction with surface "search-results" and the entry\'s key'), + 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'), diff --git a/lib/plugin-search-results.ts b/lib/plugin-search-results.ts index 4c8de6f01..91b5f2120 100644 --- a/lib/plugin-search-results.ts +++ b/lib/plugin-search-results.ts @@ -1,11 +1,12 @@ // 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. What a plugin returns -// crosses from its sandbox, so it is checked and cut here, never trusted. +// 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 } from './plugin-types'; +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 { @@ -18,6 +19,8 @@ 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 ''; @@ -25,6 +28,21 @@ function text(value: unknown, max: number): string { 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; + 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. @@ -43,7 +61,12 @@ export function checkSearchResults(pluginId: string, value: unknown): ProvidedSe if (!key || key.length > MAX_KEY || seen.has(key) || !title) continue; seen.add(key); const source = text(entry.source, MAX_SOURCE); - out.push({ pluginId, key, title, snippet: text(entry.snippet, MAX_SNIPPET), ...(source ? { source } : {}) }); + const message = email(entry.email); + out.push({ + pluginId, key, title, snippet: text(entry.snippet, MAX_SNIPPET), + ...(source ? { source } : {}), + ...(message ? { email: message } : {}), + }); } return out; } diff --git a/lib/plugin-slot-catalog.ts b/lib/plugin-slot-catalog.ts index 5940d0055..ef0b51b99 100644 --- a/lib/plugin-slot-catalog.ts +++ b/lib/plugin-slot-catalog.ts @@ -62,7 +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 reaches the plugin as onAction with the entry\'s key as actionId'), + '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'), diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index c523453ce..c7ba2b4eb 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -1191,7 +1191,9 @@ export interface ConflictWarning { * 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 reaches the plugin that returned the entry as `onAction`, with + * 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. @@ -1203,6 +1205,16 @@ export interface ExternalSearchResult { snippet: string; /** 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; } /**