mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: highlight search hits with SearchSnippet/get
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { parseSearchSnippet } from '@/lib/search-snippet';
|
||||
|
||||
interface SearchSnippetTextProps {
|
||||
/** Server snippet with `<mark>` 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 ? (
|
||||
<mark
|
||||
key={i}
|
||||
className="rounded-sm bg-yellow-200 px-0.5 text-inherit dark:bg-yellow-500/40"
|
||||
>
|
||||
{segment.text}
|
||||
</mark>
|
||||
) : (
|
||||
<React.Fragment key={i}>{segment.text}</React.Fragment>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement, SingleEmailItemProps>(
|
||||
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<HTMLDivElement, SingleEmailItemProps>(
|
||||
'min-w-0 truncate',
|
||||
isUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||
)}>
|
||||
{email.subject || '(no subject)'}
|
||||
{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (email.subject || '(no subject)')}
|
||||
</span>
|
||||
{inlinePreview && (
|
||||
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">{inlinePreview}</span>
|
||||
{(previewSnippet || inlinePreview) && (
|
||||
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">
|
||||
{previewSnippet ? <> <SearchSnippetText snippet={previewSnippet} /></> : inlinePreview}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -488,7 +495,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{email.subject || "(no subject)"}
|
||||
{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (email.subject || "(no subject)")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -499,7 +506,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/80"
|
||||
)}>
|
||||
{trimmedPreview || t('no_preview_available')}
|
||||
{previewSnippet ? <SearchSnippetText snippet={previewSnippet} /> : (trimmedPreview || t('no_preview_available'))}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
@@ -563,6 +570,11 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||
const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? '');
|
||||
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
||||
// In a search the matched mail need not be the thread's latest one: show
|
||||
// the snippet of whichever email in the thread carries one.
|
||||
const threadSnippet = thread.emails.find((e) => 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<HTMLDivElement, ThreadListItemPro
|
||||
'min-w-0 truncate',
|
||||
hasUnread ? 'font-semibold text-foreground' : 'text-foreground/90'
|
||||
)}>
|
||||
{latestEmail.subject || '(no subject)'}
|
||||
{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (latestEmail.subject || '(no subject)')}
|
||||
</span>
|
||||
{inlinePreview && (
|
||||
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">{inlinePreview}</span>
|
||||
{(previewSnippet || inlinePreview) && (
|
||||
<span className="min-w-0 shrink-[9999] truncate text-muted-foreground">
|
||||
{previewSnippet ? <> <SearchSnippetText snippet={previewSnippet} /></> : inlinePreview}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -965,7 +979,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
? "font-semibold text-foreground"
|
||||
: "font-normal text-foreground/90"
|
||||
)}>
|
||||
{latestEmail.subject || "(no subject)"}
|
||||
{subjectSnippet ? <SearchSnippetText snippet={subjectSnippet} /> : (latestEmail.subject || "(no subject)")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -976,7 +990,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/80"
|
||||
)}>
|
||||
{trimmedPreview || tEmailViewer('no_preview_available')}
|
||||
{previewSnippet ? <SearchSnippetText snippet={previewSnippet} /> : (trimmedPreview || tEmailViewer('no_preview_available'))}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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: <mark>Invoice</mark> & 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('<script>x</script> <mark>hit</mark><b>bold</b>');
|
||||
expect(segments).toEqual([
|
||||
{ text: '<script>x</script> ', marked: false },
|
||||
{ text: 'hit', marked: true },
|
||||
{ text: '<b>bold</b>', marked: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports no highlight for a snippet without marks', () => {
|
||||
const segments = parseSearchSnippet('plain subject');
|
||||
expect(segments).toEqual([{ text: 'plain subject', marked: false }]);
|
||||
expect(hasMarkedSegment(segments)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('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: '<mark>hit</mark> a', preview: null },
|
||||
{ emailId: 'b', subject: 's-b', preview: 'nothing marked' },
|
||||
{ emailId: 'c', subject: null, preview: 'body <mark>hit</mark>' },
|
||||
]);
|
||||
expect(emails[0].searchSnippet).toEqual({ subject: '<mark>hit</mark> a', preview: null });
|
||||
expect(emails[1].searchSnippet).toBeUndefined();
|
||||
expect(emails[2].searchSnippet).toEqual({ subject: null, preview: 'body <mark>hit</mark>' });
|
||||
});
|
||||
|
||||
it('is a no-op without snippets', () => {
|
||||
const emails = [email('a')];
|
||||
attachSearchSnippets(emails, undefined);
|
||||
attachSearchSnippets(emails, []);
|
||||
expect(emails[0].searchSnippet).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> | 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<string, unknown>,
|
||||
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,
|
||||
|
||||
@@ -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 <mark>, set on search
|
||||
// hits only. See lib/search-snippet.ts.
|
||||
searchSnippet?: { subject: string | null; preview: string | null };
|
||||
textBody?: EmailBodyPart[];
|
||||
htmlBody?: EmailBodyPart[];
|
||||
bodyValues?: Record<string, EmailBodyValue>;
|
||||
|
||||
@@ -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 `<mark>` 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<string, string> = {
|
||||
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 `<mark>`ed segments. Anything that is not
|
||||
* a `<mark>`/`</mark>` token is treated as (escaped) text, so an unexpected
|
||||
* tag in the payload is displayed literally rather than interpreted.
|
||||
*/
|
||||
export function parseSearchSnippet(snippet: string): SnippetSegment[] {
|
||||
const segments: SnippetSegment[] = [];
|
||||
const re = /<\/?mark>/g;
|
||||
let marked = false;
|
||||
let last = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(snippet)) !== null) {
|
||||
if (match.index > last) {
|
||||
segments.push({ text: decodeEntities(snippet.slice(last, match.index)), marked });
|
||||
}
|
||||
marked = match[0] === '<mark>';
|
||||
last = match.index + match[0].length;
|
||||
}
|
||||
if (last < snippet.length) {
|
||||
segments.push({ text: decodeEntities(snippet.slice(last)), marked });
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
/** 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<string, unknown> | undefined): boolean {
|
||||
if (!filter) return false;
|
||||
const conditions = filter.conditions;
|
||||
if (Array.isArray(conditions)) {
|
||||
return conditions.some((c) => filterHasSnippetTerms(c as Record<string, unknown>));
|
||||
}
|
||||
return Object.entries(filter).some(
|
||||
([key, value]) => TERM_PROPERTIES.has(key) && typeof value === 'string' && value.trim().length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The filter to hand SearchSnippet/get: the query filter with the 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<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(filter)) {
|
||||
if (key === 'conditions' && Array.isArray(value)) {
|
||||
out[key] = value.map((c) => snippetFilterFor(c as Record<string, unknown>));
|
||||
} else if (TERM_PROPERTIES.has(key) && typeof value === 'string') {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user