diff --git a/src/InboxIntel.Infrastructure/Search/SearchService.cs b/src/InboxIntel.Infrastructure/Search/SearchService.cs
index 601f8ce..178b3e4 100644
--- a/src/InboxIntel.Infrastructure/Search/SearchService.cs
+++ b/src/InboxIntel.Infrastructure/Search/SearchService.cs
@@ -10,7 +10,9 @@ namespace InboxIntel.Infrastructure.Search;
///
/// Structured + full-text search. Structured filters compose as SQL WHERE
/// clauses; free text uses PostgreSQL FTS via the generated SearchVector column
-/// (EF.Functions.ToTsVector/Matches translate to @@ / to_tsquery).
+/// (websearch_to_tsquery / @@ / ts_rank_cd). Results are relevance-ranked when a
+/// free-text query is present, date-ordered otherwise. See
+/// docs/discovery/05-search-redesign.md for the full multi-layer search design.
///
public class SearchService : ISearchService
{
@@ -58,16 +60,24 @@ public class SearchService : ISearchService
return new PagedResult { Items = [], Page = r.Page, PageSize = r.PageSize, TotalCount = 0 };
}
- if (!string.IsNullOrWhiteSpace(r.Query))
- {
- // PostgreSQL full-text match against the generated tsvector.
- var term = r.Query.Trim();
- q = q.Where(e => e.SearchVector!.Matches(EF.Functions.PlainToTsQuery("english", term)));
- }
+ // websearch_to_tsquery (vs. plainto_tsquery) understands quotes ("exact phrase"),
+ // 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 items = await q
- .OrderByDescending(e => e.SentAtUtc)
+
+ // 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 items = await ranked
.Skip((r.Page - 1) * r.PageSize)
.Take(r.PageSize)
.Select(e => new EmailSummaryDto(
diff --git a/tests/InboxIntel.IntegrationTests/SearchRankingTests.cs b/tests/InboxIntel.IntegrationTests/SearchRankingTests.cs
new file mode 100644
index 0000000..9706171
--- /dev/null
+++ b/tests/InboxIntel.IntegrationTests/SearchRankingTests.cs
@@ -0,0 +1,53 @@
+using FluentAssertions;
+using InboxIntel.Application.Abstractions;
+using InboxIntel.Application.Search;
+using InboxIntel.Domain.Entities;
+using InboxIntel.Infrastructure.Persistence;
+using InboxIntel.Infrastructure.Search;
+using Microsoft.EntityFrameworkCore;
+using Xunit;
+
+namespace InboxIntel.IntegrationTests;
+
+///
+/// SearchService now relevance-ranks (ts_rank_cd via RankCoverDensity) when a free-text
+/// query is present, falling back to date order otherwise. The ranked path requires a
+/// live PostgreSQL instance to exercise (EF's InMemory provider cannot translate
+/// websearch_to_tsquery/RankCoverDensity) — this project deliberately avoids a hard
+/// Postgres/Testcontainers dependency for tests (see AuthEndpointsTests.TestAppFactory),
+/// so ranking correctness itself is verified manually/in staging, not here. What IS
+/// covered: the date-order fallback, which is plain LINQ and must not regress.
+///
+public class SearchRankingTests
+{
+ private sealed class FakeCurrentUser : ICurrentUser
+ {
+ public Guid UserId { get; set; }
+ public bool IsAuthenticated => UserId != Guid.Empty;
+ }
+
+ [Fact]
+ public async Task No_query_falls_back_to_date_descending_order()
+ {
+ var user = Guid.NewGuid();
+ var opts = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(nameof(No_query_falls_back_to_date_descending_order)).Options;
+
+ using (var seed = new AppDbContext(opts, new FakeCurrentUser()))
+ {
+ var sender = new Sender { UserId = user, Address = "sender@example.com", DisplayName = "Sender" };
+ seed.Senders.Add(sender);
+ var baseline = DateTimeOffset.UtcNow;
+ seed.Emails.Add(new Email { UserId = user, GmailMessageId = "oldest", SentAtUtc = baseline.AddDays(-2), Sender = sender });
+ seed.Emails.Add(new Email { UserId = user, GmailMessageId = "newest", SentAtUtc = baseline, Sender = sender });
+ seed.Emails.Add(new Email { UserId = user, GmailMessageId = "middle", SentAtUtc = baseline.AddDays(-1), Sender = sender });
+ await seed.SaveChangesAsync();
+ }
+
+ using var ctx = new AppDbContext(opts, new FakeCurrentUser { UserId = user });
+ var request = GmailQueryParser.Parse(null, page: 1, pageSize: 50);
+ var result = await new SearchService(ctx).SearchAsync(user, request);
+
+ result.Items.Select(i => i.GmailMessageId).Should().ContainInOrder("newest", "middle", "oldest");
+ }
+}