feat(search): why this matched highlights (#15)
CI / backend (push) Successful in 50s
CI / frontend (push) Successful in 14s
Deploy Staging / deploy (push) Successful in 29s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 55s
CI / backend (pull_request) Successful in 47s
CI / frontend (pull_request) Successful in 12s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 54s

This commit was merged in pull request #15.
This commit is contained in:
2026-07-01 22:22:38 +02:00
parent fcf290a83b
commit 9bbab5d32a
4 changed files with 69 additions and 11 deletions
+22 -1
View File
@@ -16,6 +16,25 @@ const fmtSize = (b) => {
return `${(b / 1048576).toFixed(1)} MB`; 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 <mark> 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(<mark key={key++}>{m[1]}</mark>);
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 }) { export default function EmailRow({ email: initial, onRemove, selected, onToggleSelect, focused }) {
const [email, setEmail] = useState(initial); const [email, setEmail] = useState(initial);
const [acting, setActing] = useState(false); const [acting, setActing] = useState(false);
@@ -83,7 +102,9 @@ export default function EmailRow({ email: initial, onRemove, selected, onToggleS
</td> </td>
<td className="el-subject"> <td className="el-subject">
<span className="el-subj-text">{email.subject || '(no subject)'}</span> <span className="el-subj-text">{email.subject || '(no subject)'}</span>
{email.snippet && <span className="el-snippet"> {email.snippet}</span>} {email.matchHighlight
? <span className="el-snippet"> {renderHighlight(email.matchHighlight)}</span>
: email.snippet && <span className="el-snippet"> {email.snippet}</span>}
</td> </td>
<td className="el-meta"> <td className="el-meta">
{email.hasAttachments && <span className="el-attach" title="Has attachment">📎</span>} {email.hasAttachments && <span className="el-attach" title="Has attachment">📎</span>}
+8
View File
@@ -64,6 +64,14 @@
border-color: hsl(var(--border)); 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 { body {
@apply bg-background text-foreground antialiased; @apply bg-background text-foreground antialiased;
/* Inter (variable, self-hosted via @fontsource-variable/inter); system fallback. */ /* Inter (variable, self-hosted via @fontsource-variable/inter); system fallback. */
+6 -1
View File
@@ -16,7 +16,12 @@ public record EmailSummaryDto(
long SizeEstimateBytes, long SizeEstimateBytes,
EmailCategory Category, EmailCategory Category,
bool HasListUnsubscribe, 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 <mark> 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);
/// <summary>Full single-email view, including body text, for the detail pane.</summary> /// <summary>Full single-email view, including body text, for the detail pane.</summary>
public record EmailDetailDto( public record EmailDetailDto(
@@ -77,15 +77,39 @@ public class SearchService : ISearchService
.ThenByDescending(e => e.SentAtUtc) .ThenByDescending(e => e.SentAtUtc)
: q.OrderByDescending(e => e.SentAtUtc); : q.OrderByDescending(e => e.SentAtUtc);
var items = await ranked var paged = ranked.Skip((r.Page - 1) * r.PageSize).Take(r.PageSize);
.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<EmailSummaryDto> 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
// <mark> 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( .Select(e => new EmailSummaryDto(
e.Id, e.GmailMessageId, e.Subject, e.Snippet, e.Id, e.GmailMessageId, e.Subject, e.Snippet,
e.Sender!.Address, e.Sender.DisplayName, e.SentAtUtc, e.Sender!.Address, e.Sender.DisplayName, e.SentAtUtc,
e.IsUnread, e.IsStarred, e.HasAttachments, e.SizeEstimateBytes, e.Category, e.IsUnread, e.IsStarred, e.HasAttachments, e.SizeEstimateBytes, e.Category,
e.HasListUnsubscribe, e.SupportsOneClickUnsubscribe)) e.HasListUnsubscribe, e.SupportsOneClickUnsubscribe,
EF.Functions.WebSearchToTsQuery("english", term).GetResultHeadline("english", e.BodyText ?? "", headlineOpts)))
.ToListAsync(ct); .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<EmailSummaryDto> return new PagedResult<EmailSummaryDto>
{ {