feat: show where a search matched in each result

This commit is contained in:
Linus Rath
2026-10-31 05:22:58 +01:00
parent 90e4e230af
commit 1342e2a0f8
14 changed files with 1013 additions and 26 deletions
@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createFakeJmap } from '../../stores/__tests__/fake-jmap-server';
// A search in the unified views highlights its hits like the folder list
// (webmail searchAcrossAccounts with deferSnippets, 2d67442a2): the page
// comes back first, each account's SearchSnippet/get follows, and the
// excerpts are keyed by account because ids repeat across them.
const server = vi.hoisted(() => ({ current: null as null | ReturnType<typeof import('../../stores/__tests__/fake-jmap-server').createFakeJmap> }));
vi.mock('../jmap-client', () => ({
jmapClient: {
isConnected: true,
accountId: 'c',
username: 'me',
serverUrl: 'https://mail.example.com',
currentSession: { apiUrl: 'https://mail.example.com/jmap/', accounts: {} },
getSharedMailAccounts: () => [{ id: 'team', name: 'Team' }],
request: vi.fn(async (calls: Array<[string, Record<string, unknown>, string]>) => {
// The fake answers mail calls; folders are answered here.
if (calls[0][0] === 'Mailbox/get') {
const accountId = calls[0][1].accountId as string;
return { methodResponses: [['Mailbox/get', { accountId, list: [{ id: `${accountId}-in`, role: 'inbox', name: 'Inbox' }] }, calls[0][2]]] };
}
return server.current!.request(calls as never);
}),
},
rewriteSessionUrl: (u: string) => u,
extractOrigin: (u: string) => u,
parseRetryAfter: () => 0,
REQUEST_TIMEOUT_MS: 30000,
}));
import { fetchUnifiedInbox, resetUnifiedCache, unifiedSnippetStamp } from '../unified-inbox';
import { withSearchSnippets } from '../../lib/search-snippet';
import type { Email } from '../types';
// The live account's registry id: username@host.
const ACCOUNT = 'me@mail.example.com';
function mail(id: string, folder: string, day: number, subject: string, preview: string): Email {
return {
id, threadId: `t-${id}`, size: 1, hasAttachment: false,
mailboxIds: { [folder]: true }, keywords: {},
receivedAt: `2026-09-${String(day).padStart(2, '0')}T10:00:00Z`,
subject, preview,
};
}
beforeEach(() => {
resetUnifiedCache();
server.current = createFakeJmap({
c: [mail('a', 'c-in', 20, 'Zephyr launch', 'own body'), mail('b', 'c-in', 19, 'Lunch', 'nothing')],
team: [mail('a', 'team-in', 22, 'Team sync', 'the zephyr review')],
});
});
describe('unified search snippets', () => {
it("hands back each account's excerpts for its own rows", async () => {
const result = await fetchUnifiedInbox([ACCOUNT], 25, { includeGroup: true, query: 'zephyr' });
expect(result.emails.map((e) => `${e.jmapAccountId}:${e.id}`)).toEqual(['team:a', 'c:a']);
expect(result.snippets).toBeDefined();
const batches = await result.snippets!;
const calls = server.current!.callsOf('SearchSnippet/get');
expect(calls.map(([, args]) => [args.accountId, args.emailIds])).toEqual(
expect.arrayContaining([['c', ['a']], ['team', ['a']]]),
);
const rows = withSearchSnippets(result.emails, batches, unifiedSnippetStamp);
expect(rows.find((e) => e.jmapAccountId === 'team')?.searchSnippet)
.toEqual({ subject: null, preview: 'the <mark>zephyr</mark> review' });
expect(rows.find((e) => e.jmapAccountId === 'c')?.searchSnippet)
.toEqual({ subject: '<mark>Zephyr</mark> launch', preview: null });
});
it('asks for no excerpts without a search', async () => {
const result = await fetchUnifiedInbox([ACCOUNT], 25, { includeGroup: true });
expect(result.emails).toHaveLength(3);
expect(result.snippets).toBeUndefined();
expect(server.current!.callsOf('SearchSnippet/get')).toHaveLength(0);
});
it('lists the page even when an account cannot excerpt', async () => {
const release = server.current!.holdSnippets();
const result = await fetchUnifiedInbox([ACCOUNT], 25, { includeGroup: true, query: 'zephyr' });
expect(result.emails).toHaveLength(2);
server.current!.failing.set('team', 'forbidden');
release();
const batches = await result.snippets!;
expect(batches.map((b) => b.accountId)).toEqual([`${ACCOUNT}|c`]);
});
});
+42
View File
@@ -12,6 +12,7 @@ import {
import { CAPABILITIES } from './types';
import type { Attachment, Email, EmailAddress, JMAPMethodCall, Mailbox, Thread } from './types';
import { textFilter } from '../lib/search-utils';
import { snippetFilterFor, type SearchSnippetResult } from '../lib/search-snippet';
import { sanitizeDisplayName } from '../lib/rfc5322-mailbox';
import { generateMessageId, stripMessageIdBrackets } from '../lib/email-threading';
import { buildMdnMessage, type MdnOptions } from '../lib/mdn';
@@ -1494,6 +1495,47 @@ export async function searchEmails(
return (requireMethodResult(res, '0', 'Email/query').ids as string[]) ?? [];
}
/**
* The SearchSnippet/get excerpts (RFC 8621 §5) of the rows of a result
* page, per group of rows that live in one JMAP account (undefined = the
* user's own). One call per account in as few requests as the server's
* `maxCallsInRequest` allows. The search filter's terms are loosened first
* (see snippetFilterFor). The highlights only decorate the rows, so this
* never rejects: an account whose call fails, or a server without the
* method, yields `undefined` for that group and the rows keep their plain
* subject and preview (webmail `deferredSnippets`).
*/
export async function getSearchSnippets(
filter: Record<string, unknown>,
groups: Array<{ accountId?: string; emailIds: string[] }>,
): Promise<Array<SearchSnippetResult[] | undefined>> {
const out: Array<SearchSnippetResult[] | undefined> = groups.map(() => undefined);
const snippetFilter = snippetFilterFor(filter);
const calls: Array<{ index: number; call: JMAPMethodCall }> = [];
groups.forEach((group, index) => {
const accountId = group.accountId ?? jmapClient.accountId;
batched(group.emailIds, maxInGet()).forEach((emailIds, part) => {
calls.push({ index, call: ['SearchSnippet/get', { accountId, filter: snippetFilter, emailIds }, `${index}:${part}`] });
});
});
const perRequest = Math.max(1, typeof jmapClient.getMaxCallsInRequest === 'function' ? jmapClient.getMaxCallsInRequest() : 16);
await Promise.all(batched(calls, perRequest).map(async (chunk) => {
let res: Awaited<ReturnType<typeof jmapClient.request>>;
try {
res = await jmapClient.request(chunk.map((c) => c.call));
} catch {
return;
}
for (const { index, call } of chunk) {
try {
const list = (requireMethodResult(res, call[2], 'SearchSnippet/get').list as SearchSnippetResult[]) ?? [];
out[index] = [...(out[index] ?? []), ...list];
} catch { /* this account's rows stay without highlights */ }
}
}));
return out;
}
// Cross-mailbox query with an arbitrary JMAP filter (used by contact activity
// to search "from OR to <addresses>" without picking a specific mailbox).
export async function queryEmailsByFilter(
+7
View File
@@ -99,6 +99,13 @@ export interface Email {
* belong to the folder's account.
*/
jmapAccountId?: string;
/**
* Client-side, never sent by the server: a search hit's SearchSnippet/get
* excerpts (RFC 8621 §5) with the matched terms in `<mark>`, HTML-escaped
* otherwise. Set only when at least one of them marks something; the row
* shows them in place of the subject and preview (src/lib/search-snippet).
*/
searchSnippet?: { subject: string | null; preview: string | null };
}
export interface MailboxRights {
+43 -3
View File
@@ -8,6 +8,9 @@ import { keywordPointer, mailboxPointer } from './patch-pointer';
import { secureFetch } from '../lib/client-cert';
import { freshStoredCredentials } from '../lib/stored-token-refresh';
import { textConditions } from '../lib/search-utils';
import {
filterHasSnippetTerms, snippetFilterFor, type SearchSnippetResult, type SnippetBatch,
} from '../lib/search-snippet';
import { LIST_AUTH_RESULTS_PROPERTY } from '../lib/email-headers';
// Aggregated views across accounts ("All inboxes", "All Sent", All mail /
@@ -66,6 +69,13 @@ export interface UnifiedInboxResult {
/** Per-target paging cursors to pass back as `positions` for the next page. */
positions: Record<string, number>;
hasMore: boolean;
/**
* With a search, the SearchSnippet/get excerpts of this page, already in
* flight: one batch per account, keyed by `unifiedSnippetStamp`, for
* `withSearchSnippets(emails, batches, unifiedSnippetStamp)`. The page is
* listed without waiting for them (webmail 2d67442a2). Never rejects.
*/
snippets?: Promise<SnippetBatch[]>;
}
export interface UnifiedFetchOptions {
@@ -344,13 +354,21 @@ function targetKey(accountId: string, jmapAccountId: string): string {
return `${accountId}|${jmapAccountId}`;
}
/**
* The account a unified row's search snippets are keyed by: ids repeat
* across logins and their shared accounts.
*/
export function unifiedSnippetStamp(email: UnifiedEmail): string {
return targetKey(email.sourceAccountId, email.jmapAccountId);
}
async function fetchTarget(
entry: AccountEntry,
target: { jmapId: string; isShared: boolean; label?: string },
opts: UnifiedFetchOptions,
position: number,
limit: number,
): Promise<{ emails: UnifiedEmail[]; hasMore: boolean }> {
): Promise<{ emails: UnifiedEmail[]; hasMore: boolean; snippets?: Promise<SearchSnippetResult[] | undefined> }> {
const mailboxes = await mailboxesFor(entry, target.jmapId);
const filter = buildFilter(mailboxes, opts);
if (!filter) return { emails: [], hasMore: false };
@@ -391,7 +409,20 @@ async function fetchTarget(
} as UnifiedEmail];
});
const hasMore = total !== undefined ? position + ids.length < total : ids.length === limit;
return { emails, hasMore };
// The highlights of a search, in a request of their own so the page
// doesn't wait for the server to excerpt every hit.
if (emails.length === 0 || !filterHasSnippetTerms(filter)) return { emails, hasMore };
const snippets = entry.transport.post([
['SearchSnippet/get', {
accountId: target.jmapId,
filter: snippetFilterFor(filter),
emailIds: emails.map((e) => e.id),
}, 's'],
]).then((sres) => {
const [name, body] = sres[0] ?? [];
return name === 'SearchSnippet/get' ? ((body?.list as SearchSnippetResult[]) ?? []) : undefined;
}).catch(() => undefined);
return { emails, hasMore, snippets };
}
/**
@@ -410,6 +441,7 @@ export async function fetchUnifiedInbox(
const errors: Record<string, string> = {};
const positions: Record<string, number> = { ...(opts.positions ?? {}) };
let hasMore = false;
const snippetBatches: Array<Promise<SnippetBatch | null>> = [];
const settled = await Promise.all(
accountIds.map(async (accountId) => {
@@ -428,6 +460,9 @@ export async function fetchUnifiedInbox(
const page = await fetchTarget(entry, target, opts, position, perAccountLimit);
positions[key] = position + page.emails.length;
if (page.hasMore) hasMore = true;
if (page.snippets) {
snippetBatches.push(page.snippets.then((list) => (list ? { accountId: key, list } : null)));
}
return page.emails;
} catch (err) {
// A single inaccessible shared account shouldn't sink the whole
@@ -447,7 +482,12 @@ export async function fetchUnifiedInbox(
const emails = settled
.flat()
.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
return { emails, errors, positions, hasMore };
const result: UnifiedInboxResult = { emails, errors, positions, hasMore };
if (snippetBatches.length > 0) {
result.snippets = Promise.all(snippetBatches)
.then((batches) => batches.filter((b): b is SnippetBatch => b !== null));
}
return result;
}
// ── Actions routed to the owning account ────────────────────────────────
@@ -0,0 +1,33 @@
import React from 'react';
import { StyleSheet, Text } from 'react-native';
import { snippetLineSegments } from '../../lib/search-snippet';
import { useResolvedTheme } from '../../theme/colors';
/**
* A SearchSnippet/get excerpt (RFC 8621 §5) with the matched terms
* highlighted, as the spans of the row's own Text: nest it inside a Text
* that sets the font and `numberOfLines`, so the row keeps its height when
* the excerpt replaces the plain subject or preview. The snippet is parsed
* into text and marked segments, never rendered as HTML (webmail
* components/email/search-snippet-text.tsx).
*/
export function SearchSnippetText({ snippet }: { snippet: string }) {
const dark = useResolvedTheme() === 'dark';
const segments = React.useMemo(() => snippetLineSegments(snippet), [snippet]);
const markStyle = dark ? styles.markDark : styles.markLight;
return (
<>
{segments.map((segment, i) => (
segment.marked
? <Text key={i} style={markStyle} testID="search-snippet-mark">{segment.text}</Text>
: <Text key={i}>{segment.text}</Text>
))}
</>
);
}
// The webmail's highlight: bg-yellow-200, dark:bg-yellow-500/40, text-inherit.
const styles = StyleSheet.create({
markLight: { backgroundColor: '#fef08a' },
markDark: { backgroundColor: 'rgba(234, 179, 8, 0.4)' },
});
+172
View File
@@ -0,0 +1,172 @@
import { describe, expect, it } from 'vitest';
import {
filterHasSnippetTerms,
hasMarkedSegment,
parseSearchSnippet,
snippetFilterFor,
snippetHighlight,
snippetLineSegments,
withSearchSnippets,
} from '../search-snippet';
import type { Email } from '../../api/types';
// Ported from the webmail's lib/__tests__/search-snippet.test.ts.
describe('parseSearchSnippet', () => {
it('splits marked and plain segments and decodes the escaped text', () => {
const segments = parseSearchSnippet('Re: <mark>Invoice</mark> &amp; receipt &lt;2026&gt;');
expect(segments).toEqual([
{ text: 'Re: ', marked: false },
{ text: 'Invoice', marked: true },
{ text: ' & receipt <2026>', marked: false },
]);
expect(hasMarkedSegment(segments)).toBe(true);
});
it('treats any other tag as literal text', () => {
const segments = parseSearchSnippet('<script>x</script> <mark>hit</mark><b>bold</b>');
expect(segments).toEqual([
{ text: '<script>x</script> ', marked: false },
{ text: 'hit', marked: true },
{ text: '<b>bold</b>', marked: false },
]);
});
it('reports no highlight for a snippet without marks', () => {
const segments = parseSearchSnippet('plain subject');
expect(segments).toEqual([{ text: 'plain subject', marked: false }]);
expect(hasMarkedSegment(segments)).toBe(false);
});
});
describe('snippetLineSegments', () => {
it('puts a multi-line body excerpt on one line without gluing words together', () => {
expect(snippetLineSegments('\n Hello\r\n\r\nthe <mark>zephyr</mark>\tlaunch \n')).toEqual([
{ text: 'Hello the ', marked: false },
{ text: 'zephyr', marked: true },
{ text: ' launch', marked: false },
]);
});
it('drops segments left empty', () => {
expect(snippetLineSegments(' <mark>hit</mark>')).toEqual([{ text: 'hit', marked: true }]);
});
});
describe('filterHasSnippetTerms', () => {
it('finds text/subject/body terms at any nesting level', () => {
expect(filterHasSnippetTerms({ text: 'foo' })).toBe(true);
expect(filterHasSnippetTerms({ operator: 'AND', conditions: [{ inMailbox: 'x' }, { subject: 'foo' }] })).toBe(true);
expect(
filterHasSnippetTerms({
operator: 'AND',
conditions: [{ inMailbox: 'x' }, { operator: 'NOT', conditions: [{ body: 'foo' }] }],
}),
).toBe(true);
});
it('ignores structural-only filters and empty terms', () => {
expect(filterHasSnippetTerms(undefined)).toBe(false);
expect(filterHasSnippetTerms({ inMailbox: 'x', hasKeyword: '$seen' })).toBe(false);
expect(filterHasSnippetTerms({ minSize: 1024 })).toBe(false);
expect(filterHasSnippetTerms({ from: 'alice' })).toBe(false);
expect(filterHasSnippetTerms({ text: ' ' })).toBe(false);
});
});
describe('snippetFilterFor', () => {
it('strips the prefix-search wildcard from highlightable terms only', () => {
expect(
snippetFilterFor({
operator: 'AND',
conditions: [{ inMailbox: 'x' }, { text: 'inv* rece*' }, { from: 'ali*' }],
}),
).toEqual({
operator: 'AND',
conditions: [{ inMailbox: 'x' }, { text: 'inv rece' }, { from: 'ali*' }],
});
});
it('leaves a lone wildcard alone', () => {
expect(snippetFilterFor({ text: '*' })).toEqual({ text: '*' });
});
it('unquotes phrases: one quoted term puts the whole request in exact mode on Stalwart 0.16 and 1.0', () => {
expect(
snippetFilterFor({
operator: 'AND',
conditions: [
{ text: '"annual report"' },
{ text: '2024' },
{ subject: "'draft'" },
{ from: '"Anna Muster"' },
{ inMailbox: 'x' },
],
}),
).toEqual({
operator: 'AND',
conditions: [
{ text: 'annual report' },
{ text: '2024' },
{ subject: 'draft' },
{ from: '"Anna Muster"' },
{ inMailbox: 'x' },
],
});
expect(snippetFilterFor({ body: '"exact phrase"' })).toEqual({ body: 'exact phrase' });
// Only a value wrapped in quotes is a phrase.
expect(snippetFilterFor({ text: 'say "hi" twice' })).toEqual({ text: 'say "hi" twice' });
});
});
describe('snippetHighlight', () => {
it('keeps only the parts that mark something', () => {
expect(snippetHighlight({ emailId: 'a', subject: '<mark>hit</mark> a', preview: null }))
.toEqual({ subject: '<mark>hit</mark> a', preview: null });
expect(snippetHighlight({ emailId: 'b', subject: 's-b', preview: 'nothing marked' })).toBeNull();
expect(snippetHighlight({ emailId: 'c', subject: null, preview: 'body <mark>hit</mark>' }))
.toEqual({ subject: null, preview: 'body <mark>hit</mark>' });
expect(snippetHighlight({ emailId: 'd', subject: '<mark></mark>', preview: undefined })).toBeNull();
});
});
describe('withSearchSnippets', () => {
const email = (id: string, jmapAccountId?: string) => ({ id, subject: `Subject ${id}`, jmapAccountId }) 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');
});
it('reads the account from the given stamp', () => {
type Row = Email & { login: string };
const emails = [{ ...email('a'), login: 'x' }, { ...email('a'), login: 'y' }] as Row[];
const next = withSearchSnippets(emails, [{ accountId: 'y', list: [marked('a')] }], (e) => e.login);
expect(next[0].searchSnippet).toBeUndefined();
expect(next[1].searchSnippet?.subject).toBe('<mark>Subject</mark> a');
});
});
+170
View File
@@ -0,0 +1,170 @@
/**
* SearchSnippet/get support (RFC 8621 §5), ported from the webmail's
* lib/search-snippet.ts (b408b2597, 5967a1c48, 2d67442a2).
*
* The server returns, per search hit, the subject and a body excerpt with
* the matching terms wrapped in `<mark>` and everything else HTML-escaped
* (`&amp;`, `&lt;`, `&gt;`, `&quot;`). The list shows those instead of the
* plain subject/preview so the user sees why a mail matched. Nothing here
* ever renders HTML: the snippet is split into text/marked segments and the
* text is decoded and drawn as Text spans.
*/
import type { Email } from '../api/types';
import { decodeEntities } from './verification-code';
export interface SnippetSegment {
text: string;
marked: boolean;
}
/** A SearchSnippet object as returned by SearchSnippet/get. */
export interface SearchSnippetResult {
emailId: string;
subject?: string | null;
preview?: string | null;
}
/**
* Splits a snippet into plain and `<mark>`ed segments. Anything that is not
* a `<mark>`/`</mark>` token is treated as (escaped) text, so an unexpected
* tag in the payload is displayed literally rather than interpreted.
*/
export function parseSearchSnippet(snippet: string): SnippetSegment[] {
const segments: SnippetSegment[] = [];
const re = /<\/?mark>/g;
let marked = false;
let last = 0;
let match: RegExpExecArray | null;
while ((match = re.exec(snippet)) !== null) {
if (match.index > last) {
segments.push({ text: decodeEntities(snippet.slice(last, match.index)), marked });
}
marked = match[0] === '<mark>';
last = match.index + match[0].length;
}
if (last < snippet.length) {
segments.push({ text: decodeEntities(snippet.slice(last)), marked });
}
return segments;
}
/**
* The segments of a snippet as one line of a list row: whitespace runs
* (line breaks of the body excerpt) become one space, the ends are trimmed
* and empty segments dropped, like `singleLine` does for the plain preview.
*/
export function snippetLineSegments(snippet: string): SnippetSegment[] {
const segments = parseSearchSnippet(snippet).map((s) => ({ ...s, text: s.text.replace(/\s+/g, ' ') }));
if (segments.length > 0) {
segments[0].text = segments[0].text.trimStart();
segments[segments.length - 1].text = segments[segments.length - 1].text.trimEnd();
}
return segments.filter((s) => s.text.length > 0);
}
/** True when a segment list carries at least one highlighted term. */
export function hasMarkedSegment(segments: SnippetSegment[]): boolean {
return segments.some((s) => s.marked && s.text.length > 0);
}
const TERM_PROPERTIES = new Set(['text', 'subject', 'body']);
/**
* Whether a JMAP Email filter contains a term the server can highlight
* (`text`, `subject` or `body`, RFC 8621 §5.1). Filters made only of
* structural conditions (mailbox, keywords, dates, sizes, addresses) produce
* empty snippets, so the extra method call is skipped for them.
*/
export function filterHasSnippetTerms(filter: Record<string, unknown> | undefined): boolean {
if (!filter) return false;
const conditions = filter.conditions;
if (Array.isArray(conditions)) {
return conditions.some((c) => filterHasSnippetTerms(c as Record<string, unknown>));
}
return Object.entries(filter).some(
([key, value]) => TERM_PROPERTIES.has(key) && typeof value === 'string' && value.trim().length > 0,
);
}
/**
* The filter to hand SearchSnippet/get: the query filter with the
* highlightable terms loosened. As soon as one term is quoted, Stalwart
* (0.16 and 1.0) marks text only where the words of all terms appear in a
* row, so a phrase ANDed with other parts of the query would mark nothing.
* Phrases are therefore unquoted and their words marked wherever they
* occur. A trailing `*` (a typed prefix) is dropped as well, so the
* server's tokenizer sees the word itself. Structural conditions are passed
* through unchanged.
*/
export function snippetFilterFor(filter: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(filter)) {
if (key === 'conditions' && Array.isArray(value)) {
out[key] = value.map((c) => snippetFilterFor(c as Record<string, unknown>));
} else if (TERM_PROPERTIES.has(key) && typeof value === 'string') {
const phrase = /^\s*(["'])([\s\S]*)\1\s*$/.exec(value);
out[key] = phrase
? phrase[2]
: value
.split(/\s+/)
.map((word) => (word.length > 1 && word.endsWith('*') ? word.slice(0, -1) : word))
.join(' ');
} else {
out[key] = value;
}
}
return out;
}
/**
* The snippets of one JMAP account's rows. Ids repeat across accounts, so a
* list spanning accounts keeps each account's snippets to the rows stamped
* with it (`Email.jmapAccountId`); no `accountId` matches every row (the
* rows of a single folder carry no stamp).
*/
export interface SnippetBatch {
accountId?: string;
list: SearchSnippetResult[];
}
/**
* The highlight of one row, or null when the snippet marks nothing (the
* server found nothing to mark, or returned the plain subject): the row
* then keeps its regular subject and preview.
*/
export function snippetHighlight(snippet: SearchSnippetResult): Email['searchSnippet'] | null {
const subject = snippet.subject && hasMarkedSegment(parseSearchSnippet(snippet.subject)) ? snippet.subject : null;
const preview = snippet.preview && hasMarkedSegment(parseSearchSnippet(snippet.preview)) ? snippet.preview : null;
return subject || preview ? { subject, preview } : null;
}
/**
* The snippets attached to a list that is already on screen: a new list
* with new objects for the rows 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<T extends Email>(
emails: T[],
batches: SnippetBatch[],
stampOf: (email: T) => string | undefined = (email) => email.jmapAccountId,
): T[] {
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 && stampOf(email) !== accountId) continue;
const snippet = byId.get(email.id);
if (!snippet) continue;
const highlight = snippetHighlight(snippet);
if (!highlight) return email;
changed = true;
return { ...email, searchSnippet: highlight };
}
return email;
});
return changed ? next : emails;
}
+8 -2
View File
@@ -53,6 +53,7 @@ import { getContactDisplayName } from '../lib/contact-utils';
import { formatListDate } from '../lib/date-format';
import { singleLine } from '../lib/single-line';
import { cleanPreview } from '../lib/preview-text';
import { SearchSnippetText } from '../components/email/SearchSnippetText';
import {
findTrashMailbox, findArchiveMailbox, findJunkMailbox, mailboxesForSiblingOf, ownMailboxes,
} from '../lib/mailbox-tree';
@@ -299,8 +300,11 @@ const EmailRow = React.memo(function EmailRow({
{/* Row 2: Subject + tag pills */}
<View style={styles.subjectRow}>
{/* A search hit shows the server's excerpt with the matched terms marked. */}
<Text style={[styles.emailSubject, dyn.body, unread && styles.textBold]} numberOfLines={1}>
{singleLine(item.subject) || tr('email_viewer.no_subject', '(No Subject)')}
{item.searchSnippet?.subject
? <SearchSnippetText snippet={item.searchSnippet.subject} />
: singleLine(item.subject) || tr('email_viewer.no_subject', '(No Subject)')}
</Text>
{tags.slice(0, 3).map((tag) => (
<View key={tag.id} style={[styles.tagPill, { backgroundColor: tag.bg }]}>
@@ -316,7 +320,9 @@ const EmailRow = React.memo(function EmailRow({
{/* Row 3: Preview - hidden in compact density modes regardless of toggle */}
{showPreview && density.showPreview && (
<Text style={[styles.emailPreview, dyn.body]} numberOfLines={2}>
{singleLine(cleanPreview(item.preview))}
{item.searchSnippet?.preview
? <SearchSnippetText snippet={item.searchSnippet.preview} />
: singleLine(cleanPreview(item.preview))}
</Text>
)}
{verificationCode && <VerificationCodeChip code={verificationCode} disabled={selectionMode} inList />}
+25 -6
View File
@@ -15,8 +15,10 @@ import SenderAvatar from '../components/SenderAvatar';
import { SwipeableRow } from '../components/SwipeableRow';
import {
fetchUnifiedInbox, patchUnifiedKeywords, moveUnifiedEmails, deleteUnifiedEmails, isInUnifiedTrash,
type UnifiedEmail, type UnifiedRole, type CrossView,
unifiedSnippetStamp, type UnifiedEmail, type UnifiedRole, type CrossView, type UnifiedInboxResult,
} from '../api/unified-inbox';
import { withSearchSnippets } from '../lib/search-snippet';
import { SearchSnippetText } from '../components/email/SearchSnippetText';
import { useAccountStore } from '../stores/account-store';
import { useAuthStore } from '../stores/auth-store';
import { useSettingsStore, type SwipeAction } from '../stores/settings-store';
@@ -103,6 +105,15 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
[includeGroup, query, role, view],
);
// A search page's highlights, once they land, while that search is still
// the one on screen (a new load bumps `loadSeq`).
const highlight = React.useCallback((result: UnifiedInboxResult, seq: number) => {
void result.snippets?.then((batches) => {
if (seq !== loadSeq.current) return;
setEmails((prev) => withSearchSnippets(prev, batches, unifiedSnippetStamp));
});
}, []);
const load = React.useCallback(async () => {
const seq = ++loadSeq.current;
const active = useAuthStore.getState().activeAccountId;
@@ -115,10 +126,11 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
hasMoreRef.current = result.hasMore;
setEmails(result.emails);
setErrors(result.errors);
highlight(result, seq);
} finally {
if (seq === loadSeq.current) setLoading(false);
}
}, [accountIds, fetchOpts, reloadTracker]);
}, [accountIds, fetchOpts, reloadTracker, highlight]);
const loadMore = React.useCallback(async () => {
if (loading || loadingMore || !hasMoreRef.current) return;
@@ -138,10 +150,11 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
for (const e of result.emails) if (!seen.has(rowKey(e))) merged.push(e);
return merged.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
});
highlight(result, seq);
} finally {
setLoadingMore(false);
}
}, [accountIds, fetchOpts, loading, loadingMore]);
}, [accountIds, fetchOpts, loading, loadingMore, highlight]);
// Reload whenever the inputs change, and on coming back into focus only
// when something may have changed meanwhile (a message read or deleted in
@@ -373,7 +386,9 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
<Text style={[styles.time, dyn.caption]}>{formatListDate(item.receivedAt, { dateFormat, timeFormat, locale, t })}</Text>
</View>
<Text style={[styles.subject, dyn.body, unread && styles.bold]} numberOfLines={1}>
{singleLine(item.subject) || t('email_viewer.no_subject', '(No Subject)')}
{item.searchSnippet?.subject
? <SearchSnippetText snippet={item.searchSnippet.subject} />
: singleLine(item.subject) || t('email_viewer.no_subject', '(No Subject)')}
</Text>
<View style={styles.line}>
{acc && accountIds.length > 1 && (
@@ -396,8 +411,12 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) {
)}
</View>
{/* Same settings as the folder list: preview on/off, density, text size. */}
{item.preview && showPreview && density.showPreview ? (
<Text style={[styles.preview, dyn.body]} numberOfLines={2}>{singleLine(cleanPreview(item.preview))}</Text>
{(item.searchSnippet?.preview || item.preview) && showPreview && density.showPreview ? (
<Text style={[styles.preview, dyn.body]} numberOfLines={2}>
{item.searchSnippet?.preview
? <SearchSnippetText snippet={item.searchSnippet.preview} />
: singleLine(cleanPreview(item.preview))}
</Text>
) : null}
</View>
</Pressable>
@@ -0,0 +1,38 @@
// The folder list and the unified views show a search hit's excerpts in
// place of its subject and preview (webmail thread-list-item, b408b2597).
// They are drawn as spans of the row's own one- or two-line Text, so the
// row keeps its height when they arrive, and never as HTML. The screens
// aren't rendered in these tests, so their source is checked.
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const SRC = join(__dirname, '..', '..');
const read = (file: string) => readFileSync(join(SRC, file), 'utf8').replace(/\r\n/g, '\n');
// The Text element that renders `expr` and the attributes it opens with.
function textAround(source: string, expr: string): string | null {
const at = source.indexOf(`<SearchSnippetText snippet={${expr}} />`);
if (at < 0) return null;
const open = source.lastIndexOf('<Text ', at);
return source.slice(open, source.indexOf('>', open) + 1);
}
describe.each([
['screens/EmailListScreen.tsx'],
['screens/UnifiedInboxScreen.tsx'],
])('%s', (file) => {
const source = read(file);
it('shows the subject excerpt on the one subject line', () => {
expect(textAround(source, 'item.searchSnippet.subject')).toMatch(/numberOfLines=\{1\}/);
});
it('shows the body excerpt on the two preview lines', () => {
expect(textAround(source, 'item.searchSnippet.preview')).toMatch(/numberOfLines=\{2\}/);
});
it('never renders the excerpt as HTML', () => {
expect(source).not.toMatch(/dangerouslySet|innerHTML/);
});
});
@@ -154,6 +154,9 @@ function seed() {
}
const ids = () => useEmailStore.getState().emails.map((e) => e.id);
// The requests that load the list: a search's highlights follow in a request
// of their own (SearchSnippet/get), which these tests don't count.
const listRequests = () => server.current!.requests.filter((r) => !r.every(([n]) => n === 'SearchSnippet/get'));
// "All folders" picked explicitly, unless a test picked a folder: the default
// scope also leaves Trash and Junk out (see "the default search scope").
@@ -199,7 +202,7 @@ describe('the default search scope', () => {
await searchDefault('zephyr');
expect(ids()).toEqual(['m1', 'o1', 'm2', 'm3', 'o2', 'm4', 'o3']);
expect(server.current!.requests).toHaveLength(1);
expect(listRequests()).toHaveLength(1);
const queries = server.current!.callsOf('Email/query');
expect(queries.map(([, args]) => [args.accountId, args.filter])).toEqual([
['c', { operator: 'AND', conditions: [{ text: 'zephyr' }, { inMailboxOtherThan: ['trash', 'junk'] }] }],
@@ -229,7 +232,7 @@ describe('"All folders" search across the own and the team account (#1082)', ()
expect(ids()).toEqual(['m1', 'o1', 'm2', 'm3', 'o2', 'm4', 'o3', 'm5']);
expect(useEmailStore.getState().totalEmails).toBe(8);
expect(server.current!.requests).toHaveLength(1);
expect(listRequests()).toHaveLength(1);
const queries = server.current!.callsOf('Email/query');
expect(queries.map(([, args]) => args.accountId)).toEqual(['c', 'team']);
for (const [, args] of queries) expect(args.filter).toEqual({ text: 'zephyr' });
@@ -272,18 +275,18 @@ describe('"All folders" search across the own and the team account (#1082)', ()
expect(ids()).toEqual(['m1', 'o1', 'm2', 'o2']);
await useEmailStore.getState().loadMoreEmails();
const page2 = server.current!.requests[1].filter(([n]) => n === 'Email/query');
const page2 = listRequests()[1].filter(([n]) => n === 'Email/query');
expect(page2.map(([, a]) => [a.accountId, a.position])).toEqual([['c', 2], ['team', 2]]);
expect(ids()).toEqual(['m1', 'o1', 'm2', 'm3', 'o2', 'm4', 'o3']);
await useEmailStore.getState().loadMoreEmails();
const page3 = server.current!.requests[2].filter(([n]) => n === 'Email/query');
const page3 = listRequests()[2].filter(([n]) => n === 'Email/query');
expect(page3.map(([, a]) => [a.accountId, a.position])).toEqual([['c', 3], ['team', 4]]);
expect(ids()).toEqual(['m1', 'o1', 'm2', 'm3', 'o2', 'm4', 'o3', 'm5']);
// Everything is loaded: no further request.
await useEmailStore.getState().loadMoreEmails();
expect(server.current!.requests).toHaveLength(3);
expect(listRequests()).toHaveLength(3);
});
it('shows the reachable account\'s hits when the other one fails', async () => {
@@ -647,7 +650,7 @@ describe('tag view across the own and the team account (#1038)', () => {
await openTag('team:t-inbox');
expect(ids()).toEqual(['m1', 'o1', 'm2', 'o2']);
expect(server.current!.requests).toHaveLength(1);
expect(listRequests()).toHaveLength(1);
const queries = server.current!.callsOf('Email/query');
// Each account leaves its own Trash and Junk out (webmail #1156).
expect(queries.map(([, a]) => [a.accountId, a.filter])).toEqual([
@@ -670,7 +673,7 @@ describe('tag view across the own and the team account (#1038)', () => {
// one prefix containing a space and found nothing (P1-7).
await vi.waitFor(() => expect(ids()).toEqual(['m2', 'o2']));
const last = server.current!.requests.at(-1)!.filter(([n]) => n === 'Email/query');
const last = listRequests().at(-1)!.filter(([n]) => n === 'Email/query');
expect(last.map(([, a]) => a.filter)).toEqual([
{ operator: 'AND', conditions: [{ text: 'red 2' }, { hasKeyword: RED }, { inMailboxOtherThan: ['trash', 'junk'] }] },
{ operator: 'AND', conditions: [{ text: 'red 2' }, { hasKeyword: RED }, { inMailboxOtherThan: ['t-trash', 't-junk'] }] },
@@ -689,7 +692,7 @@ describe('tag view across the own and the team account (#1038)', () => {
await openTag('inbox');
useEmailStore.getState().setSearchQuery('"red 2" notes');
await vi.waitFor(() => {
const last = server.current!.requests.at(-1)!.filter(([n]) => n === 'Email/query');
const last = listRequests().at(-1)!.filter(([n]) => n === 'Email/query');
expect(last[0][1].filter).toEqual({
operator: 'AND',
conditions: [{ text: '"red 2"' }, { text: 'notes' }, { hasKeyword: RED }, { inMailboxOtherThan: ['trash', 'junk'] }],
@@ -719,7 +722,7 @@ describe('tag view across the own and the team account (#1038)', () => {
await useEmailStore.getState().loadMoreEmails();
const page2 = server.current!.requests[1].filter(([n]) => n === 'Email/query');
const page2 = listRequests()[1].filter(([n]) => n === 'Email/query');
expect(page2.map(([, a]) => [a.accountId, a.position])).toEqual([['c', 1], ['team', 1]]);
expect(ids()).toEqual(['m1', 'o1', 'm2', 'o2']);
});
@@ -0,0 +1,260 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createFakeJmap, type FakeEmail } from './fake-jmap-server';
// Search hits show the server's SearchSnippet/get excerpts with the matched
// terms marked (webmail b408b2597, 2d67442a2). The page is listed first and
// the excerpts follow in one request per page, one call per account; they
// land only while the same search is still on screen, and in a list that
// spans accounts each account's excerpts reach its own rows only.
const server = vi.hoisted(() => ({ current: null as null | ReturnType<typeof import('./fake-jmap-server').createFakeJmap> }));
vi.mock('../../api/jmap-client', () => ({
jmapClient: {
isConnected: true,
accountId: 'c',
connectedAccountId: 'c',
username: 'me@example.com',
serverUrl: 'https://mail.example.com',
currentSession: {
apiUrl: 'https://mail.example.com/jmap/',
accounts: {
c: { accountCapabilities: { 'urn:ietf:params:jmap:mail': { emailQuerySortOptions: ['receivedAt'] } } },
team: { accountCapabilities: { 'urn:ietf:params:jmap:mail': { emailQuerySortOptions: ['receivedAt'] } } },
},
},
getMaxObjectsInGet: () => 500,
getMaxObjectsInSet: () => 500,
getMaxCallsInRequest: () => 16,
getSharedMailAccounts: () => [{ id: 'team', name: 'Team' }],
request: vi.fn((calls) => server.current!.request(calls)),
},
}));
vi.mock('../locale-store', () => ({
t: (_key: string, fallback?: string) => fallback ?? _key,
useLocaleStore: { getState: () => ({ locale: 'en', t: (_k: string, f?: string) => f ?? _k }) },
}));
vi.mock('../outbox-store', () => ({
applyOrQueueBatch: async (_ops: unknown[], onlineRun?: () => Promise<void>) => { if (onlineRun) await onlineRun(); return { queued: false }; },
applyOrQueue: async (_op: unknown, onlineRun?: () => Promise<void>) => { if (onlineRun) await onlineRun(); return { queued: false }; },
useOutboxStore: { getState: () => ({ setAccount: vi.fn(async () => undefined), flush: vi.fn(async () => undefined) }) },
}));
vi.mock('../settings-store', () => {
const settings: Record<string, unknown> = {};
return {
useSettingsStore: {
getState: () => ({
...settings,
updateSetting: (key: string, value: unknown) => { settings[key] = value; },
}),
},
__settings: settings,
};
});
vi.mock('../offline-cache-store', () => ({
useOfflineCacheStore: {
getState: () => ({
hydrated: true,
hydrate: vi.fn(),
setAccount: vi.fn(async () => undefined),
totalCount: () => 0,
getEmailsInMailbox: vi.fn(async () => []),
has: () => false,
get: vi.fn(async () => null),
put: vi.fn(async () => undefined),
patch: vi.fn(async () => undefined),
remove: vi.fn(async () => undefined),
}),
},
}));
import { generateAccountId } from '../../lib/account-utils';
import * as settingsModule from '../settings-store';
import { useEmailStore } from '../email-store';
import type { Email, Mailbox } from '../../api/types';
const settings = (settingsModule as unknown as { __settings: Record<string, unknown> }).__settings;
function mb(id: string, role: string | null, shared?: { raw: string }): Mailbox {
return {
id,
name: role ?? id,
role,
totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0,
myRights: {} as Mailbox['myRights'],
...(shared
? { originalId: shared.raw, accountId: 'team', accountName: 'Team', isShared: true }
: { accountId: 'c', isShared: false }),
};
}
const MAILBOXES: Mailbox[] = [
mb('inbox', 'inbox'), mb('archive', 'archive'), mb('trash', 'trash'),
mb('team:t-inbox', 'inbox', { raw: 't-inbox' }),
];
function mail(id: string, folder: string, day: number, subject: string, extra: Partial<FakeEmail> = {}): Email {
return {
id,
size: 1,
hasAttachment: false,
threadId: `t-${id}`,
mailboxIds: { [folder]: true },
keywords: {},
receivedAt: `2026-09-${String(day).padStart(2, '0')}T10:00:00Z`,
subject,
...extra,
};
}
function seed() {
return createFakeJmap({
c: [
mail('a', 'inbox', 20, 'Zephyr launch', { preview: 'The <launch> is on & off' }),
mail('b', 'inbox', 19, 'Lunch', { preview: 'zephyr is mentioned in the body only' }),
mail('c1', 'archive', 14, 'Quarterly numbers', { preview: 'nothing to see' }),
],
team: [
// Same id as the own account's "a": ids only are unique per account.
mail('a', 't-inbox', 22, 'Team sync', { preview: 'agenda for the zephyr review' }),
],
});
}
const rows = () => useEmailStore.getState().emails;
const row = (id: string, stamp?: string) => rows().find((e) => e.id === id && (stamp === undefined || e.jmapAccountId === stamp));
const snippetCalls = () => server.current!.callsOf('SearchSnippet/get');
async function settle() {
await vi.waitFor(() => expect(useEmailStore.getState().loading).toBe(false));
}
beforeEach(() => {
server.current = seed();
for (const key of Object.keys(settings)) delete settings[key];
Object.assign(settings, {
emailsPerPage: 25,
mailSortAscending: false,
disableThreading: true,
deleteAction: 'trash',
permanentlyDeleteJunk: false,
archiveMode: 'single',
messageListOrder: [],
messageListOrderScope: 'inbox',
});
useEmailStore.getState().reset();
useEmailStore.setState({
activeAccountId: generateAccountId('me@example.com', 'https://mail.example.com'),
mailboxes: MAILBOXES,
currentMailboxId: 'inbox',
pendingUndo: null,
});
});
describe('search snippets in a folder search', () => {
it('marks the matched terms of each hit, asked once for the whole page', async () => {
useEmailStore.setState({ filters: { folder: 'current' } });
useEmailStore.getState().setSearchQuery('zephyr');
await settle();
expect(rows().map((e) => e.id)).toEqual(['a', 'b']);
await vi.waitFor(() => expect(row('b')?.searchSnippet).toBeDefined());
expect(row('a')?.searchSnippet).toEqual({ subject: '<mark>Zephyr</mark> launch', preview: null });
expect(row('b')?.searchSnippet).toEqual({ subject: null, preview: '<mark>zephyr</mark> is mentioned in the body only' });
// One call for the page, sent on its own after the page was listed.
expect(snippetCalls()).toHaveLength(1);
const [, args] = snippetCalls()[0];
expect(args).toEqual({ accountId: 'c', filter: { text: 'zephyr' }, emailIds: ['a', 'b'] });
const snippetRequest = server.current!.requests.find((r) => r.some(([n]) => n === 'SearchSnippet/get'))!;
expect(snippetRequest.map(([n]) => n)).toEqual(['SearchSnippet/get']);
});
it('loosens phrases and prefixes so the server marks their words', async () => {
useEmailStore.setState({ filters: { folder: 'current' } });
useEmailStore.getState().setSearchQuery('"zephyr launch" laun*');
await settle();
await vi.waitFor(() => expect(snippetCalls()).toHaveLength(1));
expect(snippetCalls()[0][1].filter).toEqual({
operator: 'AND', conditions: [{ text: 'zephyr launch' }, { text: 'laun' }],
});
});
it('asks nothing for a filter without words to mark', async () => {
useEmailStore.getState().setFilters({ isUnread: true, folder: 'current' });
await settle();
expect(rows().length).toBeGreaterThan(0);
await new Promise((r) => setTimeout(r, 20));
expect(snippetCalls()).toHaveLength(0);
});
it('drops the excerpts of a search the user has since changed', async () => {
const release = server.current!.holdSnippets();
useEmailStore.setState({ filters: { folder: 'current' } });
useEmailStore.getState().setSearchQuery('zephyr');
await settle();
await vi.waitFor(() => expect(snippetCalls()).toHaveLength(1));
// The unread filter lists the same messages; they must not show the
// search's excerpts once it is gone.
useEmailStore.setState({ searchQuery: '', filters: { folder: 'current', isUnread: true } });
await useEmailStore.getState().refreshEmails();
expect(rows().map((e) => e.id)).toEqual(['a', 'b']);
release();
await new Promise((r) => setTimeout(r, 20));
expect(snippetCalls()).toHaveLength(1);
expect(row('a')?.searchSnippet).toBeUndefined();
expect(row('b')?.searchSnippet).toBeUndefined();
});
it('highlights the rows load-more brings', async () => {
settings.emailsPerPage = 1;
useEmailStore.setState({ filters: { folder: 'current' } });
useEmailStore.getState().setSearchQuery('zephyr');
await settle();
expect(rows().map((e) => e.id)).toEqual(['a']);
await useEmailStore.getState().loadMoreEmails();
expect(rows().map((e) => e.id)).toEqual(['a', 'b']);
await vi.waitFor(() => expect(row('b')?.searchSnippet?.preview).toContain('<mark>zephyr</mark>'));
expect(snippetCalls().map(([, args]) => args.emailIds)).toEqual([['a'], ['b']]);
});
});
describe('search snippets across accounts', () => {
it("asks every account in one request and keeps each account's excerpts to its own rows", async () => {
useEmailStore.setState({ filters: { folder: 'all' } });
useEmailStore.getState().setSearchQuery('zephyr');
await settle();
expect(rows().map((e) => `${e.jmapAccountId}:${e.id}`)).toEqual(['team:a', 'c:a', 'c:b']);
await vi.waitFor(() => expect(row('a', 'team')?.searchSnippet).toBeDefined());
await vi.waitFor(() => expect(row('b', 'c')?.searchSnippet).toBeDefined());
expect(row('a', 'team')?.searchSnippet).toEqual({ subject: null, preview: 'agenda for the <mark>zephyr</mark> review' });
expect(row('a', 'c')?.searchSnippet).toEqual({ subject: '<mark>Zephyr</mark> launch', preview: null });
const snippetRequests = server.current!.requests.filter((r) => r.some(([n]) => n === 'SearchSnippet/get'));
expect(snippetRequests).toHaveLength(1);
expect(snippetRequests[0].map(([, args]) => [args.accountId, args.emailIds])).toEqual([
['team', ['a']],
['c', ['a', 'b']],
]);
});
it('keeps the results when one account cannot excerpt', async () => {
const release = server.current!.holdSnippets();
useEmailStore.setState({ filters: { folder: 'all' } });
useEmailStore.getState().setSearchQuery('zephyr');
await settle();
await vi.waitFor(() => expect(snippetCalls()).toHaveLength(2));
// The team account goes away between the query and the excerpts.
server.current!.failing.set('team', 'forbidden');
release();
await vi.waitFor(() => expect(row('b', 'c')?.searchSnippet).toBeDefined());
expect(row('a', 'c')?.searchSnippet?.subject).toBe('<mark>Zephyr</mark> launch');
expect(row('a', 'team')?.searchSnippet).toBeUndefined();
expect(rows()).toHaveLength(3);
});
});
+53 -4
View File
@@ -1,7 +1,9 @@
// A small in-memory JMAP server for store tests that assert the wire: it
// answers Email/query (text / inMailbox / hasKeyword / notKeyword filters,
// newest first, position + limit), Email/get and Thread/get (including
// result references), and applies Email/set updates and destroys. Every
// answers Email/query (text / inMailbox / hasKeyword / notKeyword / minSize /
// maxSize filters, newest first, position + limit), Email/get, Thread/get
// (including result references) and SearchSnippet/get (every word of the
// filter's text/subject/body terms wrapped in <mark>), and applies Email/set
// updates and destroys. Every
// request is recorded so a test can check which method calls went out, and
// in how many requests. Text conditions are matched the way Stalwart's
// built-in index does (see lib/__tests__/stalwart-text-model), 1.0 unless
@@ -23,10 +25,40 @@ function matches(email: FakeEmail, filter: Filter | undefined, textModel: Stalwa
if (key === 'inMailboxOtherThan') return !(value as string[]).some((id) => email.mailboxIds[id]);
if (key === 'hasKeyword') return !!email.keywords[value as string];
if (key === 'notKeyword') return !email.keywords[value as string];
if (key === 'minSize') return (email.size ?? 0) >= (value as number);
if (key === 'maxSize') return (email.size ?? 0) < (value as number);
throw new Error(`fake server: unsupported filter ${key}`);
});
}
// The words of a filter's highlightable terms, lower-cased.
function snippetWords(filter: Filter | undefined, out = new Set<string>()): Set<string> {
if (!filter) return out;
if (Array.isArray(filter.conditions)) {
for (const c of filter.conditions as Filter[]) snippetWords(c, out);
return out;
}
for (const key of ['text', 'subject', 'body']) {
const value = filter[key];
if (typeof value !== 'string') continue;
for (const w of value.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []) out.add(w);
}
return out;
}
// `text` HTML-escaped, with every word in `words` wrapped in <mark>; null
// when nothing matched (as Stalwart answers for a field without a hit).
function markWords(text: string, words: Set<string>): string | null {
let hit = false;
const escaped = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const marked = escaped.replace(/[\p{L}\p{N}]+/gu, (w) => {
if (!words.has(w.toLowerCase())) return w;
hit = true;
return `<mark>${w}</mark>`;
});
return hit ? marked : null;
}
function unescapePointer(segment: string): string {
return segment.replace(/~1/g, '/').replace(/~0/g, '~');
}
@@ -41,9 +73,18 @@ export function createFakeJmap(
const failing = new Map<string, string>();
/** JMAP account id → messages whose Email/set updates it refuses as `forbidden`. */
const refusing = new Map<string, Set<string>>();
/** While set, requests carrying a SearchSnippet/get wait for it. */
let snippetGate: Promise<void> | null = null;
/** Hold every SearchSnippet/get answer until the returned function is called. */
const holdSnippets = () => {
let release!: () => void;
snippetGate = new Promise<void>((resolve) => { release = resolve; });
return () => { snippetGate = null; release(); };
};
const request = async (calls: JMAPMethodCall[]) => {
requests.push(calls);
if (snippetGate && calls.some(([name]) => name === 'SearchSnippet/get')) await snippetGate;
const responses: Array<[string, Record<string, any>, string]> = [];
const byCallId = new Map<string, [string, Record<string, any>, string]>();
for (const [name, args, callId] of calls) {
@@ -91,6 +132,14 @@ export function createFakeJmap(
emailIds: store.filter((e) => e.threadId === id).map((e) => e.id),
}));
response = ['Thread/get', { accountId, list, state: 't', notFound: [] }, callId];
} else if (name === 'SearchSnippet/get') {
const words = snippetWords(args.filter as Filter);
const list = ((args.emailIds ?? []) as string[]).flatMap((id) => {
const email = store.find((e) => e.id === id);
if (!email) return [];
return [{ emailId: id, subject: markWords(email.subject ?? '', words), preview: markWords(email.preview ?? '', words) }];
});
response = ['SearchSnippet/get', { accountId, list, notFound: [] }, callId];
} else if (name === 'Email/set') {
const updated: Record<string, null> = {};
const notUpdated: Record<string, { type: string }> = {};
@@ -137,5 +186,5 @@ export function createFakeJmap(
/** The calls of `name` sent so far. */
const callsOf = (name: string) => calls().filter(([n]) => n === name);
return { request, requests, calls, callsOf, failing, refusing, accounts };
return { request, requests, calls, callsOf, failing, refusing, accounts, holdSnippets };
}
+58 -2
View File
@@ -19,6 +19,7 @@ import {
getThreads,
getFullEmail,
importEmailBlob,
getSearchSnippets,
patchKeywordsForEmails,
patchKeywordsPerEmail,
moveEmail,
@@ -43,6 +44,7 @@ import {
mailboxesForSiblingOf, mailboxesOfAccount, findJunkMailbox, findArchiveMailbox, findTrashMailbox, ownMailboxes,
} from '../lib/mailbox-tree';
import { textConditions } from '../lib/search-utils';
import { filterHasSnippetTerms, withSearchSnippets, type SnippetBatch } from '../lib/search-snippet';
import { collapseThreads, rowKeyOf } from '../lib/thread-utils';
import { compareEmails, levelKeyword, orderForMailbox, sanitizeSortLevels, type SortLevel } from '../lib/message-list-order';
import { buildListSort, markKeywordSortUnsupported } from '../lib/keyword-sort-polarity';
@@ -726,6 +728,56 @@ function listComparator(state: EmailState): (a: Email, b: Email) => number {
return compareEmails(levels, { pinnedFirst: true });
}
/**
* Highlights the rows of a search page once their SearchSnippet/get lands
* (webmail highlightWhenSnippetsArrive, 2d67442a2). The page is listed
* first and the excerpts follow in a request of their own, one call per
* account of the page: chained onto the query they held the whole page back
* while the server excerpted every hit. They are applied only while the same
* search is still on screen in the same account; matching ids would not do,
* since a folder opened meanwhile can hold the same messages and must not
* show search excerpts. Rows of a list spanning accounts are stamped with
* their account and get that account's snippets only; the rows of one folder
* are asked in `accountId` (undefined = the user's own).
*/
function highlightSearchHits(
view: Pick<EmailState, 'searchQuery' | 'filters'>,
filter: Record<string, unknown> | undefined,
rows: Email[],
accountId: string | undefined,
): void {
if (!filter || rows.length === 0 || !filterHasSnippetTerms(filter)) return;
const { activeAccountId, currentMailboxId, searchQuery, filters } = useEmailStore.getState();
// The page was asked for another search than the one now on screen.
if (searchQuery !== view.searchQuery || filters !== view.filters) return;
const groups = new Map<string, { accountId?: string; stamp?: string; emailIds: string[] }>();
for (const e of rows) {
const key = e.jmapAccountId ?? '';
let group = groups.get(key);
if (!group) {
group = { accountId: e.jmapAccountId ?? accountId, stamp: e.jmapAccountId, emailIds: [] };
groups.set(key, group);
}
group.emailIds.push(e.id);
}
const list = [...groups.values()];
void getSearchSnippets(filter, list).then((results) => {
const state = useEmailStore.getState();
if (
state.activeAccountId !== activeAccountId || state.currentMailboxId !== currentMailboxId ||
state.searchQuery !== searchQuery || state.filters !== filters ||
!jmapClientServesActiveAccount(activeAccountId)
) return;
const batches: SnippetBatch[] = [];
list.forEach((group, i) => {
const found = results[i];
if (found) batches.push({ accountId: group.stamp, list: found });
});
const emails = withSearchSnippets(state.emails, batches);
if (emails !== state.emails) useEmailStore.setState({ emails });
}).catch(() => { /* the rows keep their plain subject and preview */ });
}
/**
* The total that drives load-more. RFC 8620 makes Email/query's `total`
* optional and some servers leave it out (#1364); the API layer reads a
@@ -1256,9 +1308,9 @@ export const useEmailStore = create<EmailState>()(
now.searchQuery !== searchQuery || now.filters !== filters
) return;
const shown = new Set(now.emails.map(rowKeyOf));
const added = page.list.filter((e) => !shown.has(rowKeyOf(e)));
set({
emails: [...now.emails, ...page.list.filter((e) => !shown.has(rowKeyOf(e)))]
.sort(listComparator(now)),
emails: [...now.emails, ...added].sort(listComparator(now)),
// An account that failed this time keeps counting what it showed,
// so load-more doesn't keep asking for it.
totalEmails: page.total + Object.keys(page.errors).reduce((n, id) => n + (loaded[id] ?? 0), 0),
@@ -1266,6 +1318,7 @@ export const useEmailStore = create<EmailState>()(
accountErrors: page.errors,
loading: false,
});
highlightSearchHits({ searchQuery, filters }, filter, added, undefined);
return;
}
const { list, total: serverTotal, ids, threads } = await queryEmailPage(scope.mailboxId, {
@@ -1303,6 +1356,7 @@ export const useEmailStore = create<EmailState>()(
};
}
set(updates);
highlightSearchHits({ searchQuery, filters }, filter, newEmails, scope.accountId);
} catch (err) {
if (get().activeAccountId !== activeAccountId) return;
set({ loading: false, error: err instanceof Error ? err.message : 'Failed to load more' });
@@ -3153,6 +3207,7 @@ async function refreshEmailsImpl(): Promise<void> {
accountErrors: page.errors,
loading: false,
});
highlightSearchHits({ searchQuery, filters }, filter, page.list, undefined);
return;
}
const threads = !useSettingsStore.getState().disableThreading;
@@ -3197,6 +3252,7 @@ async function refreshEmailsImpl(): Promise<void> {
};
}
set(updates);
if (!baseView) highlightSearchHits({ searchQuery, filters }, filter, queryRes.list, scope.accountId);
} catch (err) {
console.warn('[email-store] refreshEmails failed:', err);
if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return;