mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
131 lines
4.7 KiB
TypeScript
131 lines
4.7 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|