From b408b2597a172afa796d797741eddd0524cef10b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:47:23 +0200 Subject: [PATCH] feat: highlight search hits with SearchSnippet/get --- components/email/search-snippet-text.tsx | 32 ++++++ components/email/thread-list-item.tsx | 34 ++++-- lib/__tests__/search-snippet.test.ts | 98 +++++++++++++++++ lib/jmap/client.ts | 33 ++++++ lib/jmap/types.ts | 4 + lib/search-snippet.ts | 130 +++++++++++++++++++++++ 6 files changed, 321 insertions(+), 10 deletions(-) create mode 100644 components/email/search-snippet-text.tsx create mode 100644 lib/__tests__/search-snippet.test.ts create mode 100644 lib/search-snippet.ts diff --git a/components/email/search-snippet-text.tsx b/components/email/search-snippet-text.tsx new file mode 100644 index 000000000..1d3c6f97d --- /dev/null +++ b/components/email/search-snippet-text.tsx @@ -0,0 +1,32 @@ +import React, { useMemo } from 'react'; +import { parseSearchSnippet } from '@/lib/search-snippet'; + +interface SearchSnippetTextProps { + /** Server snippet with `` around the matched terms (RFC 8621 §5). */ + snippet: string; +} + +/** + * Renders a SearchSnippet/get excerpt with the matched terms highlighted. + * The snippet is parsed into segments and rendered as text nodes, never as + * HTML. + */ +export function SearchSnippetText({ snippet }: SearchSnippetTextProps) { + const segments = useMemo(() => parseSearchSnippet(snippet), [snippet]); + return ( + <> + {segments.map((segment, i) => + segment.marked ? ( + + {segment.text} + + ) : ( + {segment.text} + ), + )} + + ); +} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index a9005d12d..0272fd13c 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -21,6 +21,7 @@ import { useSwipeActions } from "@/hooks/use-swipe-actions"; import type { SwipeAction } from "@/stores/settings-store"; import { ThreadEmailItem } from "./thread-email-item"; import { EmailHoverActions } from "./email-hover-actions"; +import { SearchSnippetText } from "./search-snippet-text"; import { useTranslations } from "next-intl"; /** @@ -145,6 +146,10 @@ const SingleEmailItem = React.forwardRef( const isFocusedMailLayout = mailLayout === 'focus' && !isMobile; const trimmedPreview = stripInvisibleLeading(email.preview ?? ''); const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : ''; + // Search hits carry server snippets with the matched terms marked; they + // replace the plain subject / preview so the user sees why a mail matched. + const subjectSnippet = email.searchSnippet?.subject ?? null; + const previewSnippet = showPreview ? (email.searchSnippet?.preview ?? null) : null; const scheduledSendLabel = email.isScheduled && email.scheduledSendAt ? formatDateTime(email.scheduledSendAt, timeFormat) : null; @@ -363,10 +368,12 @@ const SingleEmailItem = React.forwardRef( 'min-w-0 truncate', isUnread ? 'font-semibold text-foreground' : 'text-foreground/90' )}> - {email.subject || '(no subject)'} + {subjectSnippet ? : (email.subject || '(no subject)')} - {inlinePreview && ( - {inlinePreview} + {(previewSnippet || inlinePreview) && ( + + {previewSnippet ? <> : inlinePreview} + )} @@ -488,7 +495,7 @@ const SingleEmailItem = React.forwardRef( ? "font-semibold text-foreground" : "font-normal text-foreground/90" )}> - {email.subject || "(no subject)"} + {subjectSnippet ? : (email.subject || "(no subject)")} @@ -499,7 +506,7 @@ const SingleEmailItem = React.forwardRef( ? "text-muted-foreground" : "text-muted-foreground/80" )}> - {trimmedPreview || t('no_preview_available')} + {previewSnippet ? : (trimmedPreview || t('no_preview_available'))}

)} @@ -563,6 +570,11 @@ export const ThreadListItem = React.forwardRef e.searchSnippet)?.searchSnippet ?? latestEmail.searchSnippet; + const subjectSnippet = threadSnippet?.subject ?? null; + const previewSnippet = showPreview ? (threadSnippet?.preview ?? null) : null; const scheduledSendLabel = latestEmail.isScheduled && latestEmail.scheduledSendAt ? formatDateTime(latestEmail.scheduledSendAt, timeFormat) : null; @@ -837,10 +849,12 @@ export const ThreadListItem = React.forwardRef - {latestEmail.subject || '(no subject)'} + {subjectSnippet ? : (latestEmail.subject || '(no subject)')} - {inlinePreview && ( - {inlinePreview} + {(previewSnippet || inlinePreview) && ( + + {previewSnippet ? <> : inlinePreview} + )} @@ -965,7 +979,7 @@ export const ThreadListItem = React.forwardRef - {latestEmail.subject || "(no subject)"} + {subjectSnippet ? : (latestEmail.subject || "(no subject)")} @@ -976,7 +990,7 @@ export const ThreadListItem = React.forwardRef - {trimmedPreview || tEmailViewer('no_preview_available')} + {previewSnippet ? : (trimmedPreview || tEmailViewer('no_preview_available'))}

)} diff --git a/lib/__tests__/search-snippet.test.ts b/lib/__tests__/search-snippet.test.ts new file mode 100644 index 000000000..8772013de --- /dev/null +++ b/lib/__tests__/search-snippet.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { + attachSearchSnippets, + filterHasSnippetTerms, + hasMarkedSegment, + parseSearchSnippet, + snippetFilterFor, +} from '../search-snippet'; +import type { Email } from '@/lib/jmap/types'; + +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('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({ 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 and quoted phrases alone', () => { + expect(snippetFilterFor({ text: '*' })).toEqual({ text: '*' }); + expect(snippetFilterFor({ subject: '"exact phrase"' })).toEqual({ subject: '"exact phrase"' }); + }); +}); + +describe('attachSearchSnippets', () => { + const email = (id: string): Email => ({ id, subject: `s-${id}` }) as Email; + + it('attaches snippets by email id and drops ones without a highlight', () => { + const emails = [email('a'), email('b'), email('c')]; + attachSearchSnippets(emails, [ + { emailId: 'a', subject: 'hit a', preview: null }, + { emailId: 'b', subject: 's-b', preview: 'nothing marked' }, + { emailId: 'c', subject: null, preview: 'body hit' }, + ]); + expect(emails[0].searchSnippet).toEqual({ subject: 'hit a', preview: null }); + expect(emails[1].searchSnippet).toBeUndefined(); + expect(emails[2].searchSnippet).toEqual({ subject: null, preview: 'body hit' }); + }); + + it('is a no-op without snippets', () => { + const emails = [email('a')]; + attachSearchSnippets(emails, undefined); + attachSearchSnippets(emails, []); + expect(emails[0].searchSnippet).toBeUndefined(); + }); +}); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 04257ef6b..e6c216a5f 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -3,6 +3,7 @@ import type { Email, Mailbox, StateChange, AccountStates, CollectionChanges, Thr import type { SieveScript, SieveCapabilities } from "./sieve-types"; import type { IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration } from "./client-interface"; import { toWildcardQuery } from "./search-utils"; +import { attachSearchSnippets, filterHasSnippetTerms, snippetFilterFor, type SearchSnippetResult } from "@/lib/search-snippet"; import { batched, itemsPerRequest } from "./request-limits"; import { keywordPointer } from "./patch-pointer"; import { FirstTouchGate } from "./first-touch-gate"; @@ -2716,6 +2717,7 @@ export class JMAPClient implements IJMAPClient { "#ids": { resultOf: "0", name: "Email/query", path: "/ids" }, properties: [...EMAIL_LIST_PROPERTIES], }, "1"], + this.searchSnippetCall(targetAccountId, filter), ]); const queryResponse = response.methodResponses?.[0]?.[1]; @@ -2725,6 +2727,7 @@ export class JMAPClient implements IJMAPClient { ); const total = queryResponse?.total || 0; const hasMore = computeHasMore(position, emails.length, total, limit); + attachSearchSnippets(emails, this.searchSnippetsFrom(response)); // Mirror getEmails: emails fetched from a delegated/shared account carry // bare owner mailbox ids; namespace them to `${ownerId}:${id}` so they line @@ -2740,6 +2743,34 @@ export class JMAPClient implements IJMAPClient { } } + /** + * The SearchSnippet/get call (RFC 8621 §5) chained onto a search request, + * or a Core/echo no-op when the filter has no term the server could + * highlight (a purely structural filter yields empty snippets). Reads the + * ids straight from the Email/query result, so it costs no extra round + * trip; a failure of this call alone leaves the results without snippets. + */ + private searchSnippetCall(accountId: string, filter: Record | undefined): JMAPMethodCall { + if (!filter || !filterHasSnippetTerms(filter)) { + return ["Core/echo", {}, "snippets"]; + } + return ["SearchSnippet/get", { + accountId, + filter: snippetFilterFor(filter), + "#emailIds": { resultOf: "0", name: "Email/query", path: "/ids" }, + }, "snippets"]; + } + + /** The SearchSnippet list of a search response, if the call succeeded. */ + private searchSnippetsFrom(response: JMAPResponse): SearchSnippetResult[] | undefined { + for (const [method, result, callId] of response.methodResponses ?? []) { + if (callId === "snippets" && method === "SearchSnippet/get") { + return ((result as { list?: SearchSnippetResult[] })?.list) ?? undefined; + } + } + return undefined; + } + async advancedSearchEmails( filter: Record, accountId?: string, @@ -2767,6 +2798,7 @@ export class JMAPClient implements IJMAPClient { "#ids": { resultOf: "0", name: "Email/query", path: "/ids" }, properties: [...EMAIL_LIST_PROPERTIES], }, "1"], + this.searchSnippetCall(targetAccountId, hasFilter ? filter : undefined), ]); const queryResponse = response.methodResponses?.[0]?.[1]; @@ -2776,6 +2808,7 @@ export class JMAPClient implements IJMAPClient { ); const total = queryResponse?.total || 0; const hasMore = computeHasMore(position, emails.length, total, limit); + attachSearchSnippets(emails, this.searchSnippetsFrom(response)); // Namespace shared/delegated-account mailbox ids (see searchEmails). The // cross-account views (All mail / Unread / Starred) browse via this method, diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index aedae11ea..f9a944510 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -37,6 +37,10 @@ export interface Email { subject?: string; sentAt?: string; preview?: string; + // Server-side search highlighting (SearchSnippet/get, RFC 8621 §5): the + // subject / body excerpt with the matched terms in , set on search + // hits only. See lib/search-snippet.ts. + searchSnippet?: { subject: string | null; preview: string | null }; textBody?: EmailBodyPart[]; htmlBody?: EmailBodyPart[]; bodyValues?: Record; diff --git a/lib/search-snippet.ts b/lib/search-snippet.ts new file mode 100644 index 000000000..5fe84f8dd --- /dev/null +++ b/lib/search-snippet.ts @@ -0,0 +1,130 @@ +/** + * SearchSnippet/get support (RFC 8621 §5). + * + * 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 renders those instead of the + * plain subject/preview so the user sees *why* a mail matched. Nothing here + * ever injects HTML: the snippet is split into text/marked segments and the + * text is decoded and rendered as React text. + */ + +import type { Email } from '@/lib/jmap/types'; + +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; +} + +const ENTITY_RE = /&(amp|lt|gt|quot|apos|#39|#x27);/g; +const ENTITIES: Record = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + '#39': "'", + '#x27': "'", +}; + +function decodeEntities(text: string): string { + return text.replace(ENTITY_RE, (_m, name: string) => ENTITIES[name] ?? _m); +} + +/** + * 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; +} + +/** 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, 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 wildcard + * suffix the search adds for prefix matching (`pri*`) removed from the + * highlightable terms, so the server's tokenizer sees the words the user + * typed. 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') { + out[key] = value + .split(/\s+/) + .map((word) => (word.length > 1 && word.endsWith('*') ? word.slice(0, -1) : word)) + .join(' '); + } else { + out[key] = value; + } + } + return out; +} + +/** + * Attaches the snippets to the emails they belong to. A snippet without any + * highlighted term (the server found nothing to mark, or returned the plain + * subject) is dropped, so the list falls back to the regular subject/preview. + */ +export function attachSearchSnippets(emails: Email[], snippets: SearchSnippetResult[] | undefined): Email[] { + if (!snippets || snippets.length === 0) return emails; + const byId = new Map(snippets.map((s) => [s.emailId, s])); + for (const email of emails) { + const snippet = byId.get(email.id); + if (!snippet) continue; + const subject = snippet.subject && hasMarkedSegment(parseSearchSnippet(snippet.subject)) ? snippet.subject : null; + const preview = snippet.preview && hasMarkedSegment(parseSearchSnippet(snippet.preview)) ? snippet.preview : null; + if (subject || preview) { + email.searchSnippet = { subject, preview }; + } + } + return emails; +}