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..b7f80893d --- /dev/null +++ b/components/email/__tests__/plugin-search-results.test.tsx @@ -0,0 +1,148 @@ +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, +// 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 }, 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(() => { + 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('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(); + + 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 011df3db0..af703af36 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -42,12 +42,15 @@ 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 { type OpenableEmail, PluginSearchResults } from "./plugin-search-results"; interface EmailListProps { emails: Email[]; /** emailKeyFor of the open message: ids repeat across accounts. */ selectedEmailKey?: 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 +131,7 @@ export function EmailList({ emails, selectedEmailKey, onEmailSelect, + onOpenSearchResult, onEmailPrefetch, onEmailDoubleClick, onFindFromSender, @@ -974,6 +978,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..ec41e5b09 --- /dev/null +++ b/components/email/plugin-search-results.tsx @@ -0,0 +1,168 @@ +"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 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"; + +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()]; +} + +/** 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 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, 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); + 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 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); + 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) => { + const current = isOpen(r.email, openEmail); + return ( +
  • + +
  • + ); + })} +
+ )} +
+ ); + })} +
+ ); +} diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 2a8d777be..33d2ced23 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -5215,6 +5215,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-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..1b92e9c62 --- /dev/null +++ b/lib/__tests__/plugin-search-results.test.ts @@ -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']); + }); +}); diff --git a/lib/plugin-hook-catalog.ts b/lib/plugin-hook-catalog.ts index 2598fc23c..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'), + 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-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 d69954eb2..91fab3dd1 100644 --- a/lib/plugin-sandbox/actions.ts +++ b/lib/plugin-sandbox/actions.ts @@ -196,6 +196,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..91b5f2120 --- /dev/null +++ b/lib/plugin-search-results.ts @@ -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; + 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(); + 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); + 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 { + 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..ef0b51b99 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 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 c8161d8bb..9fc9b4959 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -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; } /** 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 0aafd8ab4..9745a30dd 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -19,7 +19,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"; @@ -204,8 +204,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 @@ -2836,6 +2836,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: [], @@ -4726,6 +4751,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; @@ -4788,18 +4814,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. @@ -4843,6 +4861,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; @@ -4909,18 +4928,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,