diff --git a/components/email/__tests__/composer-plugin-lifecycle.test.tsx b/components/email/__tests__/composer-plugin-lifecycle.test.tsx index 2d19c5e75..ff7c7a0c0 100644 --- a/components/email/__tests__/composer-plugin-lifecycle.test.tsx +++ b/components/email/__tests__/composer-plugin-lifecycle.test.tsx @@ -312,3 +312,43 @@ describe('onBeforeCompose prefill', () => { expect(screen.getByText('bob@example.com')).toBeInTheDocument(); }); }); + +describe('onBeforeDraftAutoSave', () => { + function withClient(createDraft: ReturnType) { + useAuthStore.setState({ + client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never, + }); + } + + it('lets a plugin skip a save while typing', async () => { + const before = vi.fn().mockReturnValue(false); + emailHooks.onBeforeDraftAutoSave.register('test', before); + const createDraft = vi.fn().mockResolvedValue('draft-1'); + withClient(createDraft); + try { + render(); + fireEvent.change(screen.getByTestId('composer-subject'), { target: { value: 'Lunch?' } }); + await waitFor(() => expect(before).toHaveBeenCalled(), { timeout: 4000 }); + await act(async () => {}); + + expect(createDraft).not.toHaveBeenCalled(); + } finally { + useAuthStore.setState({ client: null }); + } + }, 15_000); + + it('saves the draft a plugin returns', async () => { + emailHooks.onBeforeDraftAutoSave.register('test', (draft: { subject: string }) => ({ ...draft, subject: `[x] ${draft.subject}` })); + const createDraft = vi.fn().mockResolvedValue('draft-1'); + withClient(createDraft); + try { + render(); + fireEvent.change(screen.getByTestId('composer-subject'), { target: { value: 'Lunch?' } }); + await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 }); + + expect(createDraft.mock.calls[0][1]).toBe('[x] Lunch?'); + } finally { + useAuthStore.setState({ client: null }); + } + }, 15_000); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 90405ec01..26b56528d 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -357,6 +357,9 @@ export function normalizePluginAttachment(raw: unknown): { name: string; type: s return { name, type, size: Math.floor(r.size), blobId: r.blobId }; } +/** How long a plugin may take over onBeforeDraftAutoSave before the save goes on without it. */ +const DRAFT_HOOK_TIMEOUT_MS = 5000; + export function EmailComposer({ onSend, onScheduledSendCreated, @@ -2131,7 +2134,10 @@ export function EmailComposer({ }, [previewAttachment, composerClient]); // Auto-save draft functionality - const saveDraftOnce = async (): Promise => { + // `auto`: the debounced save while typing. Only that one may be skipped + // by a plugin; saves on close and before send always run, or the text + // would be lost. + const saveDraftOnce = async (auto = false): Promise => { if (!client || !composerClient) return null; const versionAtStart = changeVersionRef.current; @@ -2234,7 +2240,19 @@ export function EmailComposer({ fromName, htmlBody: draftHtmlBody } - savedDraft = await emailHooks.onBeforeDraftAutoSave.transform(savedDraft); + if (emailHooks.onBeforeDraftAutoSave.size > 0) { + const outcome = await emailHooks.onBeforeDraftAutoSave.interceptWithValue( + savedDraft, + (result) => (result && typeof result === 'object' ? result as typeof savedDraft : undefined), + { timeoutMs: DRAFT_HOOK_TIMEOUT_MS }, + ); + if (!outcome.allowed && auto) { + // Skipped: leave lastSavedDataRef alone so the next save retries. + setSaveStatus('idle'); + return draftIdRef.current; + } + if (outcome.allowed) savedDraft = outcome.value; + } // Use the JMAP client and raw identity id for the *owning* account // - falls back to active client for single-account / same-account @@ -2321,13 +2339,13 @@ export function EmailComposer({ // racing two `Email/set { destroy, create }` requests against the same // draftId, which left orphan drafts and (when EmailSubmission failed) // looked like "send didn't happen" (#303). - const saveDraft = (): Promise => { + const saveDraft = (opts: { auto?: boolean } = {}): Promise => { const previous = inflightSaveRef.current; const promise = (async (): Promise => { if (previous) { try { await previous; } catch { /* prior failure already reported */ } } - return saveDraftOnce(); + return saveDraftOnce(!!opts.auto); })(); inflightSaveRef.current = promise; promise.finally(() => { @@ -2372,7 +2390,7 @@ export function EmailComposer({ .filter(a => a.blobId && !a.uploading && !a.error) .map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })), }); - saveDraft(); + saveDraft({ auto: true }); }, 2000); // Cleanup on unmount diff --git a/lib/__tests__/plugin-hook-catalog.test.ts b/lib/__tests__/plugin-hook-catalog.test.ts index e4b728bb5..315fd5021 100644 --- a/lib/__tests__/plugin-hook-catalog.test.ts +++ b/lib/__tests__/plugin-hook-catalog.test.ts @@ -37,16 +37,14 @@ 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|transform|takeOver)\\b`); + const re = new RegExp(`\\b${groupName}\\.${hookName}\\s*\\.(emit|emitSync|emitTo|intercept|interceptOutcome|interceptWithValue|transform|takeOver)\\b`); return re.test(sources); } // Groups whose call sites are still being wired in this branch. Each // commit that wires a group removes it here, and the test then insists every // hook in it has a call site. -const NOT_YET_WIRED = new Set([ - 'mail-list', 'mail-read', 'mail-compose', 'mail-send', 'mail-organize', 'mail-mailboxes', 'mail-search', - 'mail-sync', 'settings', 'identities', +const NOT_YET_WIRED = new Set([ 'settings', 'identities', 'filters', 'vacation', 'templates', 'security' ]); diff --git a/lib/__tests__/plugin-hook-result.test.ts b/lib/__tests__/plugin-hook-result.test.ts index 81c1d38dd..8b043041d 100644 --- a/lib/__tests__/plugin-hook-result.test.ts +++ b/lib/__tests__/plugin-hook-result.test.ts @@ -3,7 +3,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { clearAllHooks, uiHooks, pluginErrorTracker } from '../plugin-hooks'; import { interceptWithResult, acceptLinkRewrite, resolveExternalLink } from '../plugin-hook-result'; -const plainIntercept = (v: unknown) => uiHooks.onBeforeExternalLink.intercept(v); beforeEach(() => { clearAllHooks(); @@ -13,7 +12,7 @@ beforeEach(() => { describe('interceptWithResult', () => { it('lets the action through unchanged without handlers', async () => { const ctx = { href: 'https://a.example/' }; - expect(await interceptWithResult(uiHooks.onBeforeExternalLink, ctx, acceptLinkRewrite, plainIntercept)) + expect(await interceptWithResult(uiHooks.onBeforeExternalLink, ctx, acceptLinkRewrite)) .toEqual({ allowed: true, value: ctx }); }); @@ -21,7 +20,7 @@ describe('interceptWithResult', () => { const later = vi.fn(); uiHooks.onBeforeExternalLink.register('a', () => false); uiHooks.onBeforeExternalLink.register('b', later); - const out = await interceptWithResult(uiHooks.onBeforeExternalLink, { href: 'https://a.example/' }, acceptLinkRewrite, plainIntercept); + const out = await interceptWithResult(uiHooks.onBeforeExternalLink, { href: 'https://a.example/' }, acceptLinkRewrite); expect(out).toEqual({ allowed: false }); expect(later).not.toHaveBeenCalled(); }); @@ -30,7 +29,7 @@ describe('interceptWithResult', () => { const second = vi.fn(() => undefined); uiHooks.onBeforeExternalLink.register('a', () => ({ href: 'https://proxy.example/?u=1' })); uiHooks.onBeforeExternalLink.register('b', second); - const out = await interceptWithResult(uiHooks.onBeforeExternalLink, { href: 'https://a.example/', emailId: 'e1' }, acceptLinkRewrite, plainIntercept); + const out = await interceptWithResult(uiHooks.onBeforeExternalLink, { href: 'https://a.example/', emailId: 'e1' }, acceptLinkRewrite); expect(out).toEqual({ allowed: true, value: { href: 'https://proxy.example/?u=1', emailId: 'e1' } }); expect(second).toHaveBeenCalledWith({ href: 'https://proxy.example/?u=1', emailId: 'e1' }); }); @@ -38,13 +37,13 @@ describe('interceptWithResult', () => { it('calls every handler once', async () => { const handler = vi.fn(() => ({ href: 'https://b.example/' })); uiHooks.onBeforeExternalLink.register('a', handler); - await interceptWithResult(uiHooks.onBeforeExternalLink, { href: 'https://a.example/' }, acceptLinkRewrite, plainIntercept); + await interceptWithResult(uiHooks.onBeforeExternalLink, { href: 'https://a.example/' }, acceptLinkRewrite); expect(handler).toHaveBeenCalledTimes(1); }); it('skips a handler that throws (fails open)', async () => { uiHooks.onBeforeExternalLink.register('a', () => { throw new Error('boom'); }); - const out = await interceptWithResult(uiHooks.onBeforeExternalLink, { href: 'https://a.example/' }, acceptLinkRewrite, plainIntercept); + const out = await interceptWithResult(uiHooks.onBeforeExternalLink, { href: 'https://a.example/' }, acceptLinkRewrite); expect(out).toEqual({ allowed: true, value: { href: 'https://a.example/' } }); }); }); diff --git a/lib/plugin-compose-gate.ts b/lib/plugin-compose-gate.ts index 1c93834a4..30b4f16cd 100644 --- a/lib/plugin-compose-gate.ts +++ b/lib/plugin-compose-gate.ts @@ -6,7 +6,6 @@ import type { ComposeOptions } from './plugin-types'; import { emailHooks } from './plugin-hooks'; -import { interceptWithResult } from './plugin-hook-result'; import { buildReplyRecipients, type ReplySource } from './reply-recipients'; import { buildForwardSubject, buildReplySubject } from './subject-prefix'; import { formatRecipient } from './email-composer-utils'; @@ -62,7 +61,9 @@ const sameList = (a: string[], b: string[]) => a.length === b.length && a.every( * otherwise to the fields it changed (empty when none). */ export async function runBeforeCompose(options: ComposeOptions): Promise { - const outcome = await interceptWithResult(emailHooks.onBeforeCompose, options, acceptComposeOptions, (o) => emailHooks.onBeforeCompose.intercept(o)); + const outcome = emailHooks.onBeforeCompose.size > 0 + ? await emailHooks.onBeforeCompose.interceptWithValue(options, acceptComposeOptions) + : { allowed: true as const, value: options }; if (!outcome.allowed) return null; const changed = outcome.value; const prefill: ComposePrefill = {}; diff --git a/lib/plugin-hook-catalog.ts b/lib/plugin-hook-catalog.ts index 57e05fdc9..69d1c73aa 100644 --- a/lib/plugin-hook-catalog.ts +++ b/lib/plugin-hook-catalog.ts @@ -65,7 +65,7 @@ export const HOOK_CATALOG = { onComposerOpen: H('mail-compose', 'observer', 'email:read', 'ComposerContext', 'after the composer opened'), onComposerClose: H('mail-compose', 'observer', 'email:read', '{ draftId, sent: boolean }', 'after the composer closed'), onDraftChange: H('mail-compose', 'observer', 'email:read', 'DraftView', 'while the user writes (debounced)'), - onBeforeDraftAutoSave: H('mail-compose', 'transform', 'email:send', 'AlmostSavedDraft', 'before a draft is saved; return false to skip this save'), + onBeforeDraftAutoSave: H('mail-compose', 'transform', 'email:send', 'AlmostSavedDraft', 'before a draft is saved; return a changed draft, or false to skip a save while typing (saves on close and before send always run)'), onDraftAutoSave: H('mail-compose', 'observer', 'email:read', '{ draftId, subject }', 'after a draft was saved'), onBeforeEditDraft: H('mail-compose', 'transform', 'email:send', 'Email', 'before a saved draft is opened in the composer'), onBuildQuoteHeader: H('mail-compose', 'transform', 'email:send', 'QuoteHeader, QuoteHeaderContext', 'when a reply or forward is set up'), @@ -115,7 +115,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', 'observer', 'email:read', '{ query, count }', 'after search results arrived'), + 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'), // ─── Mail: attachments ───────────────────────────────────── diff --git a/lib/plugin-hook-result.ts b/lib/plugin-hook-result.ts index 1c532885c..b7f1ed017 100644 --- a/lib/plugin-hook-result.ts +++ b/lib/plugin-hook-result.ts @@ -1,83 +1,28 @@ // "Before" hooks whose handlers may either stop an action (return false) or // change what it does (return a value), such as onBeforeExternalLink -// (`{ href }`) and onBeforeCompose (a ComposeOptions to prefill). -// -// HookBus.intercept() only reports whether the action may go ahead and drops -// every other return value; HookBus.transform() keeps return values but -// ignores `false`. Running both would call every plugin twice (and show any -// confirmation dialog twice), so this walks the handlers once and honours -// both answers. A payload changed in place has no effect: plugin handlers run -// in a sandboxed iframe and receive a copy. +// (`{ href }`) and onBeforeCompose (a ComposeOptions to prefill). A payload +// changed in place has no effect: plugin handlers run in a sandboxed iframe +// and receive a copy. -import { pluginErrorTracker, uiHooks, type HookBus } from './plugin-hooks'; +import { uiHooks, type HookBus } from './plugin-hooks'; import type { ExternalLinkContext } from './plugin-types'; import { isOpenableLinkHref } from './email-sanitization'; -// Same budget as HookBus.intercept: these hooks may wait on a dialog. -const INTERCEPT_TIMEOUT_MS = 60_000; - -interface Entry { - pluginId: string; - handler: (...args: unknown[]) => unknown; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function handlersOf(bus: HookBus): Entry[] | null { - const handlers = (bus as unknown as { handlers?: unknown }).handlers; - return Array.isArray(handlers) ? [...(handlers as Entry[])] : null; -} - -function withTimeout(value: T | Promise, ms: number): Promise { - if (!(value instanceof Promise)) return Promise.resolve(value); - return new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error(`Hook timed out after ${ms}ms`)), ms); - value.then( - (v) => { clearTimeout(timer); resolve(v); }, - (e) => { clearTimeout(timer); reject(e); }, - ); - }); -} - export type InterceptResult = { allowed: false } | { allowed: true; value: V }; /** - * Ask every handler of an intercept bus, in order. `false` stops the action; - * any other return value goes through `accept`, which turns it into the next - * value or returns undefined to ignore it. Like `intercept`, a handler that - * throws or times out is skipped (fail open). - * - * `plainIntercept` is the bus's own `intercept`, used when its handlers can't - * be walked (a test double): the veto still works, return values are lost. + * Ask every handler of an intercept bus once, in order. `false` stops the + * action; any other return value goes through `accept`. See + * HookBus.interceptWithValue. */ export async function interceptWithResult( // eslint-disable-next-line @typescript-eslint/no-explicit-any bus: HookBus, value: V, accept: (result: unknown, current: V) => V | undefined, - plainIntercept: (value: V) => Promise, ): Promise> { - // No listeners (or a test double without a size): nothing to ask. if (!(bus && bus.size > 0)) return { allowed: true, value }; - const handlers = handlersOf(bus); - if (!handlers) { - return (await plainIntercept(value)) ? { allowed: true, value } : { allowed: false }; - } - let current = value; - for (const { pluginId, handler } of handlers) { - if (pluginErrorTracker.isDisabled(pluginId)) continue; - let result: unknown; - try { - result = await withTimeout(handler(current), INTERCEPT_TIMEOUT_MS); - } catch (err) { - pluginErrorTracker.record(pluginId, err); - continue; - } - if (result === false) return { allowed: false }; - if (result === undefined || result === null || result === true) continue; - const next = accept(result, current); - if (next !== undefined) current = next; - } - return { allowed: true, value: current }; + return bus.interceptWithValue(value, accept); } /** @@ -105,7 +50,9 @@ export function acceptLinkRewrite(result: unknown, c * when a plugin stopped it. */ export async function resolveExternalLink(ctx: ExternalLinkContext): Promise { - const outcome = await interceptWithResult(uiHooks.onBeforeExternalLink, ctx, acceptLinkRewrite, (c) => uiHooks.onBeforeExternalLink.intercept(c)); + const outcome = uiHooks.onBeforeExternalLink.size > 0 + ? await uiHooks.onBeforeExternalLink.interceptWithValue(ctx, acceptLinkRewrite) + : { allowed: true as const, value: ctx }; if (!outcome.allowed || !isOpenableLinkHref(outcome.value.href)) return null; return outcome.value.href; } diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts index 6667c1e91..94635dcc4 100644 --- a/lib/plugin-hooks.ts +++ b/lib/plugin-hooks.ts @@ -251,6 +251,38 @@ export class HookBus any> { return 'default'; } + /** + * For "before" hooks whose handlers may stop an action (return `false`) or + * change what it does (return a value). Handlers run once each, in order, + * with the intercept budget; `accept` turns a return value into the next + * value, or returns undefined to ignore it. A handler that throws or runs + * out of time is skipped (fail open), and counts as a failure like any + * other hook error. + */ + async interceptWithValue( + initial: V, + accept: (result: unknown, current: V) => V | undefined, + opts: { timeoutMs?: number } = {}, + ): Promise<{ allowed: false } | { allowed: true; value: V }> { + const budget = opts.timeoutMs ?? Math.max(this.timeoutMs, INTERCEPT_TIMEOUT_MS); + let value = initial; + for (const { pluginId, handler } of this.handlers) { + if (pluginErrorTracker.isDisabled(pluginId)) continue; + let result: unknown; + try { + result = await withTimeout((handler as (v: V) => unknown)(value), budget); + } catch (err) { + this.recordFailure(pluginId, err); + continue; + } + if (result === false) return { allowed: false }; + if (result === undefined || result === null || result === true) continue; + const next = accept(result, value); + if (next !== undefined) value = next; + } + return { allowed: true, value }; + } + /** Fire handlers as transforms - each receives the output of the previous */ async transform(initial: V, ...rest: unknown[]): Promise { let value = initial; diff --git a/stores/__tests__/email-store-open-close-hooks.test.ts b/stores/__tests__/email-store-open-close-hooks.test.ts new file mode 100644 index 000000000..12239f19c --- /dev/null +++ b/stores/__tests__/email-store-open-close-hooks.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useEmailStore } from '../email-store'; +import { clearAllHooks, emailHooks } from '@/lib/plugin-hooks'; +import type { Email } from '@/lib/jmap/types'; + +const email = (id: string) => ({ + id, threadId: `t-${id}`, mailboxIds: { inbox: true }, keywords: {}, subject: `Subject ${id}`, + from: [{ name: 'Bob', email: 'bob@example.org' }], to: [], receivedAt: '2026-10-01T00:00:00Z', + preview: 'p', size: 1, hasAttachment: false, blobId: 'raw-blob', +} as unknown as Email); + +beforeEach(() => { + clearAllHooks(); + useEmailStore.setState({ selectedEmail: null }); +}); + +describe('message open and close hooks', () => { + it('hands plugins plain views, not the store object', () => { + const opened = vi.fn(); + const closed = vi.fn(); + emailHooks.onEmailOpen.register('t', opened); + emailHooks.onEmailClose.register('t', closed); + + useEmailStore.getState().selectEmail(email('a')); + useEmailStore.getState().selectEmail(email('b')); + + expect(opened).toHaveBeenCalledTimes(2); + expect(opened.mock.calls[0][0]).toMatchObject({ id: 'a', subject: 'Subject a', from: [{ name: 'Bob', email: 'bob@example.org' }] }); + expect(opened.mock.calls[0][0]).not.toHaveProperty('blobId'); + expect(closed).toHaveBeenCalledWith({ emailId: 'a' }); + }); +}); diff --git a/stores/__tests__/email-store-plugin-search-hooks.test.ts b/stores/__tests__/email-store-plugin-search-hooks.test.ts index ce1a00717..25e45bc5c 100644 --- a/stores/__tests__/email-store-plugin-search-hooks.test.ts +++ b/stores/__tests__/email-store-plugin-search-hooks.test.ts @@ -78,3 +78,28 @@ describe('onSearch', () => { expect(filter).not.toContain('bob@x.test'); }); }); + +describe('onSearchResults', () => { + it('adds the messages a plugin names, fetched from the server, and nothing else', async () => { + vi.mocked(client.searchEmails).mockResolvedValue({ emails: [{ id: 'm1' }], hasMore: false, total: 1 } as never); + vi.mocked(client.getSomeEmails).mockResolvedValue([{ id: 'm9' }] as never); + const handler = vi.fn().mockReturnValue(['m9', 'm1', 42]); + emailHooks.onSearchResults.register('test', handler); + + await useEmailStore.getState().searchEmails(client, 'invoice'); + + expect(handler).toHaveBeenCalledWith([], expect.objectContaining({ query: 'invoice', emailIds: ['m1'] })); + expect(client.getSomeEmails).toHaveBeenCalledWith(['m9'], undefined); + expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['m1', 'm9']); + }); + + it('ignores anything that is not a list of ids', async () => { + vi.mocked(client.searchEmails).mockResolvedValue({ emails: [{ id: 'm1' }], hasMore: false, total: 1 } as never); + emailHooks.onSearchResults.register('test', () => ({ result: { emails: [{ id: 'fake', subject: 'Pay now' }] } })); + + await useEmailStore.getState().searchEmails(client, 'invoice'); + + expect(client.getSomeEmails).not.toHaveBeenCalled(); + expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['m1']); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 19b9820fa..b18672634 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -8,7 +8,7 @@ import { orderKeywords, placeHeldRows, type HeldKeywords, type SortLevel } from import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils"; import { emailHooks } from "@/lib/plugin-hooks"; import { allowEmailMove, emitEmailMove, idsFromSelectionKeys, keywordChangeLists, mailboxToView, quotaToView } from "@/lib/plugin-mail-hooks"; -import { emailSummary } from "@/lib/plugin-projection"; +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"; @@ -1944,6 +1944,18 @@ function mergeRetainedRows(previous: Email[], merged: Email[], retainedIds: Set< return out; } +/** Most message ids a plugin may add to one search through onSearchResults. */ +const MAX_PLUGIN_SEARCH_IDS = 100; + +async function pluginSearchResultIds(query: string, filters: unknown, emails: Email[]): Promise { + if (emailHooks.onSearchResults.size === 0) return []; + const shown = new Set(emails.map((e) => e.id)); + const ids = await emailHooks.onSearchResults.transform([] as string[], { query, filters, emailIds: [...shown] }); + if (!Array.isArray(ids)) return []; + return [...new Set(ids.filter((id): id is string => typeof id === 'string' && id.length > 0 && id.length < 256 && !shown.has(id)))] + .slice(0, MAX_PLUGIN_SEARCH_IDS); +} + export const useEmailStore = create((set, get) => ({ emails: [], mailboxes: [], @@ -2083,11 +2095,13 @@ export const useEmailStore = create((set, get) => ({ selectEmail: (email) => { const prev = get().selectedEmail; set({ selectedEmail: email, lastSelectedEmailKey: email ? emailKeyFor(email) : get().lastSelectedEmailKey }); - if (prev && (!email || email.id !== prev.id)) { - emailHooks.onEmailClose.emitSync(prev); + // Plain data only: handlers in a sandbox get a copy, and the raw store + // object carries fields no plugin permission covers. + if (prev && (!email || email.id !== prev.id) && emailHooks.onEmailClose.size > 0) { + emailHooks.onEmailClose.emitSync({ emailId: prev.id }); } - if (email && (!prev || email.id !== prev.id)) { - emailHooks.onEmailOpen.emitSync(email); + if (email && (!prev || email.id !== prev.id) && emailHooks.onEmailOpen.size > 0) { + emailHooks.onEmailOpen.emitSync(emailToReadView(email)); } }, holdListRow: (rowKey) => { @@ -3650,17 +3664,11 @@ export const useEmailStore = create((set, get) => ({ result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0); } - const hookEdit = await emailHooks.onSearchResults.transform({ - newEmailIds: [] as string[], - result: result, - query: query, - filters: searchFilters - }); - - result = hookEdit.result; - if (hookEdit.newEmailIds.length > 0) { - // in unified, accountId will be undefined and we will use the default. - const newEmails = await resolveActionClient(client).getSomeEmails(hookEdit.newEmailIds, accountId); + // Plugins may add messages to the results by id; the messages are + // fetched from the server, so a plugin can't put made-up rows in the list. + const extraIds = await pluginSearchResultIds(query, searchFilters, result.emails); + if (extraIds.length > 0) { + const newEmails = await resolveActionClient(client).getSomeEmails(extraIds, accountId); result.emails.push(...newEmails); result.total += newEmails.length; } @@ -3776,17 +3784,11 @@ export const useEmailStore = create((set, get) => ({ if (controller.signal.aborted) return; - const hookEdit = await emailHooks.onSearchResults.transform({ - newEmailIds: [] as string[], - result: result, - query: searchQuery, - filters: searchFilters - }); - - result = hookEdit.result; - - if (hookEdit.newEmailIds.length > 0) { - const newEmails = await resolveActionClient(client).getSomeEmails(hookEdit.newEmailIds, accountId); + // Plugins may add messages to the results by id; the messages are + // fetched from the server, so a plugin can't put made-up rows in the list. + const extraIds = await pluginSearchResultIds(searchQuery, searchFilters, result.emails); + if (extraIds.length > 0) { + const newEmails = await resolveActionClient(client).getSomeEmails(extraIds, accountId); result.emails.push(...newEmails); result.total += newEmails.length; }