perf: list search results without waiting for the highlighted excerpts

This commit is contained in:
Linus Rath
2026-10-14 23:51:17 +02:00
parent e7bf3c6e42
commit 2d67442a2b
12 changed files with 354 additions and 33 deletions
@@ -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: '<mark>Invoice</mark>', 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: '<mark>Invoice</mark>', 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', () => {
+34
View File
@@ -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: `<mark>Subject</mark> ${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: '<mark>Subject</mark> 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('<mark>Subject</mark> a');
});
});
@@ -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: '<mark>own</mark>' }]) }));
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: '<mark>own</mark>' }] }]);
});
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' }] };
+22 -2
View File
@@ -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<SearchSnippetResult[] | undefined>;
}
export interface IJMAPClient {
// ── Connection lifecycle ──────────────────────────────────────
connect(): Promise<void>;
@@ -224,13 +243,14 @@ export interface IJMAPClient {
}): Promise<{ keywords: Record<string, number>; 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<string, unknown> | null }>, accountId?: string): Promise<Record<string, number>>;
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<SearchEmailsResult>;
advancedSearchEmails(
filter: Record<string, unknown>,
accountId?: string,
limit?: number,
position?: number,
): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
options?: SearchRequestOptions,
): Promise<SearchEmailsResult>;
/**
* Lean recipient search for compose autocomplete ("search the server" action):
* finds messages in `sentMailboxId` whose to/cc matches `query` and returns
+38 -8
View File
@@ -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<SearchEmailsResult> {
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<string, unknown> | undefined,
emails: Email[],
): Pick<SearchEmailsResult, 'snippets'> {
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<string, unknown>,
accountId?: string,
limit: number = 50,
position: number = 0
): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
position: number = 0,
options: SearchRequestOptions = {},
): Promise<SearchEmailsResult> {
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;
+37
View File
@@ -109,6 +109,43 @@ export function snippetFilterFor(filter: Record<string, unknown>): Record<string
return out;
}
/**
* Snippets fetched after their results were listed (see
* SearchRequestOptions.deferSnippets), with the account they were asked in.
* Ids repeat across accounts, so a search fanned out over several keeps each
* account's snippets to its own rows; no `accountId` matches every row.
*/
export interface DeferredSnippetBatch {
accountId?: string;
list: SearchSnippetResult[];
}
/**
* attachSearchSnippets for a list that is already on screen: returns a new
* list with new objects for the emails that gained a snippet, and the same
* list when none did, so a store update re-renders only the rows that changed.
*/
export function withSearchSnippets(emails: Email[], batches: DeferredSnippetBatch[]): Email[] {
const lookups = batches
.filter((batch) => 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
+23 -6
View File
@@ -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<string, string>; // accountId -> error message
/** Deferred search snippets per account (see SearchRequestOptions.deferSnippets). */
snippets?: Promise<DeferredSnippetBatch[]>;
}
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<SearchEmailsResult>,
sort: (a: Email, b: Email) => number,
): Promise<UnifiedFetchResult> {
const errors = new Map<string, string>();
type AccountResult = {
account: UnifiedAccountClient;
result: { emails: Email[]; total: number; hasMore: boolean };
result: SearchEmailsResult;
} | null;
const promises = accounts.map(async (account): Promise<AccountResult> => {
@@ -695,10 +705,14 @@ async function fanOutAccountQuery(
let mergedEmails: Email[] = [];
let totalSum = 0;
let anyHasMore = false;
const snippetBatches: Promise<DeferredSnippetBatch | null>[] = [];
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 } : {}) };
}
/**
@@ -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');
});
@@ -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', () => {
@@ -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 () => {
@@ -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<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => { resolve = r; });
return { promise, resolve };
}
describe('interactive search with deferred snippets', () => {
let client: IJMAPClient;
let snippets: ReturnType<typeof deferred<SearchSnippetResult[] | undefined>>;
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: '<mark>Subject</mark> e2', preview: null }]);
await vi.waitFor(() => expect(useEmailStore.getState().emails[1].searchSnippet).toBeDefined());
const highlighted = useEmailStore.getState().emails;
expect(highlighted[1].searchSnippet).toEqual({ subject: '<mark>Subject</mark> 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: '<mark>Subject</mark> e1', preview: null }]);
await snippets.promise;
await new Promise((r) => setTimeout(r, 0));
expect(useEmailStore.getState().emails.every((e) => e.searchSnippet === undefined)).toBe(true);
});
});
+36 -5
View File
@@ -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<SearchEmailsResult, 'snippets'> & { snippets?: Promise<DeferredSnippetBatch[]> } {
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<DeferredSnippetBatch[]> | 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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((set, get) => ({
searchAbortController: null,
...(unifiedErrors ? { unifiedErrors } : {})
});
highlightWhenSnippetsArrive('snippets' in result ? result.snippets : undefined);
} catch (error) {
if (controller.signal.aborted) return;
set({