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 <noreply@anthropic.com>
This commit is contained in:
Jacob Zeitz
2026-10-25 15:22:38 +01:00
co-authored by Claude Opus 5.5
parent d02988f431
commit fc438c275f
9 changed files with 215 additions and 29 deletions
@@ -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(<PluginSearchResults surface="search-results" onOpenEmail={open} />);
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(<PluginSearchResults surface="search-results" onOpenEmail={open} />);
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(<PluginSearchResults surface="search-results" onOpenEmail={open} />);
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(<PluginSearchResults surface="search-results" onOpenEmail={vi.fn()} />);
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(<PluginSearchResults surface="search-results" />);
+5 -2
View File
@@ -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({
<PluginSlot name="message-list-header" className="w-full" extraProps={listSlotProps} />
<PluginSearchResults surface="search-results" />
<PluginSearchResults surface="search-results" onOpenEmail={onOpenSearchResult} />
{listColumns && (
<ListColumnsHeader density={density} selecting={hasSelection} showRecipient={showRecipientColumn} />
+77 -17
View File
@@ -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<Pick<Email, "id" | "sourceClientAccountId" | "sourceAccountId">>;
/**
* 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) {
</button>
{open && (
<ul id={listId} className="pb-1">
{group.results.map((r) => (
<li key={r.key}>
<button
type="button"
onClick={() => { void runPluginAction(r.pluginId, surface, r.key, { query }); }}
className={cn(
"flex w-full flex-col items-start gap-0.5 px-3 py-1.5 text-start",
"hover:bg-muted focus-visible:bg-muted focus-visible:outline-none",
)}
>
<span className="w-full truncate text-sm font-medium text-foreground">{r.title}</span>
{r.snippet && <span className="line-clamp-2 text-xs text-muted-foreground">{r.snippet}</span>}
</button>
</li>
))}
{group.results.map((r) => {
const current = isOpen(r.email, openEmail);
return (
<li key={r.key}>
<button
type="button"
onClick={() => click(r)}
aria-current={current ? "true" : undefined}
className={cn(
"flex w-full flex-col items-start gap-0.5 px-3 py-1.5 text-start",
"hover:bg-muted focus-visible:bg-muted focus-visible:outline-none",
current && "bg-accent",
)}
>
<span className="w-full truncate text-sm font-medium text-foreground">{r.title}</span>
{r.snippet && <span className="line-clamp-2 text-xs text-muted-foreground">{r.snippet}</span>}
</button>
</li>
);
})}
</ul>
)}
</section>
+1
View File
@@ -5079,6 +5079,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
}}
onEmailSelect={handleEmailSelect}
onOpenSearchResult={handleEmailSelect}
onEmailPrefetch={handleEmailPrefetch}
onEmailDoubleClick={isEmbedded ? ((email) => {
const store = useProTabStore.getState();
@@ -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([]);
+1 -1
View File
@@ -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'),
+27 -4
View File
@@ -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<string, unknown>;
const id = jmapId(raw.id);
if (!id) return null;
if (raw.accountId === undefined) return { id };
const accountId = jmapId(raw.accountId);
return accountId ? { id, accountId } : null;
}
/**
* One plugin's answer, checked: a list of entries with a key and a title,
* plain text cut to its limits, keys unique, at most MAX_RESULTS_PER_PLUGIN.
@@ -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;
}
+1 -1
View File
@@ -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'),
+13 -1
View File
@@ -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;
}
/**