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({