using InboxIntel.Application.Abstractions; using InboxIntel.Application.DTOs; using InboxIntel.Domain.Enums; using InboxIntel.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; namespace InboxIntel.Infrastructure.Analytics; public class AnalyticsService : IAnalyticsService { private readonly AppDbContext _db; public AnalyticsService(AppDbContext db) => _db = db; public async Task GetDashboardAsync(Guid userId, CancellationToken ct = default) { var health = await GetInboxHealthAsync(userId, ct); var top = await GetTopSendersAsync(userId, 10, ct); var volume = await GetVolumeOverTimeAsync(userId, 90, ct); var attachments = await GetAttachmentBreakdownAsync(userId, ct); return new DashboardSummaryDto( health, health.TotalEmails, health.UnreadEmails, top, volume, attachments, health.EstimatedStorageBytes); } public async Task GetInboxHealthAsync(Guid userId, CancellationToken ct = default) { var emails = _db.Emails.Where(e => e.UserId == userId); var total = await emails.CountAsync(ct); var unread = await emails.CountAsync(e => e.IsUnread, ct); var newsletters = await emails.CountAsync(e => e.Category == EmailCategory.Newsletter, ct); var storage = total == 0 ? 0 : await emails.SumAsync(e => e.SizeEstimateBytes, ct); var safeToUnsub = await _db.UnsubscribeItems.CountAsync(u => u.UserId == userId, ct); // Health score: penalise high unread ratio and newsletter clutter. var unreadRatio = total == 0 ? 0 : (double)unread / total; var newsletterRatio = total == 0 ? 0 : (double)newsletters / total; var score = (int)Math.Round(100 * (1 - 0.6 * unreadRatio - 0.4 * newsletterRatio)); score = Math.Clamp(score, 0, 100); var grade = score switch { >= 90 => "A", >= 80 => "B", >= 70 => "C", >= 60 => "D", _ => "F" }; var recs = new List(); if (unreadRatio > 0.3) recs.Add($"You have {unread:N0} unread emails. Consider bulk-marking older ones as read."); if (newsletters > 50) recs.Add($"{newsletters:N0} newsletters detected. Review the safe-to-unsubscribe list."); if (storage > 1_000_000_000) recs.Add("Inbox storage exceeds 1 GB. Clean up large attachments."); if (recs.Count == 0) recs.Add("Your inbox is in good shape. Keep it up!"); return new InboxHealthDto(score, grade, total, unread, newsletters, safeToUnsub, storage, recs); } public async Task> GetTopSendersAsync(Guid userId, int take = 20, CancellationToken ct = default) { return await _db.Senders .Where(s => s.UserId == userId) .OrderByDescending(s => s.EmailCount) .Take(take) .Select(s => new SenderStatDto( s.Id, s.Address, s.DisplayName, s.Domain!.Name, s.EmailCount, s.UnreadCount, s.TotalSizeBytes, s.HasUnsubscribe, s.LastReceivedUtc)) .ToListAsync(ct); } public async Task> GetVolumeOverTimeAsync(Guid userId, int days = 90, CancellationToken ct = default) { var since = DateTimeOffset.UtcNow.AddDays(-days); var raw = await _db.Emails .Where(e => e.UserId == userId && e.SentAtUtc >= since) .GroupBy(e => e.SentAtUtc.Date) .Select(g => new { Day = g.Key, Count = g.Count() }) .ToListAsync(ct); return raw.OrderBy(x => x.Day) .Select(x => new TimeSeriesPointDto(DateOnly.FromDateTime(x.Day), x.Count)) .ToList(); } public async Task> GetCategoryHeatmapAsync(Guid userId, CancellationToken ct = default) { var raw = await _db.Emails .Where(e => e.UserId == userId) .Select(e => new { e.SentAtUtc, e.Category }) .ToListAsync(ct); return raw .GroupBy(x => new { x.Category, Dow = (int)x.SentAtUtc.DayOfWeek }) .Select(g => new CategoryHeatmapCellDto(g.Key.Category.ToString(), g.Key.Dow, g.Count())) .ToList(); } public async Task> GetAttachmentBreakdownAsync(Guid userId, CancellationToken ct = default) { var raw = await _db.Attachments .Where(a => a.UserId == userId) .Select(a => new { a.MimeType, a.SizeBytes }) .ToListAsync(ct); return raw .GroupBy(a => Bucket(a.MimeType)) .Select(g => new AttachmentBreakdownDto(g.Key, g.Sum(x => x.SizeBytes), g.Count())) .OrderByDescending(x => x.TotalBytes) .ToList(); } private static string Bucket(string? mime) => mime switch { null => "other", var m when m.StartsWith("image/") => "images", var m when m.StartsWith("video/") => "video", var m when m.StartsWith("audio/") => "audio", var m when m.Contains("pdf") => "pdf", var m when m.Contains("zip") || m.Contains("compressed") => "archives", var m when m.Contains("spreadsheet") || m.Contains("excel") => "spreadsheets", var m when m.Contains("word") || m.Contains("document") => "documents", _ => "other" }; public async Task GetSidebarCountsAsync(Guid userId, CancellationToken ct = default) { var emails = _db.Emails.Where(e => e.UserId == userId); var allMail = await emails.CountAsync(ct); var inbox = await emails.CountAsync(e => e.IsInInbox, ct); var unread = await emails.CountAsync(e => e.IsUnread, ct); var starred = await emails.CountAsync(e => e.IsStarred, ct); var trash = await emails.CountAsync(e => e.IsTrashed, ct); var large = await emails.CountAsync(e => e.SizeEstimateBytes > 5_000_000, ct); var cutoff = DateTimeOffset.UtcNow.AddYears(-1); var old = await emails.CountAsync(e => e.SentAtUtc < cutoff, ct); // Label-backed counts (SENT / DRAFT / SPAM are system Gmail labels) async Task LabelCount(string gmailId) { var labelId = await _db.Labels .Where(l => l.UserId == userId && l.GmailLabelId == gmailId) .Select(l => (Guid?)l.Id) .FirstOrDefaultAsync(ct); return labelId.HasValue ? await _db.EmailLabels.CountAsync(el => el.LabelId == labelId.Value, ct) : 0; } var sent = await LabelCount("SENT"); var drafts = await LabelCount("DRAFT"); var spam = await LabelCount("SPAM"); // Category → smart-folder slug mapping var catCounts = await emails .GroupBy(e => e.Category) .Select(g => new { Cat = g.Key, N = g.Count() }) .ToListAsync(ct); int Cat(EmailCategory c) => catCounts.FirstOrDefault(x => x.Cat == c)?.N ?? 0; var smartFolders = new Dictionary { ["automated"] = Cat(EmailCategory.Notification), ["finance"] = Cat(EmailCategory.Finance), ["social"] = Cat(EmailCategory.Social), ["shopping"] = Cat(EmailCategory.Shopping) + Cat(EmailCategory.Promotional), ["noreply"] = Cat(EmailCategory.Notification), ["gaming"] = Cat(EmailCategory.Gaming), ["sales"] = Cat(EmailCategory.SeasonalSales), ["ridesharing"] = Cat(EmailCategory.RideSharing), ["food"] = Cat(EmailCategory.FoodDelivery), ["wellness"] = Cat(EmailCategory.Wellness), // New categories ["travel"] = Cat(EmailCategory.Travel), ["subscriptions"] = Cat(EmailCategory.Subscriptions), ["parcels"] = Cat(EmailCategory.Parcels), ["recruitment"] = Cat(EmailCategory.Recruitment), ["events"] = Cat(EmailCategory.Events), ["security"] = Cat(EmailCategory.SecurityAlerts), ["healthcare"] = Cat(EmailCategory.Healthcare), ["education"] = Cat(EmailCategory.Education), ["news"] = Cat(EmailCategory.NewsMedia), ["property"] = Cat(EmailCategory.PropertyUtilities), ["charity"] = Cat(EmailCategory.Charity), ["government"] = Cat(EmailCategory.Government), ["crypto"] = Cat(EmailCategory.CryptoInvesting), ["family"] = Cat(EmailCategory.FamilySchool), }; return new SidebarCountsDto(inbox, allMail, unread, starred, sent, drafts, trash, spam, large, old, smartFolders); } public async Task RefreshAggregatesAsync(Guid userId, CancellationToken ct = default) { var since = DateTimeOffset.UtcNow.AddDays(-365); var perDay = await _db.Emails .Where(e => e.UserId == userId && e.SentAtUtc >= since) .Select(e => new { e.SentAtUtc, e.IsUnread, e.Category, e.HasAttachments, e.SizeEstimateBytes }) .ToListAsync(ct); var grouped = perDay.GroupBy(e => DateOnly.FromDateTime(e.SentAtUtc.UtcDateTime.Date)); foreach (var g in grouped) { var existing = await _db.AnalyticsAggregates.FirstOrDefaultAsync(a => a.UserId == userId && a.Day == g.Key, ct); var agg = existing ?? new Domain.Entities.AnalyticsAggregate { UserId = userId, Day = g.Key }; agg.TotalReceived = g.Count(); agg.TotalUnread = g.Count(x => x.IsUnread); agg.NewsletterCount = g.Count(x => x.Category == EmailCategory.Newsletter); agg.WithAttachments = g.Count(x => x.HasAttachments); agg.TotalSizeBytes = g.Sum(x => x.SizeEstimateBytes); if (existing is null) _db.AnalyticsAggregates.Add(agg); } await _db.SaveChangesAsync(ct); } }