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;
+}