9bbab5d32a
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
123 lines
6.1 KiB
C#
123 lines
6.1 KiB
C#
using InboxIntel.Application.Abstractions;
|
|
using InboxIntel.Application.Common;
|
|
using InboxIntel.Application.DTOs;
|
|
using InboxIntel.Domain.Enums;
|
|
using InboxIntel.Infrastructure.Persistence;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace InboxIntel.Infrastructure.Search;
|
|
|
|
/// <summary>
|
|
/// Structured + full-text search. Structured filters compose as SQL WHERE
|
|
/// clauses; free text uses PostgreSQL FTS via the generated SearchVector column
|
|
/// (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.
|
|
/// </summary>
|
|
public class SearchService : ISearchService
|
|
{
|
|
private readonly AppDbContext _db;
|
|
public SearchService(AppDbContext db) => _db = db;
|
|
|
|
public async Task<PagedResult<EmailSummaryDto>> SearchAsync(Guid userId, SearchRequestDto r, CancellationToken ct = default)
|
|
{
|
|
// NOTE: pagination is clamped at the user-facing trust boundary (SearchController),
|
|
// NOT here, so internal callers (e.g. CleanupService target resolution, which
|
|
// legitimately requests large pages) are unaffected. See V-10 fix.
|
|
var q = _db.Emails.AsNoTracking().Where(e => e.UserId == userId);
|
|
|
|
if (!string.IsNullOrWhiteSpace(r.Sender))
|
|
q = q.Where(e => e.Sender!.Address.Contains(r.Sender) || e.Sender.DisplayName!.Contains(r.Sender));
|
|
if (!string.IsNullOrWhiteSpace(r.Domain))
|
|
q = q.Where(e => e.Sender!.Domain!.Name == r.Domain);
|
|
if (r.From is { } from)
|
|
q = q.Where(e => e.SentAtUtc >= new DateTimeOffset(from.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero));
|
|
if (r.To is { } to)
|
|
q = q.Where(e => e.SentAtUtc <= new DateTimeOffset(to.ToDateTime(TimeOnly.MaxValue), TimeSpan.Zero));
|
|
if (r.IsUnread is { } unread)
|
|
q = q.Where(e => e.IsUnread == unread);
|
|
if (r.HasAttachments is { } att)
|
|
q = q.Where(e => e.HasAttachments == att);
|
|
if (r.IsInInbox is { } inbox)
|
|
q = q.Where(e => e.IsInInbox == inbox);
|
|
if (r.IsStarred is { } starred)
|
|
q = q.Where(e => e.IsStarred == starred);
|
|
if (r.IsTrashed is { } trashed)
|
|
q = q.Where(e => e.IsTrashed == trashed);
|
|
if (r.MinSizeBytes is { } minSize)
|
|
q = q.Where(e => e.SizeEstimateBytes >= minSize);
|
|
if (!string.IsNullOrWhiteSpace(r.Category) && Enum.TryParse<EmailCategory>(r.Category, true, out var cat))
|
|
q = q.Where(e => e.Category == cat);
|
|
if (!string.IsNullOrWhiteSpace(r.GmailLabel))
|
|
{
|
|
var labelId = await _db.Labels
|
|
.Where(l => l.UserId == userId && l.GmailLabelId == r.GmailLabel.ToUpperInvariant())
|
|
.Select(l => (Guid?)l.Id)
|
|
.FirstOrDefaultAsync(ct);
|
|
if (labelId.HasValue)
|
|
q = q.Where(e => e.EmailLabels.Any(el => el.LabelId == labelId.Value));
|
|
else
|
|
return new PagedResult<EmailSummaryDto> { Items = [], Page = r.Page, PageSize = r.PageSize, TotalCount = 0 };
|
|
}
|
|
|
|
// 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);
|
|
|
|
// 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 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<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(
|
|
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<EmailSummaryDto>
|
|
{
|
|
Items = items,
|
|
Page = r.Page,
|
|
PageSize = r.PageSize,
|
|
TotalCount = total
|
|
};
|
|
}
|
|
}
|