From 9bbab5d32a61a6a01e253a8d0994f701b5bc7290 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Wed, 1 Jul 2026 22:22:38 +0200 Subject: [PATCH] feat(search): why this matched highlights (#15) --- frontend/src/components/EmailRow.jsx | 23 +++++++++- frontend/src/index.css | 8 ++++ src/InboxIntel.Application/DTOs/EmailDtos.cs | 7 +++- .../Search/SearchService.cs | 42 +++++++++++++++---- 4 files changed, 69 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/EmailRow.jsx b/frontend/src/components/EmailRow.jsx index eb00ca8..41b3b00 100644 --- a/frontend/src/components/EmailRow.jsx +++ b/frontend/src/components/EmailRow.jsx @@ -16,6 +16,25 @@ const fmtSize = (b) => { return `${(b / 1048576).toFixed(1)} MB`; }; +// "Why this matched": the API wraps matched terms in U+E000/U+E001 sentinels (NOT HTML). +// We tokenise and render the highlighted parts as React elements — React escapes +// all text nodes, so untrusted email content can never inject markup (no dangerouslySetInnerHTML). +const HL_START = String.fromCharCode(0xE000); +const HL_STOP = String.fromCharCode(0xE001); +const HL_RE = new RegExp(HL_START + '([\s\S]*?)' + HL_STOP, 'g'); +function renderHighlight(s) { + const out = []; + let last = 0, key = 0, m; + HL_RE.lastIndex = 0; + while ((m = HL_RE.exec(s)) !== null) { + if (m.index > last) out.push(s.slice(last, m.index)); + out.push({m[1]}); + last = HL_RE.lastIndex; + } + if (last < s.length) out.push(s.slice(last)); + return out; +} + export default function EmailRow({ email: initial, onRemove, selected, onToggleSelect, focused }) { const [email, setEmail] = useState(initial); const [acting, setActing] = useState(false); @@ -83,7 +102,9 @@ export default function EmailRow({ email: initial, onRemove, selected, onToggleS {email.subject || '(no subject)'} - {email.snippet && — {email.snippet}} + {email.matchHighlight + ? — {renderHighlight(email.matchHighlight)} + : email.snippet && — {email.snippet}} {email.hasAttachments && 📎} diff --git a/frontend/src/index.css b/frontend/src/index.css index fdc45d3..8b0f426 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -64,6 +64,14 @@ border-color: hsl(var(--border)); } + /* Search "why this matched" highlight — subtle accent tint, not the default yellow. */ + mark { + background: hsl(var(--primary) / 0.22); + color: inherit; + border-radius: 3px; + padding: 0 1px; + } + body { @apply bg-background text-foreground antialiased; /* Inter (variable, self-hosted via @fontsource-variable/inter); system fallback. */ diff --git a/src/InboxIntel.Application/DTOs/EmailDtos.cs b/src/InboxIntel.Application/DTOs/EmailDtos.cs index 98effec..ec37f8b 100644 --- a/src/InboxIntel.Application/DTOs/EmailDtos.cs +++ b/src/InboxIntel.Application/DTOs/EmailDtos.cs @@ -16,7 +16,12 @@ public record EmailSummaryDto( long SizeEstimateBytes, EmailCategory Category, bool HasListUnsubscribe, - bool SupportsOneClick); + bool SupportsOneClick, + // "Why this matched": a ts_headline fragment of the body with matched terms wrapped in + // U+E000/U+E001 sentinels (NOT HTML — the client renders them as escaped elements, + // so untrusted email content can never inject markup). Null unless the search had a + // free-text query. Optional/last so other DTO constructors are unaffected. + string? MatchHighlight = null); /// Full single-email view, including body text, for the detail pane. public record EmailDetailDto( diff --git a/src/InboxIntel.Infrastructure/Search/SearchService.cs b/src/InboxIntel.Infrastructure/Search/SearchService.cs index 178b3e4..9e39e83 100644 --- a/src/InboxIntel.Infrastructure/Search/SearchService.cs +++ b/src/InboxIntel.Infrastructure/Search/SearchService.cs @@ -77,15 +77,39 @@ public class SearchService : ISearchService .ThenByDescending(e => e.SentAtUtc) : q.OrderByDescending(e => e.SentAtUtc); - var items = await ranked - .Skip((r.Page - 1) * r.PageSize) - .Take(r.PageSize) - .Select(e => new EmailSummaryDto( - e.Id, e.GmailMessageId, e.Subject, e.Snippet, - e.Sender!.Address, e.Sender.DisplayName, e.SentAtUtc, - e.IsUnread, e.IsStarred, e.HasAttachments, e.SizeEstimateBytes, e.Category, - e.HasListUnsubscribe, e.SupportsOneClickUnsubscribe)) - .ToListAsync(ct); + var paged = ranked.Skip((r.Page - 1) * r.PageSize).Take(r.PageSize); + + // Two unconditional projections (no DB function inside a C# ternary → no doubt about + // EF translation). The browse path never touches ts_headline, so it's byte-for-byte + // unchanged AND safe under the InMemory test provider. + List items; + if (hasFreeTextQuery) + { + // "Why this matched": ts_headline body fragment with matched terms wrapped in + // U+E000/U+E001 sentinels (safe, non-HTML — the client renders them as escaped + // spans; see EmailSummaryDto). + var headlineOpts = + $"StartSel={(char)0xE000},StopSel={(char)0xE001},MaxWords=16,MinWords=5,ShortWord=2,HighlightAll=false"; + items = await paged + .Select(e => new EmailSummaryDto( + e.Id, e.GmailMessageId, e.Subject, e.Snippet, + e.Sender!.Address, e.Sender.DisplayName, e.SentAtUtc, + e.IsUnread, e.IsStarred, e.HasAttachments, e.SizeEstimateBytes, e.Category, + e.HasListUnsubscribe, e.SupportsOneClickUnsubscribe, + EF.Functions.WebSearchToTsQuery("english", term).GetResultHeadline("english", e.BodyText ?? "", headlineOpts))) + .ToListAsync(ct); + } + else + { + items = await paged + .Select(e => new EmailSummaryDto( + e.Id, e.GmailMessageId, e.Subject, e.Snippet, + e.Sender!.Address, e.Sender.DisplayName, e.SentAtUtc, + e.IsUnread, e.IsStarred, e.HasAttachments, e.SizeEstimateBytes, e.Category, + e.HasListUnsubscribe, e.SupportsOneClickUnsubscribe, + null)) + .ToListAsync(ct); + } return new PagedResult {