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