)}
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;
}
/**