mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 16:15:39 +00:00
perf: list search results without waiting for the highlighted excerpts
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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' }] };
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user