Compare commits

...

2 Commits

Author SHA1 Message Date
cesnimda 18acd9f6e8 feat(search): 'why this matched' highlights (ts_headline)
CI / backend (pull_request) Successful in 49s
CI / frontend (pull_request) Successful in 13s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 51s
Slice 3 of search-core. Search results now show WHY they matched: a ts_headline
body fragment with matched terms highlighted. The API returns U+E000/U+E001
sentinels around matches (NOT HTML); the client tokenises them into escaped
<mark> React elements, so untrusted email content can never inject markup (no
dangerouslySetInnerHTML). Subtle accent tint. Only when there's a free-text query
(browse unchanged); projection branched (no DB function in a ternary) for
unambiguous EF translation.

Verified: build + all 40 tests; ts_headline output shape in raw Postgres; and the
ranked+headline EF query proven to translate + execute against a live Postgres via
a throwaway test (removed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:14:15 +02:00
cesnimda 9545f94461 feat(search): weight subject above body in the FTS vector
CI / backend (pull_request) Successful in 1m16s
CI / frontend (pull_request) Successful in 27s
Security / secrets (pull_request) Successful in 5s
Security / dependencies (pull_request) Successful in 1m14s
Slice 2 of search-core: the generated SearchVector now applies setweight 'A' to
the subject and 'B' to the body, so ts_rank_cd (added last slice) ranks a subject
match above a body-only mention -- what users expect. Adds an EF migration that
drops/recreates the stored generated column + GIN index (Npgsql emits this
correctly and transactionally).

Verified: the migration DDL was dry-run against the live staging Postgres
(schema + real data) inside a rolled-back transaction -- applies cleanly; and the
weighted ranking (subject 1.0 vs body 0.4 for the same term) was confirmed
directly in Postgres. Build + all 40 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:24:03 +02:00
8 changed files with 863 additions and 16 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(
@@ -0,0 +1,742 @@
// <auto-generated />
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("20260701192100_WeightSearchVectorSubjectBody")]
partial class WeightSearchVectorSubjectBody
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("InboxIntel.Domain.Entities.AnalyticsAggregate", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateOnly>("Day")
.HasColumnType("date");
b.Property<string>("HourHistogramJson")
.HasColumnType("text");
b.Property<int>("NewsletterCount")
.HasColumnType("integer");
b.Property<int>("TotalReceived")
.HasColumnType("integer");
b.Property<long>("TotalSizeBytes")
.HasColumnType("bigint");
b.Property<int>("TotalUnread")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<int>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("EmailId")
.HasColumnType("uuid");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("GmailAttachmentId")
.HasColumnType("text");
b.Property<string>("MimeType")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<long>("SizeBytes")
.HasColumnType("bigint");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("BodyText")
.HasColumnType("text");
b.Property<int>("Category")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("GmailMessageId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<bool>("HasAttachments")
.HasColumnType("boolean");
b.Property<bool>("HasListUnsubscribe")
.HasColumnType("boolean");
b.Property<bool>("IsImportant")
.HasColumnType("boolean");
b.Property<bool>("IsInInbox")
.HasColumnType("boolean");
b.Property<bool>("IsStarred")
.HasColumnType("boolean");
b.Property<bool>("IsTrashed")
.HasColumnType("boolean");
b.Property<bool>("IsUnread")
.HasColumnType("boolean");
b.Property<string>("ListUnsubscribeRaw")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset?>("ReceivedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<NpgsqlTsVector>("SearchVector")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("tsvector")
.HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"Subject\",'')), 'A') || setweight(to_tsvector('english', coalesce(\"BodyText\",'')), 'B')", true);
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("SentAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<long>("SizeEstimateBytes")
.HasColumnType("bigint");
b.Property<string>("Snippet")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("Subject")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<bool>("SupportsOneClickUnsubscribe")
.HasColumnType("boolean");
b.Property<Guid>("ThreadId")
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("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<Guid>("EmailId")
.HasColumnType("uuid");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ColorHex")
.HasColumnType("text");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("GmailLabelId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("EmailCount")
.HasColumnType("integer");
b.Property<bool>("IsBulkSender")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("FirstMessageUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("GmailThreadId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("LastMessageUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("MessageCount")
.HasColumnType("integer");
b.Property<string>("Snippet")
.HasColumnType("text");
b.Property<string>("Subject")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Address")
.IsRequired()
.HasMaxLength(320)
.HasColumnType("character varying(320)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<Guid>("DomainId")
.HasColumnType("uuid");
b.Property<int>("EmailCount")
.HasColumnType("integer");
b.Property<bool>("HasUnsubscribe")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LastReceivedUtc")
.HasColumnType("timestamp with time zone");
b.Property<long>("TotalSizeBytes")
.HasColumnType("bigint");
b.Property<int>("UnreadCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("CompletedUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("ConsecutiveFailures")
.HasColumnType("integer");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastError")
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<string>("LastHistoryId")
.HasColumnType("text");
b.Property<DateTimeOffset?>("LastSuccessfulSyncUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("LastSyncType")
.HasColumnType("integer");
b.Property<int>("MessagesProcessed")
.HasColumnType("integer");
b.Property<string>("ResumePageToken")
.HasColumnType("text");
b.Property<DateTimeOffset?>("StartedUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<int>("TotalMessagesEstimate")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<double>("Confidence")
.HasColumnType("double precision");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("EmailCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("LastAttemptUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Method")
.HasColumnType("integer");
b.Property<string>("ResultMessage")
.HasColumnType("text");
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("UnsubscribeTarget")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("AccessTokenExpiresAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<bool>("DigestEnabled")
.HasColumnType("boolean");
b.Property<string>("DisplayName")
.HasColumnType("text");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(320)
.HasColumnType("character varying(320)");
b.Property<byte[]>("EncryptedRefreshToken")
.HasColumnType("bytea");
b.Property<string>("GoogleSubjectId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset?>("LastDigestSentUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("LastLoginUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("PictureUrl")
.HasColumnType("text");
b.Property<DateTimeOffset?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("H")
.HasColumnType("integer");
b.Property<string>("SettingsJson")
.HasColumnType("text");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<bool>("Visible")
.HasColumnType("boolean");
b.Property<int>("W")
.HasColumnType("integer");
b.Property<string>("WidgetKey")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("X")
.HasColumnType("integer");
b.Property<int>("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
}
}
}
@@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore.Migrations;
using NpgsqlTypes;
#nullable disable
namespace InboxIntel.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class WeightSearchVectorSubjectBody : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<NpgsqlTsVector>(
name: "SearchVector",
table: "emails",
type: "tsvector",
nullable: true,
computedColumnSql: "setweight(to_tsvector('english', coalesce(\"Subject\",'')), 'A') || setweight(to_tsvector('english', coalesce(\"BodyText\",'')), 'B')",
stored: true,
oldClrType: typeof(NpgsqlTsVector),
oldType: "tsvector",
oldNullable: true,
oldComputedColumnSql: "to_tsvector('english', coalesce(\"Subject\",'') || ' ' || coalesce(\"BodyText\",''))",
oldStored: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<NpgsqlTsVector>(
name: "SearchVector",
table: "emails",
type: "tsvector",
nullable: true,
computedColumnSql: "to_tsvector('english', coalesce(\"Subject\",'') || ' ' || coalesce(\"BodyText\",''))",
stored: true,
oldClrType: typeof(NpgsqlTsVector),
oldType: "tsvector",
oldNullable: true,
oldComputedColumnSql: "setweight(to_tsvector('english', coalesce(\"Subject\",'')), 'A') || setweight(to_tsvector('english', coalesce(\"BodyText\",'')), 'B')",
oldStored: true);
}
}
}
@@ -160,7 +160,7 @@ namespace InboxIntel.Infrastructure.Migrations
b.Property<NpgsqlTsVector>("SearchVector") b.Property<NpgsqlTsVector>("SearchVector")
.ValueGeneratedOnAddOrUpdate() .ValueGeneratedOnAddOrUpdate()
.HasColumnType("tsvector") .HasColumnType("tsvector")
.HasComputedColumnSql("to_tsvector('english', coalesce(\"Subject\",'') || ' ' || coalesce(\"BodyText\",''))", true); .HasComputedColumnSql("setweight(to_tsvector('english', coalesce(\"Subject\",'')), 'A') || setweight(to_tsvector('english', coalesce(\"BodyText\",'')), 'B')", true);
b.Property<Guid>("SenderId") b.Property<Guid>("SenderId")
.HasColumnType("uuid"); .HasColumnType("uuid");
@@ -59,15 +59,17 @@ public class AppDbContext : DbContext, IAppDbContext
modelBuilder.Entity<UnsubscribeItem>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId); modelBuilder.Entity<UnsubscribeItem>().HasQueryFilter(e => CurrentUserId == Guid.Empty || e.UserId == CurrentUserId);
// PostgreSQL full-text search: generated tsvector over subject + body with a // PostgreSQL full-text search: generated tsvector over subject + body with a
// GIN index, maintained by the DB and read-only in code. The tsvector type is // GIN index, maintained by the DB and read-only in code. Subject is weighted 'A'
// Npgsql-only, so map it only for relational providers and ignore it otherwise // and body 'B' so ts_rank_cd ranks a subject match above a body-only mention. The
// (e.g. the InMemory provider used by tests). Production behaviour is unchanged. // tsvector type is Npgsql-only, so map it only for relational providers and ignore
// it otherwise (e.g. the InMemory provider used by tests).
if (Database.IsRelational()) if (Database.IsRelational())
{ {
modelBuilder.Entity<Email>().Property(e => e.SearchVector) modelBuilder.Entity<Email>().Property(e => e.SearchVector)
.HasColumnType("tsvector") .HasColumnType("tsvector")
.HasComputedColumnSql( .HasComputedColumnSql(
"to_tsvector('english', coalesce(\"Subject\",'') || ' ' || coalesce(\"BodyText\",''))", "setweight(to_tsvector('english', coalesce(\"Subject\",'')), 'A') || " +
"setweight(to_tsvector('english', coalesce(\"BodyText\",'')), 'B')",
stored: true); stored: true);
modelBuilder.Entity<Email>().HasIndex(e => e.SearchVector).HasMethod("GIN"); modelBuilder.Entity<Email>().HasIndex(e => e.SearchVector).HasMethod("GIN");
} }
@@ -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>
{ {