From 1342e2a0f8e34b7a6de74c1c6f28872c4aac3033 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:22:58 +0100 Subject: [PATCH] feat: show where a search matched in each result --- .../__tests__/unified-inbox-snippets.test.ts | 92 +++++++ src/api/email.ts | 42 +++ src/api/types.ts | 7 + src/api/unified-inbox.ts | 46 +++- src/components/email/SearchSnippetText.tsx | 33 +++ src/lib/__tests__/search-snippet.test.ts | 172 ++++++++++++ src/lib/search-snippet.ts | 170 ++++++++++++ src/screens/EmailListScreen.tsx | 10 +- src/screens/UnifiedInboxScreen.tsx | 31 ++- .../__tests__/search-snippet-rows.test.ts | 38 +++ .../__tests__/email-store-all-folders.test.ts | 21 +- .../email-store-search-snippets.test.ts | 260 ++++++++++++++++++ src/stores/__tests__/fake-jmap-server.ts | 57 +++- src/stores/email-store.ts | 60 +++- 14 files changed, 1013 insertions(+), 26 deletions(-) create mode 100644 src/api/__tests__/unified-inbox-snippets.test.ts create mode 100644 src/components/email/SearchSnippetText.tsx create mode 100644 src/lib/__tests__/search-snippet.test.ts create mode 100644 src/lib/search-snippet.ts create mode 100644 src/screens/__tests__/search-snippet-rows.test.ts create mode 100644 src/stores/__tests__/email-store-search-snippets.test.ts diff --git a/src/api/__tests__/unified-inbox-snippets.test.ts b/src/api/__tests__/unified-inbox-snippets.test.ts new file mode 100644 index 00000000..a5acdf2a --- /dev/null +++ b/src/api/__tests__/unified-inbox-snippets.test.ts @@ -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 })); + +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]>) => { + // 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 zephyr review' }); + expect(rows.find((e) => e.jmapAccountId === 'c')?.searchSnippet) + .toEqual({ subject: 'Zephyr 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`]); + }); +}); diff --git a/src/api/email.ts b/src/api/email.ts index c1f975c9..57e31f88 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -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, + groups: Array<{ accountId?: string; emailIds: string[] }>, +): Promise> { + const out: Array = 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>; + 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 " without picking a specific mailbox). export async function queryEmailsByFilter( diff --git a/src/api/types.ts b/src/api/types.ts index 43aad98a..12b42115 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -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 ``, 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 { diff --git a/src/api/unified-inbox.ts b/src/api/unified-inbox.ts index a49475d4..4cdd67ce 100644 --- a/src/api/unified-inbox.ts +++ b/src/api/unified-inbox.ts @@ -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; 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; } 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 }> { 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 = {}; const positions: Record = { ...(opts.positions ?? {}) }; let hasMore = false; + const snippetBatches: Array> = []; 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 ──────────────────────────────── diff --git a/src/components/email/SearchSnippetText.tsx b/src/components/email/SearchSnippetText.tsx new file mode 100644 index 00000000..4eedc117 --- /dev/null +++ b/src/components/email/SearchSnippetText.tsx @@ -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 + ? {segment.text} + : {segment.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)' }, +}); diff --git a/src/lib/__tests__/search-snippet.test.ts b/src/lib/__tests__/search-snippet.test.ts new file mode 100644 index 00000000..ca221272 --- /dev/null +++ b/src/lib/__tests__/search-snippet.test.ts @@ -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: Invoice & receipt <2026>'); + 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(' hitbold'); + expect(segments).toEqual([ + { text: ' ', marked: false }, + { text: 'hit', marked: true }, + { text: 'bold', 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 zephyr\tlaunch \n')).toEqual([ + { text: 'Hello the ', marked: false }, + { text: 'zephyr', marked: true }, + { text: ' launch', marked: false }, + ]); + }); + + it('drops segments left empty', () => { + expect(snippetLineSegments(' hit')).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: 'hit a', preview: null })) + .toEqual({ subject: 'hit a', preview: null }); + expect(snippetHighlight({ emailId: 'b', subject: 's-b', preview: 'nothing marked' })).toBeNull(); + expect(snippetHighlight({ emailId: 'c', subject: null, preview: 'body hit' })) + .toEqual({ subject: null, preview: 'body hit' }); + expect(snippetHighlight({ emailId: 'd', subject: '', 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: `Subject ${emailId}`, preview: null }); + + it('returns new objects only for the emails that gained a snippet', () => { + const emails = [email('e1'), email('e2')]; + const next = withSearchSnippets(emails, [{ list: [marked('e2')] }]); + + expect(next).not.toBe(emails); + expect(next[0]).toBe(emails[0]); + expect(next[1]).not.toBe(emails[1]); + expect(next[1].searchSnippet).toEqual({ subject: 'Subject e2', preview: null }); + // The list already on screen is left untouched. + expect(emails[1].searchSnippet).toBeUndefined(); + }); + + it('returns the same list when nothing is highlighted', () => { + const emails = [email('e1')]; + expect(withSearchSnippets(emails, [])).toBe(emails); + expect(withSearchSnippets(emails, [{ list: [] }])).toBe(emails); + expect(withSearchSnippets(emails, [{ list: [{ emailId: 'e1', subject: 'Subject e1', preview: null }] }])).toBe(emails); + expect(withSearchSnippets(emails, [{ list: [marked('other')] }])).toBe(emails); + }); + + it("keeps each account's snippets to its own rows, since ids repeat across accounts", () => { + const emails = [email('a', 'own'), email('a', 'group')]; + const next = withSearchSnippets(emails, [{ accountId: 'group', list: [marked('a')] }]); + + expect(next[0]).toBe(emails[0]); + expect(next[1].searchSnippet?.subject).toBe('Subject a'); + }); + + 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('Subject a'); + }); +}); diff --git a/src/lib/search-snippet.ts b/src/lib/search-snippet.ts new file mode 100644 index 00000000..e6a7db9b --- /dev/null +++ b/src/lib/search-snippet.ts @@ -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 `` and everything else HTML-escaped + * (`&`, `<`, `>`, `"`). 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 ``ed segments. Anything that is not + * a ``/`` 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] === ''; + 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 | undefined): boolean { + if (!filter) return false; + const conditions = filter.conditions; + if (Array.isArray(conditions)) { + return conditions.some((c) => filterHasSnippetTerms(c as Record)); + } + 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): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(filter)) { + if (key === 'conditions' && Array.isArray(value)) { + out[key] = value.map((c) => snippetFilterFor(c as Record)); + } 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( + 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; +} diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index 49ce4ff9..db4d9c1c 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -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 */} + {/* A search hit shows the server's excerpt with the matched terms marked. */} - {singleLine(item.subject) || tr('email_viewer.no_subject', '(No Subject)')} + {item.searchSnippet?.subject + ? + : singleLine(item.subject) || tr('email_viewer.no_subject', '(No Subject)')} {tags.slice(0, 3).map((tag) => ( @@ -316,7 +320,9 @@ const EmailRow = React.memo(function EmailRow({ {/* Row 3: Preview - hidden in compact density modes regardless of toggle */} {showPreview && density.showPreview && ( - {singleLine(cleanPreview(item.preview))} + {item.searchSnippet?.preview + ? + : singleLine(cleanPreview(item.preview))} )} {verificationCode && } diff --git a/src/screens/UnifiedInboxScreen.tsx b/src/screens/UnifiedInboxScreen.tsx index adf60fec..d6e6a6c7 100644 --- a/src/screens/UnifiedInboxScreen.tsx +++ b/src/screens/UnifiedInboxScreen.tsx @@ -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) { {formatListDate(item.receivedAt, { dateFormat, timeFormat, locale, t })} - {singleLine(item.subject) || t('email_viewer.no_subject', '(No Subject)')} + {item.searchSnippet?.subject + ? + : singleLine(item.subject) || t('email_viewer.no_subject', '(No Subject)')} {acc && accountIds.length > 1 && ( @@ -396,8 +411,12 @@ export default function UnifiedInboxScreen({ navigation, route }: Props) { )} {/* Same settings as the folder list: preview on/off, density, text size. */} - {item.preview && showPreview && density.showPreview ? ( - {singleLine(cleanPreview(item.preview))} + {(item.searchSnippet?.preview || item.preview) && showPreview && density.showPreview ? ( + + {item.searchSnippet?.preview + ? + : singleLine(cleanPreview(item.preview))} + ) : null} diff --git a/src/screens/__tests__/search-snippet-rows.test.ts b/src/screens/__tests__/search-snippet-rows.test.ts new file mode 100644 index 00000000..65ffa58f --- /dev/null +++ b/src/screens/__tests__/search-snippet-rows.test.ts @@ -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(``); + if (at < 0) return null; + const open = source.lastIndexOf('', 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/); + }); +}); diff --git a/src/stores/__tests__/email-store-all-folders.test.ts b/src/stores/__tests__/email-store-all-folders.test.ts index 4a382792..2cda7ace 100644 --- a/src/stores/__tests__/email-store-all-folders.test.ts +++ b/src/stores/__tests__/email-store-all-folders.test.ts @@ -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']); }); diff --git a/src/stores/__tests__/email-store-search-snippets.test.ts b/src/stores/__tests__/email-store-search-snippets.test.ts new file mode 100644 index 00000000..c6a199e3 --- /dev/null +++ b/src/stores/__tests__/email-store-search-snippets.test.ts @@ -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 })); + +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) => { if (onlineRun) await onlineRun(); return { queued: false }; }, + applyOrQueue: async (_op: unknown, onlineRun?: () => Promise) => { 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 = {}; + 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 }).__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 = {}): 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 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: 'Zephyr launch', preview: null }); + expect(row('b')?.searchSnippet).toEqual({ subject: null, preview: 'zephyr 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('zephyr')); + 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 zephyr review' }); + expect(row('a', 'c')?.searchSnippet).toEqual({ subject: 'Zephyr 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('Zephyr launch'); + expect(row('a', 'team')?.searchSnippet).toBeUndefined(); + expect(rows()).toHaveLength(3); + }); +}); diff --git a/src/stores/__tests__/fake-jmap-server.ts b/src/stores/__tests__/fake-jmap-server.ts index 5f212f8d..5f59b526 100644 --- a/src/stores/__tests__/fake-jmap-server.ts +++ b/src/stores/__tests__/fake-jmap-server.ts @@ -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 ), 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()): Set { + 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 ; null +// when nothing matched (as Stalwart answers for a field without a hit). +function markWords(text: string, words: Set): string | null { + let hit = false; + const escaped = text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + const marked = escaped.replace(/[\p{L}\p{N}]+/gu, (w) => { + if (!words.has(w.toLowerCase())) return w; + hit = true; + return `${w}`; + }); + 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(); /** JMAP account id → messages whose Email/set updates it refuses as `forbidden`. */ const refusing = new Map>(); + /** While set, requests carrying a SearchSnippet/get wait for it. */ + let snippetGate: Promise | null = null; + /** Hold every SearchSnippet/get answer until the returned function is called. */ + const holdSnippets = () => { + let release!: () => void; + snippetGate = new Promise((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]> = []; const byCallId = new Map, 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 = {}; const notUpdated: Record = {}; @@ -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 }; } diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index 38a7b237..5f0e5dba 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -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, + filter: Record | 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(); + 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()( 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()( 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()( }; } 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 { 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 { }; } 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;