From 2d67442a2bd81d1cbb8e4791b25f1c23eecc8e46 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:51:17 +0200 Subject: [PATCH] perf: list search results without waiting for the highlighted excerpts --- lib/__tests__/jmap-client-extensions.test.ts | 51 ++++++++++++ lib/__tests__/search-snippet.test.ts | 34 ++++++++ .../unified-mailbox-search-accounts.test.ts | 22 ++++++ lib/jmap/client-interface.ts | 24 +++++- lib/jmap/client.ts | 46 +++++++++-- lib/search-snippet.ts | 37 +++++++++ lib/unified-mailbox.ts | 29 +++++-- .../email-store-plugin-search-hooks.test.ts | 2 +- .../email-store-search-accounts.test.ts | 18 ++--- .../email-store-search-scope.test.ts | 4 +- .../email-store-search-snippets.test.ts | 79 +++++++++++++++++++ stores/email-store.ts | 41 ++++++++-- 12 files changed, 354 insertions(+), 33 deletions(-) create mode 100644 stores/__tests__/email-store-search-snippets.test.ts diff --git a/lib/__tests__/jmap-client-extensions.test.ts b/lib/__tests__/jmap-client-extensions.test.ts index 0543433b3..be6f818ce 100644 --- a/lib/__tests__/jmap-client-extensions.test.ts +++ b/lib/__tests__/jmap-client-extensions.test.ts @@ -200,6 +200,57 @@ describe('JMAP client extensions', () => { expect(result.emails).toHaveLength(1); expect(result.emails[0].searchSnippet).toBeUndefined(); }); + + describe('deferred', () => { + const hits = [ + { id: 'e1', threadId: 't1', receivedAt: '2026-08-29T00:00:00Z', subject: 'Invoice', mailboxIds: {}, keywords: {} }, + { id: 'e2', threadId: 't2', receivedAt: '2026-08-28T00:00:00Z', subject: 'Other', mailboxIds: {}, keywords: {} }, + ]; + const searchOrSnippets = (snippets: unknown) => (body: RequestBody) => body.methodCalls[0][0] === 'SearchSnippet/get' + ? { methodResponses: [snippets] } + : { methodResponses: [['Email/query', { ids: ['e1', 'e2'], total: 2 }, '0'], ['Email/get', { list: hits.map((hit) => ({ ...hit })) }, '1']] }; + + it('lists the results without waiting for the snippets', async () => { + await setup(); + answer(searchOrSnippets(['SearchSnippet/get', { list: [{ emailId: 'e1', subject: 'Invoice', preview: null }] }, 'snippets'])); + + const result = await client.searchEmails('invoice', 'inbox', undefined, 50, 0, { deferSnippets: true }); + + // The search itself no longer carries the snippet call ... + expect(requests[0].methodCalls.map((call) => call[0])).toEqual(['Email/query', 'Email/get']); + expect(result.emails.map((email) => email.id)).toEqual(['e1', 'e2']); + expect(result.emails[0].searchSnippet).toBeUndefined(); + // ... it follows on its own, for exactly the listed messages. + expect(await result.snippets).toEqual([{ emailId: 'e1', subject: 'Invoice', preview: null }]); + expect(requests[1].methodCalls).toEqual([['SearchSnippet/get', { + accountId: 'acct-1', + filter: { operator: 'AND', conditions: [{ inMailbox: 'inbox' }, { text: 'invoice' }] }, + emailIds: ['e1', 'e2'], + }, 'snippets']]); + }); + + it('asks for no snippets when nothing could be highlighted', async () => { + await setup(); + answer(searchOrSnippets(['SearchSnippet/get', { list: [] }, 'snippets'])); + + const structural = await client.advancedSearchEmails({ hasKeyword: '$flagged' }, undefined, 50, 0, { deferSnippets: true }); + expect(structural.snippets).toBeUndefined(); + + answer(() => ({ methodResponses: [['Email/query', { ids: [], total: 0 }, '0'], ['Email/get', { list: [] }, '1']] })); + const empty = await client.searchEmails('nothing', undefined, undefined, 50, 0, { deferSnippets: true }); + expect(empty.snippets).toBeUndefined(); + expect(requests.every((body) => body.methodCalls[0][0] !== 'SearchSnippet/get')).toBe(true); + }); + + it('resolves to nothing instead of rejecting when the snippet request fails', async () => { + await setup(); + answer(searchOrSnippets(['error', { type: 'requestTooLarge' }, 'snippets'])); + + const result = await client.searchEmails('invoice', undefined, undefined, 50, 0, { deferSnippets: true }); + expect(result.emails).toHaveLength(2); + await expect(result.snippets).resolves.toBeUndefined(); + }); + }); }); describe('deleteMailbox', () => { diff --git a/lib/__tests__/search-snippet.test.ts b/lib/__tests__/search-snippet.test.ts index 8772013de..a5974bf7e 100644 --- a/lib/__tests__/search-snippet.test.ts +++ b/lib/__tests__/search-snippet.test.ts @@ -5,6 +5,7 @@ import { hasMarkedSegment, parseSearchSnippet, snippetFilterFor, + withSearchSnippets, } from '../search-snippet'; import type { Email } from '@/lib/jmap/types'; @@ -96,3 +97,36 @@ describe('attachSearchSnippets', () => { expect(emails[0].searchSnippet).toBeUndefined(); }); }); + +describe('withSearchSnippets', () => { + const email = (id: string, accountId?: string) => ({ id, subject: `Subject ${id}`, accountId }) as unknown as Email; + const marked = (emailId: string) => ({ emailId, subject: `Subject ${emailId}`, preview: null }); + + it('returns new objects only for the emails that gained a snippet', () => { + const emails = [email('e1'), email('e2')]; + const next = withSearchSnippets(emails, [{ list: [marked('e2')] }]); + + expect(next).not.toBe(emails); + expect(next[0]).toBe(emails[0]); + expect(next[1]).not.toBe(emails[1]); + expect(next[1].searchSnippet).toEqual({ subject: 'Subject e2', preview: null }); + // The list already on screen is left untouched. + expect(emails[1].searchSnippet).toBeUndefined(); + }); + + it('returns the same list when nothing is highlighted', () => { + const emails = [email('e1')]; + expect(withSearchSnippets(emails, [])).toBe(emails); + expect(withSearchSnippets(emails, [{ list: [] }])).toBe(emails); + expect(withSearchSnippets(emails, [{ list: [{ emailId: 'e1', subject: 'Subject e1', preview: null }] }])).toBe(emails); + expect(withSearchSnippets(emails, [{ list: [marked('other')] }])).toBe(emails); + }); + + it("keeps each account's snippets to its own rows, since ids repeat across accounts", () => { + const emails = [email('a', 'own'), email('a', 'group')]; + const next = withSearchSnippets(emails, [{ accountId: 'group', list: [marked('a')] }]); + + expect(next[0]).toBe(emails[0]); + expect(next[1].searchSnippet?.subject).toBe('Subject a'); + }); +}); diff --git a/lib/__tests__/unified-mailbox-search-accounts.test.ts b/lib/__tests__/unified-mailbox-search-accounts.test.ts index 9008e8181..cda7d2ec1 100644 --- a/lib/__tests__/unified-mailbox-search-accounts.test.ts +++ b/lib/__tests__/unified-mailbox-search-accounts.test.ts @@ -117,6 +117,28 @@ describe('searchAcrossAccounts', () => { }); }); +describe('deferred snippets across accounts', () => { + it("asks every account to defer and hands back each account's snippets under its id", async () => { + const ownSearch = vi.fn(async () => ({ ...page([email('a', '2026-09-10T00:00:00Z', 'inbox')]), snippets: Promise.resolve([{ emailId: 'a', subject: 'own' }]) })); + const groupSearch = vi.fn(async () => ({ ...page([email('a', '2026-09-12T00:00:00Z', 'group:g-inbox')]), snippets: Promise.resolve(undefined) })); + const own = makeAccount({ accountId: 'login', jmapAccountId: 'me', mailboxes: ownMailboxes }, { searchEmails: ownSearch }); + const group = makeAccount({ accountId: 'group', isShared: true, mailboxes: groupMailboxes }, { searchEmails: groupSearch }); + + const result = await searchAcrossAccounts([own, group], 'x', 50, 0, { deferSnippets: true }); + + expect(ownSearch).toHaveBeenCalledWith('x', undefined, undefined, 50, 0, { deferSnippets: true }); + expect(groupSearch).toHaveBeenCalledWith('x', undefined, 'group', 50, 0, { deferSnippets: true }); + // A failed or empty snippet request of one account drops out. + expect(await result.snippets).toEqual([{ accountId: 'login', list: [{ emailId: 'a', subject: 'own' }] }]); + }); + + it('carries no snippets when no account deferred any', async () => { + const own = makeAccount({ accountId: 'login' }, { searchEmails: vi.fn(async () => page([])) }); + const result = await searchAcrossAccounts([own], 'x', 50, 0); + expect(result.snippets).toBeUndefined(); + }); +}); + describe('advancedSearchAcrossAccounts', () => { it('sends the same folder-less filter to every account at the requested page', async () => { const filter = { operator: 'AND', conditions: [{ text: 'acesso*' }, { from: 'bob@example.com' }] }; diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 670dd5838..f1883149a 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -2,6 +2,7 @@ import type { Attachment, Email, Mailbox, MailboxRights, StateChange, AccountSta import type { SieveScript, SieveCapabilities } from "./sieve-types"; import type { FileNameRules } from "@/lib/file-name-rules"; import type { SortLevel } from "@/lib/message-list-order"; +import type { SearchSnippetResult } from "@/lib/search-snippet"; /** What `migrateKeyword` managed to do. */ export interface KeywordMigration { @@ -48,6 +49,24 @@ export interface CalendarEventUpdateOptions { keepSequence?: boolean; } +/** How a search request fetches its SearchSnippet/get highlights. */ +export interface SearchRequestOptions { + /** + * Fetch the snippets in a follow-up request instead of chaining them onto + * the search, and hand them back as `snippets`. For interactive searches, + * where the list must not wait for the highlighting. + */ + deferSnippets?: boolean; +} + +export interface SearchEmailsResult { + emails: Email[]; + hasMore: boolean; + total: number; + /** Set only with `deferSnippets`, when the filter has a term to highlight. */ + snippets?: Promise; +} + export interface IJMAPClient { // ── Connection lifecycle ────────────────────────────────────── connect(): Promise; @@ -224,13 +243,14 @@ export interface IJMAPClient { }): Promise<{ keywords: Record; scanned: number; total: number; complete: boolean }>; /** Per-tab unread counts for message-list category tabs (filter = resolved tab fragment, null = unfiltered). */ getCategoryUnreadCounts(mailboxId: string, tabs: Array<{ id: string; filter: Record | null }>, accountId?: string): Promise>; - searchEmails(query: string, mailboxId?: string, accountId?: string, limit?: number, position?: number): Promise<{ emails: Email[]; hasMore: boolean; total: number }>; + searchEmails(query: string, mailboxId?: string, accountId?: string, limit?: number, position?: number, options?: SearchRequestOptions): Promise; advancedSearchEmails( filter: Record, accountId?: string, limit?: number, position?: number, - ): Promise<{ emails: Email[]; hasMore: boolean; total: number }>; + options?: SearchRequestOptions, + ): Promise; /** * Lean recipient search for compose autocomplete ("search the server" action): * finds messages in `sentMailboxId` whose to/cc matches `query` and returns diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index da90a1cf7..958945b72 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1,7 +1,7 @@ import { generateUUID } from '@/lib/utils'; import type { Attachment, Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, CalendarEventNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarComponentType, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, CreateCalendarOptions, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailPushConfig, DeliveryStatus, EmailSubmission, RejectedRecipient, ScheduledEmail, SendEmailResult, SharedAccount } from "./types"; import type { SieveScript, SieveCapabilities } from "./sieve-types"; -import type { CalendarEventUpdateOptions, IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration } from "./client-interface"; +import type { CalendarEventUpdateOptions, IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration, SearchEmailsResult, SearchRequestOptions } from "./client-interface"; import { attachSearchSnippets, filterHasSnippetTerms, snippetFilterFor, type SearchSnippetResult } from "@/lib/search-snippet"; import { batched, itemsPerRequest } from "./request-limits"; import { keywordPointer } from "./patch-pointer"; @@ -3290,7 +3290,7 @@ export class JMAPClient implements IJMAPClient { } } - async searchEmails(query: string, mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[], hasMore: boolean, total: number }> { + async searchEmails(query: string, mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, options: SearchRequestOptions = {}): Promise { try { const targetAccountId = accountId || this.accountId; @@ -3326,7 +3326,7 @@ export class JMAPClient implements IJMAPClient { "#ids": { resultOf: "0", name: "Email/query", path: "/ids" }, properties: [...EMAIL_LIST_PROPERTIES], }, "1"], - this.searchSnippetCall(targetAccountId, filter), + ...(options.deferSnippets ? [] : [this.searchSnippetCall(targetAccountId, filter)]), ]); assertQuerySucceeded(response, 'Search'); @@ -3346,7 +3346,7 @@ export class JMAPClient implements IJMAPClient { namespaceMailboxIds(emails, accountId); } - return { emails, hasMore, total }; + return { emails, hasMore, total, ...this.deferredSnippets(options, targetAccountId, filter, emails) }; } catch (error) { // Rethrow: an empty result would read as "No results found". console.error('Search failed:', error); @@ -3372,6 +3372,35 @@ export class JMAPClient implements IJMAPClient { }, "snippets"]; } + /** + * With `deferSnippets`, the snippets of a result page as a request of their + * own, already in flight. Chained onto the search they cost no round trip + * but hold the whole response back while the server excerpts every hit, + * several times longer than the query itself, so an interactive search + * lists its results first and highlights them when this resolves. Never + * rejects: a failure leaves the results without snippets, as it does when + * chained. + */ + private deferredSnippets( + options: SearchRequestOptions, + accountId: string, + filter: Record | undefined, + emails: Email[], + ): Pick { + if (!options.deferSnippets || emails.length === 0 || !filter || !filterHasSnippetTerms(filter)) return {}; + const snippets = this.request([ + ["SearchSnippet/get", { + accountId, + filter: snippetFilterFor(filter), + emailIds: emails.map((email) => email.id), + }, "snippets"], + ]).then((response) => this.searchSnippetsFrom(response)).catch((error) => { + debug.warn("jmap", "SearchSnippet/get failed:", error); + return undefined; + }); + return { snippets }; + } + /** The SearchSnippet list of a search response, if the call succeeded. */ private searchSnippetsFrom(response: JMAPResponse): SearchSnippetResult[] | undefined { for (const [method, result, callId] of response.methodResponses ?? []) { @@ -3386,8 +3415,9 @@ export class JMAPClient implements IJMAPClient { filter: Record, accountId?: string, limit: number = 50, - position: number = 0 - ): Promise<{ emails: Email[], hasMore: boolean, total: number }> { + position: number = 0, + options: SearchRequestOptions = {}, + ): Promise { try { const targetAccountId = accountId || this.accountId; @@ -3409,7 +3439,7 @@ export class JMAPClient implements IJMAPClient { "#ids": { resultOf: "0", name: "Email/query", path: "/ids" }, properties: [...EMAIL_LIST_PROPERTIES], }, "1"], - this.searchSnippetCall(targetAccountId, hasFilter ? filter : undefined), + ...(options.deferSnippets ? [] : [this.searchSnippetCall(targetAccountId, hasFilter ? filter : undefined)]), ]); assertQuerySucceeded(response, 'Search'); @@ -3429,7 +3459,7 @@ export class JMAPClient implements IJMAPClient { namespaceMailboxIds(emails, accountId); } - return { emails, hasMore, total }; + return { emails, hasMore, total, ...this.deferredSnippets(options, targetAccountId, hasFilter ? filter : undefined, emails) }; } catch (error) { console.error('Advanced search failed:', error); throw error; diff --git a/lib/search-snippet.ts b/lib/search-snippet.ts index 5fe84f8dd..e34584ece 100644 --- a/lib/search-snippet.ts +++ b/lib/search-snippet.ts @@ -109,6 +109,43 @@ export function snippetFilterFor(filter: Record): Record batch.list.length > 0) + .map((batch) => ({ accountId: batch.accountId, byId: new Map(batch.list.map((s) => [s.emailId, s])) })); + if (lookups.length === 0) return emails; + let changed = false; + const next = emails.map((email) => { + for (const { accountId, byId } of lookups) { + if (accountId !== undefined && email.accountId !== accountId) continue; + const snippet = byId.get(email.id); + if (!snippet) continue; + const [withSnippet] = attachSearchSnippets([{ ...email }], [snippet]); + if (!withSnippet.searchSnippet) return email; + changed = true; + return withSnippet; + } + return email; + }); + return changed ? next : emails; +} + /** * Attaches the snippets to the emails they belong to. A snippet without any * highlighted term (the server found nothing to mark, or returned the plain diff --git a/lib/unified-mailbox.ts b/lib/unified-mailbox.ts index e324610bb..8fac05d5c 100644 --- a/lib/unified-mailbox.ts +++ b/lib/unified-mailbox.ts @@ -1,6 +1,7 @@ import type { Email, Mailbox, UnifiedMailboxRole, CrossView } from '@/lib/jmap/types'; import { CROSS_EXCLUDED_ROLES } from '@/lib/jmap/types'; -import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { IJMAPClient, SearchEmailsResult, SearchRequestOptions } from '@/lib/jmap/client-interface'; +import type { DeferredSnippetBatch } from '@/lib/search-snippet'; import { andFilters } from '@/lib/jmap/search-utils'; import { compareEmails, type SortLevel } from '@/lib/message-list-order'; @@ -43,6 +44,8 @@ export interface UnifiedFetchResult { total: number; hasMore: boolean; errors: Map; // accountId -> error message + /** Deferred search snippets per account (see SearchRequestOptions.deferSnippets). */ + snippets?: Promise; } export interface UnifiedMailboxCounts { @@ -591,7 +594,7 @@ export async function fetchTagEmails( ); } -export interface AcrossAccountsSearchOptions { +export interface AcrossAccountsSearchOptions extends SearchRequestOptions { /** * Leave every account's Trash and Junk out: the search panel's default * scope, "All folders except Spam and Trash". Its "All folders" scope @@ -624,8 +627,9 @@ export async function searchAcrossAccounts( return exclusion ? account.client.advancedSearchEmails( andFilters({ text: query.trim() }, exclusion), jmapAccountId, limit, positionFor(position, account), + ...searchRequestOptions(options), ) - : account.client.searchEmails(query, undefined, jmapAccountId, limit, positionFor(position, account)); + : account.client.searchEmails(query, undefined, jmapAccountId, limit, positionFor(position, account), ...searchRequestOptions(options)); }, newestFirst, ); @@ -649,11 +653,17 @@ export async function advancedSearchAcrossAccounts( jmapAccountId, limit, positionFor(position, account), + ...searchRequestOptions(options), ), newestFirst, ); } +/** The trailing SearchRequestOptions argument, left off when nothing is asked of it. */ +function searchRequestOptions(options: AcrossAccountsSearchOptions): [] | [SearchRequestOptions] { + return options.deferSnippets ? [{ deferSnippets: true }] : []; +} + function newestFirst(a: Email, b: Email): number { return new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime(); } @@ -669,14 +679,14 @@ async function fanOutAccountQuery( run: ( account: UnifiedAccountClient, jmapAccountId: string | undefined, - ) => Promise<{ emails: Email[]; total: number; hasMore: boolean }>, + ) => Promise, sort: (a: Email, b: Email) => number, ): Promise { const errors = new Map(); type AccountResult = { account: UnifiedAccountClient; - result: { emails: Email[]; total: number; hasMore: boolean }; + result: SearchEmailsResult; } | null; const promises = accounts.map(async (account): Promise => { @@ -695,10 +705,14 @@ async function fanOutAccountQuery( let mergedEmails: Email[] = []; let totalSum = 0; let anyHasMore = false; + const snippetBatches: Promise[] = []; for (const outcome of results) { if (outcome.status !== 'fulfilled' || outcome.value === null) continue; const { account, result } = outcome.value; + if (result.snippets) { + snippetBatches.push(result.snippets.then((list) => (list ? { accountId: account.accountId, list } : null))); + } // Decorate shallow copies, not the shared client-returned objects. The // source stamps are what routes every later action (read, move, delete, // thread expansion) back to the owning account. @@ -717,7 +731,10 @@ async function fanOutAccountQuery( mergedEmails.sort(sort); - return { emails: mergedEmails, total: totalSum, hasMore: anyHasMore, errors }; + const snippets = snippetBatches.length > 0 + ? Promise.all(snippetBatches).then((batches) => batches.filter((batch): batch is DeferredSnippetBatch => batch !== null)) + : undefined; + return { emails: mergedEmails, total: totalSum, hasMore: anyHasMore, errors, ...(snippets ? { snippets } : {}) }; } /** diff --git a/stores/__tests__/email-store-plugin-search-hooks.test.ts b/stores/__tests__/email-store-plugin-search-hooks.test.ts index 25e45bc5c..30d37938f 100644 --- a/stores/__tests__/email-store-plugin-search-hooks.test.ts +++ b/stores/__tests__/email-store-plugin-search-hooks.test.ts @@ -47,7 +47,7 @@ describe('onSearch', () => { await useEmailStore.getState().searchEmails(client, 'invoice'); expect(handler).toHaveBeenCalledWith({ query: 'invoice', filters: DEFAULT_SEARCH_FILTERS }); - expect(client.searchEmails).toHaveBeenCalledWith('invoice -label:ads', 'inbox', undefined, 50, 0); + expect(client.searchEmails).toHaveBeenCalledWith('invoice -label:ads', 'inbox', undefined, 50, 0, { deferSnippets: true }); expect(useEmailStore.getState().searchQuery).toBe('invoice'); }); diff --git a/stores/__tests__/email-store-search-accounts.test.ts b/stores/__tests__/email-store-search-accounts.test.ts index 73b2482f3..045be66f5 100644 --- a/stores/__tests__/email-store-search-accounts.test.ts +++ b/stores/__tests__/email-store-search-accounts.test.ts @@ -110,8 +110,8 @@ describe('"All folders" search across the own and group accounts (#1082)', () => expect(state.hasMoreEmails).toBe(false); // One folder-less query per account. expect(client.searchEmails).toHaveBeenCalledTimes(2); - expect(client.searchEmails).toHaveBeenCalledWith('acesso', undefined, undefined, 50, 0); - expect(client.searchEmails).toHaveBeenCalledWith('acesso', undefined, 'group', 50, 0); + expect(client.searchEmails).toHaveBeenCalledWith('acesso', undefined, undefined, 50, 0, { deferSnippets: true }); + expect(client.searchEmails).toHaveBeenCalledWith('acesso', undefined, 'group', 50, 0, { deferSnippets: true }); }); it('merges own and group hits newest first and stamps each with its source', async () => { @@ -151,7 +151,7 @@ describe('"All folders" search across the own and group accounts (#1082)', () => await useEmailStore.getState().searchEmails(client, 'acesso'); expect(client.searchEmails).toHaveBeenCalledTimes(1); - expect(client.searchEmails).toHaveBeenCalledWith('acesso', 'inbox', 'group', 50, 0); + expect(client.searchEmails).toHaveBeenCalledWith('acesso', 'inbox', 'group', 50, 0, { deferSnippets: true }); }); it('makes a single own-account query for a login without group folders', async () => { @@ -160,7 +160,7 @@ describe('"All folders" search across the own and group accounts (#1082)', () => await useEmailStore.getState().searchEmails(client, 'fatura'); expect(client.searchEmails).toHaveBeenCalledTimes(1); - expect(client.searchEmails).toHaveBeenCalledWith('fatura', undefined, undefined, 50, 0); + expect(client.searchEmails).toHaveBeenCalledWith('fatura', undefined, undefined, 50, 0, { deferSnippets: true }); expect(useEmailStore.getState().emails.map(e => e.id)).toEqual(['own-1', 'own-2']); }); @@ -281,8 +281,8 @@ describe('Spam and Trash in the folder-less search scopes', () => { await useEmailStore.getState().searchEmails(client, 'acesso'); expect(client.advancedSearchEmails).not.toHaveBeenCalled(); - expect(client.searchEmails).toHaveBeenCalledWith('acesso', undefined, undefined, 50, 0); - expect(client.searchEmails).toHaveBeenCalledWith('acesso', undefined, 'group', 50, 0); + expect(client.searchEmails).toHaveBeenCalledWith('acesso', undefined, undefined, 50, 0, { deferSnippets: true }); + expect(client.searchEmails).toHaveBeenCalledWith('acesso', undefined, 'group', 50, 0, { deferSnippets: true }); client.searchEmails.mockClear(); useEmailStore.setState({ searchFilters: { ...DEFAULT_SEARCH_FILTERS, from: 'sender@example.com' } }); @@ -296,7 +296,7 @@ describe('Spam and Trash in the folder-less search scopes', () => { await useEmailStore.getState().searchEmails(client, 'fatura'); expect(client.searchEmails).toHaveBeenCalledTimes(1); - expect(client.searchEmails).toHaveBeenCalledWith('fatura', 'trash', undefined, 50, 0); + expect(client.searchEmails).toHaveBeenCalledWith('fatura', 'trash', undefined, 50, 0, { deferSnippets: true }); }); it('goes back to the default scope when the filters are cleared', () => { @@ -311,7 +311,7 @@ describe('Spam and Trash in the folder-less search scopes', () => { await useEmailStore.getState().searchEmails(client, 'fatura'); expect(client.searchEmails).toHaveBeenCalledTimes(1); - expect(client.searchEmails).toHaveBeenCalledWith('fatura', 'trash', undefined, 50, 0); + expect(client.searchEmails).toHaveBeenCalledWith('fatura', 'trash', undefined, 50, 0, { deferSnippets: true }); useEmailStore.getState().selectMailbox(ownJunk.id); expect(useEmailStore.getState().searchMailboxId).toBe('junk'); @@ -326,7 +326,7 @@ describe('Spam and Trash in the folder-less search scopes', () => { await useEmailStore.getState().searchEmails(client, 'acesso'); expect(client.searchEmails).toHaveBeenCalledTimes(1); - expect(client.searchEmails).toHaveBeenCalledWith('acesso', 'trash', 'group', 50, 0); + expect(client.searchEmails).toHaveBeenCalledWith('acesso', 'trash', 'group', 50, 0, { deferSnippets: true }); }); it('keeps a scope the user picked when entering or leaving Spam and Trash', () => { diff --git a/stores/__tests__/email-store-search-scope.test.ts b/stores/__tests__/email-store-search-scope.test.ts index 2d114d18f..5d5462ee0 100644 --- a/stores/__tests__/email-store-search-scope.test.ts +++ b/stores/__tests__/email-store-search-scope.test.ts @@ -80,8 +80,8 @@ describe('search folder scope (#788)', () => { // No mailbox id -> the JMAP client omits the inMailbox constraint; the // shared folder's owner is asked as well (#1082). expect(client.searchEmails).toHaveBeenCalledTimes(2); - expect(client.searchEmails).toHaveBeenCalledWith('invoice', undefined, undefined, 50, 0); - expect(client.searchEmails).toHaveBeenCalledWith('invoice', undefined, 'owner-x', 50, 0); + expect(client.searchEmails).toHaveBeenCalledWith('invoice', undefined, undefined, 50, 0, { deferSnippets: true }); + expect(client.searchEmails).toHaveBeenCalledWith('invoice', undefined, 'owner-x', 50, 0, { deferSnippets: true }); }); it('advanced search from within a folder does not add an inMailbox condition', async () => { diff --git a/stores/__tests__/email-store-search-snippets.test.ts b/stores/__tests__/email-store-search-snippets.test.ts new file mode 100644 index 000000000..54e3001f6 --- /dev/null +++ b/stores/__tests__/email-store-search-snippets.test.ts @@ -0,0 +1,79 @@ +import { 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 type { Email, Mailbox } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { SearchSnippetResult } from '@/lib/search-snippet'; + +// An interactive search lists its hits as soon as Email/query + Email/get +// answer; the SearchSnippet/get highlighting follows in a request of its own +// and is applied to the rows already on screen. + +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 }; +} + +describe('interactive search with deferred snippets', () => { + let client: IJMAPClient; + let snippets: ReturnType>; + + beforeEach(() => { + snippets = deferred(); + client = { + searchEmails: vi.fn(async () => ({ emails: [hit('e1'), hit('e2')], hasMore: false, total: 2, snippets: snippets.promise })), + advancedSearchEmails: vi.fn(), + getSomeEmails: vi.fn().mockResolvedValue([]), + getAccountId: () => 'me', + } as unknown as IJMAPClient; + useAuthStore.setState({ + activeAccountId: 'account-a', + getClientForAccount: (id: string) => (id === 'account-a' ? client : undefined) as never, + } as never); + useSettingsStore.setState({ emailsPerPage: 50 } as never); + useEmailStore.setState({ + isUnifiedView: false, unifiedRole: null, crossView: null, viewingAccountId: null, selectedKeyword: null, + selectedMailbox: 'inbox', + // Scoped to one folder: a single searchEmails call on the client. + searchMailboxId: 'inbox', + searchQuery: '', searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchAbortController: null, emails: [], + mailboxes: [{ id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 2, unreadEmails: 0 } as Mailbox], + accountMailboxes: {}, + }); + }); + + it('lists the results before the snippets arrive and highlights them after', async () => { + await useEmailStore.getState().searchEmails(client, 'subject'); + + expect(client.searchEmails).toHaveBeenCalledWith('subject', 'inbox', undefined, 50, 0, { deferSnippets: true }); + const listed = useEmailStore.getState().emails; + expect(listed.map((e) => e.id)).toEqual(['e1', 'e2']); + expect(listed[0].searchSnippet).toBeUndefined(); + + snippets.resolve([{ emailId: 'e2', subject: 'Subject e2', preview: null }]); + await vi.waitFor(() => expect(useEmailStore.getState().emails[1].searchSnippet).toBeDefined()); + const highlighted = useEmailStore.getState().emails; + expect(highlighted[1].searchSnippet).toEqual({ subject: 'Subject e2', preview: null }); + // The row without a snippet keeps its object, so it does not re-render. + expect(highlighted[0]).toBe(listed[0]); + }); + + it('drops the snippets when the search was left before they arrived', async () => { + await useEmailStore.getState().searchEmails(client, 'subject'); + useEmailStore.setState({ searchQuery: '' }); + + snippets.resolve([{ emailId: 'e1', subject: 'Subject e1', preview: null }]); + await snippets.promise; + await new Promise((r) => setTimeout(r, 0)); + + expect(useEmailStore.getState().emails.every((e) => e.searchSnippet === undefined)).toBe(true); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index ac536f195..4267eb075 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -1,7 +1,8 @@ import { create } from "zustand"; import { Email, Mailbox, StateChange, ScheduledEmail, SendEmailResult, isUnifiedMailboxId, isCrossViewId } from "@/lib/jmap/types"; import type { UnifiedMailboxRole, CrossView } from "@/lib/jmap/types"; -import type { IJMAPClient } from "@/lib/jmap/client-interface"; +import type { IJMAPClient, SearchEmailsResult } from "@/lib/jmap/client-interface"; +import { withSearchSnippets, type DeferredSnippetBatch } from "@/lib/search-snippet"; import { useSettingsStore, getMessageListOrderFor } from "@/stores/settings-store"; import { useCalendarStore } from "@/stores/calendar-store"; import { orderKeywords, placeHeldRows, type HeldKeywords, type SortLevel } from "@/lib/message-list-order"; @@ -1469,6 +1470,34 @@ function isUnscopedSearchActive( * The fan-out options of a folder-less search scope: only "All folders" * searches Trash and Junk, the default scope ("") leaves them out. */ +/** A single-account search result with its deferred snippets in fan-out shape. */ +function withSnippetBatches( + result: SearchEmailsResult, +): Omit & { snippets?: Promise } { + const { snippets, ...rest } = result; + return snippets ? { ...rest, snippets: snippets.then((list) => (list ? [{ list }] : [])) } : rest; +} + +/** + * Highlights the results of an interactive search once its deferred + * SearchSnippet/get lands (see SearchRequestOptions.deferSnippets). The list + * is on screen by then, so this applies only while that same search still is: + * matching ids would not do, since a folder opened in the meantime can hold + * the same messages and must not show search excerpts. + */ +function highlightWhenSnippetsArrive(snippets: Promise | undefined): void { + if (!snippets) return; + const { searchQuery, searchFilters, searchMailboxId } = useEmailStore.getState(); + const isSameView = captureEmailListView(); + void snippets.then((batches) => { + const state = useEmailStore.getState(); + if (!isSameView() || state.isLoading) return; + if (state.searchQuery !== searchQuery || state.searchFilters !== searchFilters || state.searchMailboxId !== searchMailboxId) return; + const emails = withSearchSnippets(state.emails, batches); + if (emails !== state.emails) useEmailStore.setState({ emails }); + }); +} + function acrossAccountsSearchOptions(searchMailboxId: string): AcrossAccountsSearchOptions { return { excludeTrashAndJunk: searchMailboxId !== SEARCH_SCOPE_ALL_FOLDERS }; } @@ -3679,7 +3708,7 @@ export const useEmailStore = create((set, get) => ({ // shared accounts' mail (#1082). The default scope leaves every // account's Trash and Junk out; "All folders" searches them too. const built = buildTagViewAccountClients(client); - result = await searchAcrossAccounts(built, query, emailsPerPage, 0, acrossAccountsSearchOptions(searchMailboxId)); + result = await searchAcrossAccounts(built, query, emailsPerPage, 0, { ...acrossAccountsSearchOptions(searchMailboxId), deferSnippets: true }); unifiedErrors = result.errors; } else { @@ -3691,7 +3720,7 @@ export const useEmailStore = create((set, get) => ({ // Only pass accountId for shared mailboxes, not for primary account accountId = mailbox?.isShared ? mailbox.accountId : undefined; - result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0); + result = withSnippetBatches(await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0, { deferSnippets: true })); } // Plugins may add messages to the results by id; the messages are @@ -3721,6 +3750,7 @@ export const useEmailStore = create((set, get) => ({ searchAbortController: null, ...(unifiedErrors ? { unifiedErrors } : {}) }); + highlightWhenSnippetsArrive('snippets' in result ? result.snippets : undefined); } catch (error) { if (controller.signal.aborted) return; set({ @@ -3799,7 +3829,7 @@ export const useEmailStore = create((set, get) => ({ const built = buildTagViewAccountClients(client); result = await advancedSearchAcrossAccounts( built, buildJMAPFilter(searchQuery, searchFilters, undefined), emailsPerPage, 0, - acrossAccountsSearchOptions(searchMailboxId), + { ...acrossAccountsSearchOptions(searchMailboxId), deferSnippets: true }, ); unifiedErrors = result.errors; @@ -3809,7 +3839,7 @@ export const useEmailStore = create((set, get) => ({ accountId = mailbox?.isShared ? mailbox.accountId : undefined; const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId); - result = await resolveActionClient(client).advancedSearchEmails(filter, accountId, emailsPerPage, 0); + result = withSnippetBatches(await resolveActionClient(client).advancedSearchEmails(filter, accountId, emailsPerPage, 0, { deferSnippets: true })); } if (controller.signal.aborted) return; @@ -3841,6 +3871,7 @@ export const useEmailStore = create((set, get) => ({ searchAbortController: null, ...(unifiedErrors ? { unifiedErrors } : {}) }); + highlightWhenSnippetsArrive('snippets' in result ? result.snippets : undefined); } catch (error) { if (controller.signal.aborted) return; set({