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; /// /// 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. /// public class SearchService : ISearchService { private readonly AppDbContext _db; public SearchService(AppDbContext db) => _db = db; public async Task> 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(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 { 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 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 // 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 { Items = items, Page = r.Page, PageSize = r.PageSize, TotalCount = total }; } }