diff --git a/src/InboxIntel.Application/DTOs/EmailDtos.cs b/src/InboxIntel.Application/DTOs/EmailDtos.cs
index ec37f8b..3fed826 100644
--- a/src/InboxIntel.Application/DTOs/EmailDtos.cs
+++ b/src/InboxIntel.Application/DTOs/EmailDtos.cs
@@ -2,6 +2,13 @@ using InboxIntel.Domain.Enums;
namespace InboxIntel.Application.DTOs;
+///
+/// Row summary for search/list results. MatchHighlight is a "why this matched" body
+/// fragment (ts_headline) 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.
+///
public record EmailSummaryDto(
Guid Id,
string GmailMessageId,
@@ -17,10 +24,6 @@ public record EmailSummaryDto(
EmailCategory Category,
bool HasListUnsubscribe,
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.
diff --git a/src/InboxIntel.Infrastructure/Migrations/20260701203001_EnablePgTrgm.Designer.cs b/src/InboxIntel.Infrastructure/Migrations/20260701203001_EnablePgTrgm.Designer.cs
new file mode 100644
index 0000000..846cc5e
--- /dev/null
+++ b/src/InboxIntel.Infrastructure/Migrations/20260701203001_EnablePgTrgm.Designer.cs
@@ -0,0 +1,743 @@
+//
+using System;
+using InboxIntel.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using NpgsqlTypes;
+
+#nullable disable
+
+namespace InboxIntel.Infrastructure.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260701203001_EnablePgTrgm")]
+ partial class EnablePgTrgm
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.4")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("InboxIntel.Domain.Entities.AnalyticsAggregate", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Day")
+ .HasColumnType("date");
+
+ b.Property("HourHistogramJson")
+ .HasColumnType("text");
+
+ b.Property("NewsletterCount")
+ .HasColumnType("integer");
+
+ b.Property("TotalReceived")
+ .HasColumnType("integer");
+
+ b.Property("TotalSizeBytes")
+ .HasColumnType("bigint");
+
+ b.Property("TotalUnread")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.Property("WithAttachments")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "Day")
+ .IsUnique();
+
+ b.ToTable("analytics_aggregates", (string)null);
+ });
+
+ modelBuilder.Entity("InboxIntel.Domain.Entities.Attachment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EmailId")
+ .HasColumnType("uuid");
+
+ b.Property("FileName")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.Property("GmailAttachmentId")
+ .HasColumnType("text");
+
+ b.Property("MimeType")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("SizeBytes")
+ .HasColumnType("bigint");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EmailId");
+
+ b.HasIndex("UserId", "MimeType");
+
+ b.ToTable("attachments", (string)null);
+ });
+
+ modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("BodyText")
+ .HasColumnType("text");
+
+ b.Property("Category")
+ .HasColumnType("integer");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("GmailMessageId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("HasAttachments")
+ .HasColumnType("boolean");
+
+ b.Property("HasListUnsubscribe")
+ .HasColumnType("boolean");
+
+ b.Property("IsImportant")
+ .HasColumnType("boolean");
+
+ b.Property("IsInInbox")
+ .HasColumnType("boolean");
+
+ b.Property("IsStarred")
+ .HasColumnType("boolean");
+
+ b.Property("IsTrashed")
+ .HasColumnType("boolean");
+
+ b.Property("IsUnread")
+ .HasColumnType("boolean");
+
+ b.Property("ListUnsubscribeRaw")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)");
+
+ b.Property("ReceivedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SearchVector")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("tsvector")
+ .HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"Subject\",'')), 'A') || setweight(to_tsvector('english', coalesce(\"BodyText\",'')), 'B')", true);
+
+ b.Property("SenderId")
+ .HasColumnType("uuid");
+
+ b.Property("SentAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SizeEstimateBytes")
+ .HasColumnType("bigint");
+
+ b.Property("Snippet")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)");
+
+ b.Property("Subject")
+ .HasMaxLength(1024)
+ .HasColumnType("character varying(1024)");
+
+ b.Property("SupportsOneClickUnsubscribe")
+ .HasColumnType("boolean");
+
+ b.Property("ThreadId")
+ .HasColumnType("uuid");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SearchVector");
+
+ NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "GIN");
+
+ b.HasIndex("SenderId");
+
+ b.HasIndex("ThreadId");
+
+ b.HasIndex("UserId", "Category");
+
+ b.HasIndex("UserId", "GmailMessageId")
+ .IsUnique();
+
+ b.HasIndex("UserId", "IsInInbox");
+
+ b.HasIndex("UserId", "IsUnread");
+
+ b.HasIndex("UserId", "SenderId");
+
+ b.HasIndex("UserId", "SentAtUtc");
+
+ b.ToTable("emails", (string)null);
+ });
+
+ modelBuilder.Entity("InboxIntel.Domain.Entities.EmailLabel", b =>
+ {
+ b.Property("EmailId")
+ .HasColumnType("uuid");
+
+ b.Property("LabelId")
+ .HasColumnType("uuid");
+
+ b.HasKey("EmailId", "LabelId");
+
+ b.HasIndex("LabelId");
+
+ b.ToTable("email_labels", (string)null);
+ });
+
+ modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ColorHex")
+ .HasColumnType("text");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("GmailLabelId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "GmailLabelId")
+ .IsUnique();
+
+ b.ToTable("labels", (string)null);
+ });
+
+ modelBuilder.Entity("InboxIntel.Domain.Entities.MailDomain", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EmailCount")
+ .HasColumnType("integer");
+
+ b.Property("IsBulkSender")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "Name")
+ .IsUnique();
+
+ b.ToTable("domains", (string)null);
+ });
+
+ modelBuilder.Entity("InboxIntel.Domain.Entities.MailThread", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FirstMessageUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("GmailThreadId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("LastMessageUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("MessageCount")
+ .HasColumnType("integer");
+
+ b.Property("Snippet")
+ .HasColumnType("text");
+
+ b.Property("Subject")
+ .HasMaxLength(1024)
+ .HasColumnType("character varying(1024)");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "GmailThreadId")
+ .IsUnique();
+
+ b.ToTable("threads", (string)null);
+ });
+
+ modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Address")
+ .IsRequired()
+ .HasMaxLength(320)
+ .HasColumnType("character varying(320)");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DisplayName")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)");
+
+ b.Property("DomainId")
+ .HasColumnType("uuid");
+
+ b.Property("EmailCount")
+ .HasColumnType("integer");
+
+ b.Property("HasUnsubscribe")
+ .HasColumnType("boolean");
+
+ b.Property("LastReceivedUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("TotalSizeBytes")
+ .HasColumnType("bigint");
+
+ b.Property("UnreadCount")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DomainId");
+
+ b.HasIndex("UserId", "Address")
+ .IsUnique();
+
+ b.HasIndex("UserId", "EmailCount");
+
+ b.ToTable("senders", (string)null);
+ });
+
+ modelBuilder.Entity("InboxIntel.Domain.Entities.SyncState", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CompletedUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ConsecutiveFailures")
+ .HasColumnType("integer");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastError")
+ .HasMaxLength(4000)
+ .HasColumnType("character varying(4000)");
+
+ b.Property("LastHistoryId")
+ .HasColumnType("text");
+
+ b.Property("LastSuccessfulSyncUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastSyncType")
+ .HasColumnType("integer");
+
+ b.Property("MessagesProcessed")
+ .HasColumnType("integer");
+
+ b.Property("ResumePageToken")
+ .HasColumnType("text");
+
+ b.Property("StartedUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("TotalMessagesEstimate")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId")
+ .IsUnique();
+
+ b.ToTable("sync_states", (string)null);
+ });
+
+ modelBuilder.Entity("InboxIntel.Domain.Entities.UnsubscribeItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Confidence")
+ .HasColumnType("double precision");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EmailCount")
+ .HasColumnType("integer");
+
+ b.Property("LastAttemptUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Method")
+ .HasColumnType("integer");
+
+ b.Property("ResultMessage")
+ .HasColumnType("text");
+
+ b.Property("SenderId")
+ .HasColumnType("uuid");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("UnsubscribeTarget")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SenderId");
+
+ b.HasIndex("UserId", "SenderId")
+ .IsUnique();
+
+ b.ToTable("unsubscribe_items", (string)null);
+ });
+
+ modelBuilder.Entity("InboxIntel.Domain.Entities.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AccessTokenExpiresAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DigestEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("DisplayName")
+ .HasColumnType("text");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(320)
+ .HasColumnType("character varying(320)");
+
+ b.Property("EncryptedRefreshToken")
+ .HasColumnType("bytea");
+
+ b.Property("GoogleSubjectId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("LastDigestSentUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastLoginUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("PictureUrl")
+ .HasColumnType("text");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Email")
+ .IsUnique();
+
+ b.HasIndex("GoogleSubjectId")
+ .IsUnique();
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("H")
+ .HasColumnType("integer");
+
+ b.Property("SettingsJson")
+ .HasColumnType("text");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.Property("Visible")
+ .HasColumnType("boolean");
+
+ b.Property("W")
+ .HasColumnType("integer");
+
+ b.Property("WidgetKey")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("X")
+ .HasColumnType("integer");
+
+ b.Property("Y")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "WidgetKey")
+ .IsUnique();
+
+ b.ToTable("widget_layouts", (string)null);
+ });
+
+ modelBuilder.Entity("InboxIntel.Domain.Entities.Attachment", b =>
+ {
+ b.HasOne("InboxIntel.Domain.Entities.Email", "Email")
+ .WithMany("Attachments")
+ .HasForeignKey("EmailId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Email");
+ });
+
+ modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b =>
+ {
+ b.HasOne("InboxIntel.Domain.Entities.Sender", "Sender")
+ .WithMany("Emails")
+ .HasForeignKey("SenderId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.HasOne("InboxIntel.Domain.Entities.MailThread", "Thread")
+ .WithMany("Emails")
+ .HasForeignKey("ThreadId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("InboxIntel.Domain.Entities.User", null)
+ .WithMany("Emails")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Sender");
+
+ b.Navigation("Thread");
+ });
+
+ modelBuilder.Entity("InboxIntel.Domain.Entities.EmailLabel", b =>
+ {
+ b.HasOne("InboxIntel.Domain.Entities.Email", "Email")
+ .WithMany("EmailLabels")
+ .HasForeignKey("EmailId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("InboxIntel.Domain.Entities.Label", "Label")
+ .WithMany("EmailLabels")
+ .HasForeignKey("LabelId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Email");
+
+ b.Navigation("Label");
+ });
+
+ modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b =>
+ {
+ b.HasOne("InboxIntel.Domain.Entities.MailDomain", "Domain")
+ .WithMany("Senders")
+ .HasForeignKey("DomainId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired();
+
+ b.Navigation("Domain");
+ });
+
+ modelBuilder.Entity("InboxIntel.Domain.Entities.UnsubscribeItem", b =>
+ {
+ b.HasOne("InboxIntel.Domain.Entities.Sender", "Sender")
+ .WithMany()
+ .HasForeignKey("SenderId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Sender");
+ });
+
+ modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b =>
+ {
+ b.HasOne("InboxIntel.Domain.Entities.User", null)
+ .WithMany("WidgetLayouts")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b =>
+ {
+ b.Navigation("Attachments");
+
+ b.Navigation("EmailLabels");
+ });
+
+ modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b =>
+ {
+ b.Navigation("EmailLabels");
+ });
+
+ modelBuilder.Entity("InboxIntel.Domain.Entities.MailDomain", b =>
+ {
+ b.Navigation("Senders");
+ });
+
+ modelBuilder.Entity("InboxIntel.Domain.Entities.MailThread", b =>
+ {
+ b.Navigation("Emails");
+ });
+
+ modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b =>
+ {
+ b.Navigation("Emails");
+ });
+
+ modelBuilder.Entity("InboxIntel.Domain.Entities.User", b =>
+ {
+ b.Navigation("Emails");
+
+ b.Navigation("WidgetLayouts");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/InboxIntel.Infrastructure/Migrations/20260701203001_EnablePgTrgm.cs b/src/InboxIntel.Infrastructure/Migrations/20260701203001_EnablePgTrgm.cs
new file mode 100644
index 0000000..eb140e6
--- /dev/null
+++ b/src/InboxIntel.Infrastructure/Migrations/20260701203001_EnablePgTrgm.cs
@@ -0,0 +1,24 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace InboxIntel.Infrastructure.Migrations
+{
+ ///
+ public partial class EnablePgTrgm : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AlterDatabase()
+ .Annotation("Npgsql:PostgresExtension:pg_trgm", ",,");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AlterDatabase()
+ .OldAnnotation("Npgsql:PostgresExtension:pg_trgm", ",,");
+ }
+ }
+}
diff --git a/src/InboxIntel.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/InboxIntel.Infrastructure/Migrations/AppDbContextModelSnapshot.cs
index edf4aea..ffcdab8 100644
--- a/src/InboxIntel.Infrastructure/Migrations/AppDbContextModelSnapshot.cs
+++ b/src/InboxIntel.Infrastructure/Migrations/AppDbContextModelSnapshot.cs
@@ -21,6 +21,7 @@ namespace InboxIntel.Infrastructure.Migrations
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("InboxIntel.Domain.Entities.AnalyticsAggregate", b =>
diff --git a/src/InboxIntel.Infrastructure/Persistence/AppDbContext.cs b/src/InboxIntel.Infrastructure/Persistence/AppDbContext.cs
index 583f9f5..8acdfee 100644
--- a/src/InboxIntel.Infrastructure/Persistence/AppDbContext.cs
+++ b/src/InboxIntel.Infrastructure/Persistence/AppDbContext.cs
@@ -65,6 +65,9 @@ public class AppDbContext : DbContext, IAppDbContext
// it otherwise (e.g. the InMemory provider used by tests).
if (Database.IsRelational())
{
+ // pg_trgm powers the fuzzy/typo fallback in SearchService (word_similarity).
+ modelBuilder.HasPostgresExtension("pg_trgm");
+
modelBuilder.Entity().Property(e => e.SearchVector)
.HasColumnType("tsvector")
.HasComputedColumnSql(
diff --git a/src/InboxIntel.Infrastructure/Search/SearchService.cs b/src/InboxIntel.Infrastructure/Search/SearchService.cs
index 9e39e83..402ca20 100644
--- a/src/InboxIntel.Infrastructure/Search/SearchService.cs
+++ b/src/InboxIntel.Infrastructure/Search/SearchService.cs
@@ -1,6 +1,7 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Application.Common;
using InboxIntel.Application.DTOs;
+using InboxIntel.Domain.Entities;
using InboxIntel.Domain.Enums;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
@@ -64,30 +65,47 @@ public class SearchService : ISearchService
// OR, and -exclusions — the syntax users already expect from web search boxes.
var hasFreeTextQuery = !string.IsNullOrWhiteSpace(r.Query);
var term = r.Query?.Trim() ?? string.Empty;
- if (hasFreeTextQuery)
- q = q.Where(e => e.SearchVector!.Matches(EF.Functions.WebSearchToTsQuery("english", term)));
- var total = await q.CountAsync(ct);
+ var structured = q; // structured filters only — reused by the fuzzy fallback below.
+ var matched = hasFreeTextQuery
+ ? structured.Where(e => e.SearchVector!.Matches(EF.Functions.WebSearchToTsQuery("english", term)))
+ : structured;
- // Relevance-ranked when there's a free-text query (ts_rank_cd via RankCoverDensity,
- // recency as a tiebreaker); date-only otherwise — matches the existing browse
- // behaviour when the user isn't searching for anything in particular.
- var ranked = hasFreeTextQuery
- ? q.OrderByDescending(e => e.SearchVector!.RankCoverDensity(EF.Functions.WebSearchToTsQuery("english", term)))
- .ThenByDescending(e => e.SentAtUtc)
- : q.OrderByDescending(e => e.SentAtUtc);
+ var total = await matched.CountAsync(ct);
+
+ // Fuzzy/typo fallback: ONLY when a free-text search found nothing exact. word_similarity
+ // with an explicit 0.3 threshold — pg_trgm's default 0.6 misses real typos
+ // ("recieved" -> "received" scores ~0.39). Rare path, so the (non-indexed) scan over the
+ // user's own structured subset is acceptable. Needs the pg_trgm extension (see migration).
+ const double fuzzyThreshold = 0.3;
+ var fuzzy = hasFreeTextQuery && total == 0;
+ if (fuzzy)
+ {
+ matched = structured.Where(e =>
+ e.Subject != null && EF.Functions.TrigramsWordSimilarity(term, e.Subject) >= fuzzyThreshold);
+ total = await matched.CountAsync(ct);
+ }
+
+ // Ordering: fuzzy → by word similarity; exact free-text → ts_rank_cd; browse → date.
+ IQueryable ranked;
+ if (fuzzy)
+ ranked = matched.OrderByDescending(e => EF.Functions.TrigramsWordSimilarity(term, e.Subject!))
+ .ThenByDescending(e => e.SentAtUtc);
+ else if (hasFreeTextQuery)
+ ranked = matched.OrderByDescending(e => e.SearchVector!.RankCoverDensity(EF.Functions.WebSearchToTsQuery("english", term)))
+ .ThenByDescending(e => e.SentAtUtc);
+ else
+ ranked = matched.OrderByDescending(e => e.SentAtUtc);
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.
+ // "Why this matched" ts_headline only for EXACT free-text hits (fuzzy/browse get no
+ // highlight — a fuzzy hit has no literal match to headline). Unconditional projections
+ // (no DB function in a ternary) keep EF translation unambiguous.
List items;
- if (hasFreeTextQuery)
+ if (hasFreeTextQuery && !fuzzy)
{
- // "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).
+ // Matched terms wrapped in U+E000/U+E001 sentinels (safe, non-HTML; see EmailSummaryDto).
var headlineOpts =
$"StartSel={(char)0xE000},StopSel={(char)0xE001},MaxWords=16,MinWords=5,ShortWord=2,HighlightAll=false";
items = await paged