chore: init project

This commit is contained in:
cesnimda
2026-06-30 15:53:32 +02:00
commit f43ef5f945
94 changed files with 4405 additions and 0 deletions
@@ -0,0 +1,85 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Domain.Enums;
using InboxIntel.Infrastructure.Configuration;
using Microsoft.Extensions.Options;
using System.Net.Http.Json;
using System.Text.Json;
namespace InboxIntel.Infrastructure.Ai;
/// <summary>No-op provider used when AI is disabled. Returns empty completions.</summary>
public class NullAiProvider : IAiProvider
{
public AiProviderMode Mode => AiProviderMode.Disabled;
public Task<string> CompleteAsync(string systemPrompt, string userPrompt, CancellationToken ct = default)
=> Task.FromResult(string.Empty);
}
/// <summary>Local LLM via Ollama's /api/chat endpoint.</summary>
public class OllamaProvider : IAiProvider
{
private readonly HttpClient _http;
private readonly AiOptions _options;
public OllamaProvider(IHttpClientFactory factory, IOptions<AiOptions> options)
{
_options = options.Value;
_http = factory.CreateClient("ollama");
_http.BaseAddress = new Uri(_options.OllamaBaseUrl);
}
public AiProviderMode Mode => AiProviderMode.LocalOllama;
public async Task<string> CompleteAsync(string systemPrompt, string userPrompt, CancellationToken ct = default)
{
var payload = new
{
model = _options.OllamaModel,
stream = false,
messages = new[]
{
new { role = "system", content = systemPrompt },
new { role = "user", content = userPrompt }
}
};
var resp = await _http.PostAsJsonAsync("/api/chat", payload, ct);
resp.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct));
return doc.RootElement.GetProperty("message").GetProperty("content").GetString() ?? string.Empty;
}
}
/// <summary>Cloud LLM via the OpenAI Chat Completions API (optional).</summary>
public class OpenAiProvider : IAiProvider
{
private readonly HttpClient _http;
private readonly AiOptions _options;
public OpenAiProvider(IHttpClientFactory factory, IOptions<AiOptions> options)
{
_options = options.Value;
_http = factory.CreateClient("openai");
_http.BaseAddress = new Uri("https://api.openai.com");
_http.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _options.OpenAiApiKey);
}
public AiProviderMode Mode => AiProviderMode.CloudOpenAi;
public async Task<string> CompleteAsync(string systemPrompt, string userPrompt, CancellationToken ct = default)
{
var payload = new
{
model = _options.OpenAiModel,
messages = new[]
{
new { role = "system", content = systemPrompt },
new { role = "user", content = userPrompt }
}
};
var resp = await _http.PostAsJsonAsync("/v1/chat/completions", payload, ct);
resp.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct));
return doc.RootElement.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString() ?? string.Empty;
}
}
@@ -0,0 +1,85 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Application.DTOs;
using InboxIntel.Domain.Enums;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace InboxIntel.Infrastructure.Ai;
/// <summary>
/// High-level AI features built on top of an <see cref="IAiProvider"/>.
/// SAFETY: this service only ever READS data and returns suggestions. It never
/// archives, deletes, labels, or unsubscribes - destructive actions always go
/// through the cleanup/unsubscribe services after explicit user confirmation.
/// </summary>
public class AiService : IAiService
{
private readonly IAiProvider _provider;
private readonly AppDbContext _db;
public AiService(IAiProvider provider, AppDbContext db)
{
_provider = provider;
_db = db;
}
public bool IsEnabled => _provider.Mode != AiProviderMode.Disabled;
public async Task<AiClassificationDto> ClassifyAsync(Guid userId, Guid emailId, CancellationToken ct = default)
{
var email = await _db.Emails.FirstOrDefaultAsync(e => e.UserId == userId && e.Id == emailId, ct)
?? throw new InvalidOperationException("Email not found.");
if (!IsEnabled) return new AiClassificationDto(emailId, email.Category, 0);
var prompt = $"Classify this email into one of: Personal, Newsletter, Finance, Spam, Promotional, Social, Notification.\n" +
$"Subject: {email.Subject}\nFrom: {email.SenderId}\nSnippet: {email.Snippet}\n" +
$"Reply with only the single category word.";
var raw = await _provider.CompleteAsync("You are an email classifier.", prompt, ct);
var category = Enum.TryParse<EmailCategory>(raw.Trim(), true, out var c) ? c : email.Category;
return new AiClassificationDto(emailId, category, raw.Length > 0 ? 0.8 : 0);
}
public async Task<InboxSummaryDto> SummarizeInboxAsync(Guid userId, CancellationToken ct = default)
{
if (!IsEnabled) return new InboxSummaryDto("AI is disabled.", Array.Empty<string>());
var recent = await _db.Emails.AsNoTracking()
.Where(e => e.UserId == userId && e.IsUnread)
.OrderByDescending(e => e.SentAtUtc).Take(50)
.Select(e => $"- {e.Subject} ({e.Sender!.Address})")
.ToListAsync(ct);
var summary = await _provider.CompleteAsync(
"You summarise an email inbox concisely.",
"Summarise these unread emails in 3-4 sentences and list up to 5 highlights:\n" + string.Join("\n", recent), ct);
return new InboxSummaryDto(summary, recent.Take(5).ToList());
}
public async Task<IReadOnlyList<AiCleanupSuggestionDto>> SuggestCleanupAsync(Guid userId, CancellationToken ct = default)
{
// Data-driven suggestions (work even without AI); AI can enrich the rationale.
var noisy = await _db.Senders.AsNoTracking()
.Where(s => s.UserId == userId && s.HasUnsubscribe)
.OrderByDescending(s => s.EmailCount).Take(5)
.Select(s => new { s.Address, s.EmailCount }).ToListAsync(ct);
return noisy.Select(s => new AiCleanupSuggestionDto(
$"Archive newsletters from {s.Address}",
$"{s.EmailCount} emails from this sender carry an unsubscribe header and are likely low-value.",
CleanupActionType.Archive,
$"from:{s.Address}",
s.EmailCount)).ToList();
}
public async Task<GeneratedQueryDto> GenerateQueryAsync(Guid userId, string naturalLanguage, CancellationToken ct = default)
{
if (!IsEnabled)
return new GeneratedQueryDto(naturalLanguage, naturalLanguage);
var gmailQuery = await _provider.CompleteAsync(
"Convert natural language into a Gmail search query using operators like from:, after:, before:, is:unread, has:attachment. Reply with only the query.",
naturalLanguage, ct);
return new GeneratedQueryDto(naturalLanguage, gmailQuery.Trim());
}
}
@@ -0,0 +1,140 @@
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<DashboardSummaryDto> 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 heatmap = await GetHeatmapAsync(userId, ct);
var attachments = await GetAttachmentBreakdownAsync(userId, ct);
return new DashboardSummaryDto(
health, health.TotalEmails, health.UnreadEmails, top, volume, heatmap, attachments,
health.EstimatedStorageBytes);
}
public async Task<InboxHealthDto> 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<string>();
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<IReadOnlyList<SenderStatDto>> 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<IReadOnlyList<TimeSeriesPointDto>> 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<IReadOnlyList<HeatmapCellDto>> GetHeatmapAsync(Guid userId, CancellationToken ct = default)
{
var raw = await _db.Emails
.Where(e => e.UserId == userId)
.Select(e => new { e.SentAtUtc })
.ToListAsync(ct);
return raw
.GroupBy(x => new { Dow = (int)x.SentAtUtc.DayOfWeek, Hour = x.SentAtUtc.Hour })
.Select(g => new HeatmapCellDto(g.Key.Dow, g.Key.Hour, g.Count()))
.ToList();
}
public async Task<IReadOnlyList<AttachmentBreakdownDto>> 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 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);
}
}
@@ -0,0 +1,118 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Application.Common;
using InboxIntel.Application.DTOs;
using InboxIntel.Application.Search;
using InboxIntel.Domain.Entities;
using InboxIntel.Domain.Enums;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace InboxIntel.Infrastructure.Cleanup;
/// <summary>
/// Safe bulk cleanup. Every destructive call goes through Preview first and
/// requires an explicit Confirmed flag (enforced again here as defence in
/// depth, in addition to FluentValidation). Local state is updated to mirror
/// the Gmail mutation so the dashboard stays consistent.
/// </summary>
public class CleanupService : ICleanupService
{
private readonly AppDbContext _db;
private readonly IGmailService _gmail;
private readonly ISearchService _search;
private readonly ILogger<CleanupService> _logger;
public CleanupService(AppDbContext db, IGmailService gmail, ISearchService search, ILogger<CleanupService> logger)
{
_db = db;
_gmail = gmail;
_search = search;
_logger = logger;
}
public async Task<CleanupPreviewDto> PreviewAsync(Guid userId, CleanupRequestDto request, CancellationToken ct = default)
{
var emails = await ResolveTargetsAsync(userId, request, ct);
var sample = emails.Take(25).Select(e => new EmailSummaryDto(
e.Id, e.GmailMessageId, e.Subject, e.Snippet, e.Sender!.Address, e.Sender.DisplayName,
e.SentAtUtc, e.IsUnread, e.HasAttachments, e.SizeEstimateBytes, e.Category)).ToList();
return new CleanupPreviewDto(request.Action, emails.Count, emails.Sum(e => e.SizeEstimateBytes), sample);
}
public async Task<Result<CleanupResultDto>> ExecuteAsync(Guid userId, CleanupRequestDto request, CancellationToken ct = default)
{
// Safety: destructive actions must be confirmed.
if (request.Action is CleanupActionType.Trash or CleanupActionType.HardDelete && !request.Confirmed)
return Result<CleanupResultDto>.Failure("Destructive actions require explicit confirmation.");
var emails = await ResolveTargetsAsync(userId, request, ct);
if (emails.Count == 0)
return Result<CleanupResultDto>.Success(new CleanupResultDto(request.Action, 0, 0, Array.Empty<string>()));
var gmailIds = emails.Select(e => e.GmailMessageId).ToList();
var errors = new List<string>();
try
{
switch (request.Action)
{
case CleanupActionType.Archive:
await _gmail.BatchModifyAsync(userId, gmailIds, Array.Empty<string>(), new[] { "INBOX" }, ct);
emails.ForEach(e => e.IsInInbox = false);
break;
case CleanupActionType.Trash:
await _gmail.BatchTrashAsync(userId, gmailIds, ct);
emails.ForEach(e => e.IsTrashed = true);
break;
case CleanupActionType.HardDelete:
await _gmail.BatchDeleteAsync(userId, gmailIds, ct);
_db.Emails.RemoveRange(emails);
break;
case CleanupActionType.MarkRead:
await _gmail.BatchModifyAsync(userId, gmailIds, Array.Empty<string>(), new[] { "UNREAD" }, ct);
emails.ForEach(e => e.IsUnread = false);
break;
case CleanupActionType.MarkUnread:
await _gmail.BatchModifyAsync(userId, gmailIds, new[] { "UNREAD" }, Array.Empty<string>(), ct);
emails.ForEach(e => e.IsUnread = true);
break;
case CleanupActionType.AddLabel:
await _gmail.BatchModifyAsync(userId, gmailIds, new[] { request.LabelId! }, Array.Empty<string>(), ct);
break;
case CleanupActionType.RemoveLabel:
await _gmail.BatchModifyAsync(userId, gmailIds, Array.Empty<string>(), new[] { request.LabelId! }, ct);
break;
}
await _db.SaveChangesAsync(ct);
return Result<CleanupResultDto>.Success(new CleanupResultDto(request.Action, emails.Count, 0, errors));
}
catch (Exception ex)
{
_logger.LogError(ex, "Cleanup action {Action} failed for user {UserId}", request.Action, userId);
errors.Add(ex.Message);
return Result<CleanupResultDto>.Failure(ex.Message);
}
}
private async Task<List<Email>> ResolveTargetsAsync(Guid userId, CleanupRequestDto request, CancellationToken ct)
{
if (request.EmailIds is { Count: > 0 })
{
return await _db.Emails.Include(e => e.Sender)
.Where(e => e.UserId == userId && request.EmailIds.Contains(e.Id))
.ToListAsync(ct);
}
if (!string.IsNullOrWhiteSpace(request.Query))
{
var parsed = GmailQueryParser.Parse(request.Query, page: 1, pageSize: 10_000);
var ids = (await _search.SearchAsync(userId, parsed, ct)).Items.Select(i => i.Id).ToList();
return await _db.Emails.Include(e => e.Sender)
.Where(e => e.UserId == userId && ids.Contains(e.Id)).ToListAsync(ct);
}
return new List<Email>();
}
}
@@ -0,0 +1,136 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Application.Common;
using InboxIntel.Application.DTOs;
using InboxIntel.Domain.Entities;
using InboxIntel.Domain.Enums;
using InboxIntel.Infrastructure.Gmail;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace InboxIntel.Infrastructure.Cleanup;
/// <summary>
/// Detects unsubscribe opportunities from List-Unsubscribe headers, groups them
/// per sender, and processes a confirmed queue. HTTP one-click targets are
/// POSTed; mailto targets are surfaced to the user (we never auto-send mail).
/// </summary>
public class UnsubscribeService : IUnsubscribeService
{
private readonly AppDbContext _db;
private readonly IHttpClientFactory _httpFactory;
private readonly ILogger<UnsubscribeService> _logger;
public UnsubscribeService(AppDbContext db, IHttpClientFactory httpFactory, ILogger<UnsubscribeService> logger)
{
_db = db;
_httpFactory = httpFactory;
_logger = logger;
}
public async Task DetectAsync(Guid userId, CancellationToken ct = default)
{
// Latest unsubscribe-bearing email per sender.
var candidates = await _db.Emails
.Where(e => e.UserId == userId && e.HasListUnsubscribe)
.GroupBy(e => e.SenderId)
.Select(g => new
{
SenderId = g.Key,
Count = g.Count(),
Raw = g.OrderByDescending(e => e.SentAtUtc).Select(e => e.ListUnsubscribeRaw).FirstOrDefault(),
OneClick = g.Any(e => e.SupportsOneClickUnsubscribe)
})
.ToListAsync(ct);
foreach (var c in candidates)
{
var target = GmailMessageParser.ExtractUnsubscribeTarget(c.Raw);
var method = target is null ? UnsubscribeMethod.None
: target.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase) ? UnsubscribeMethod.MailTo
: c.OneClick ? UnsubscribeMethod.OneClickPost
: UnsubscribeMethod.HttpLink;
var item = await _db.UnsubscribeItems.FirstOrDefaultAsync(u => u.UserId == userId && u.SenderId == c.SenderId, ct);
if (item is null)
{
item = new UnsubscribeItem { UserId = userId, SenderId = c.SenderId };
_db.UnsubscribeItems.Add(item);
}
item.Method = method;
item.UnsubscribeTarget = target;
item.EmailCount = c.Count;
if (item.Status == default) item.Status = UnsubscribeStatus.Detected;
}
await _db.SaveChangesAsync(ct);
}
public async Task<IReadOnlyList<UnsubscribeItemDto>> GetSafeToUnsubscribeAsync(Guid userId, CancellationToken ct = default)
{
return await _db.UnsubscribeItems
.Where(u => u.UserId == userId && u.Method != UnsubscribeMethod.None)
.OrderByDescending(u => u.EmailCount)
.Select(u => new UnsubscribeItemDto(
u.Id, u.Sender!.Address, u.Sender.Domain!.Name, u.Method, u.Status, u.EmailCount, u.UnsubscribeTarget))
.ToListAsync(ct);
}
public async Task<Result<CleanupResultDto>> ProcessQueueAsync(Guid userId, UnsubscribeRequestDto request, CancellationToken ct = default)
{
if (!request.Confirmed)
return Result<CleanupResultDto>.Failure("Unsubscribe actions require explicit confirmation.");
var items = await _db.UnsubscribeItems
.Where(u => u.UserId == userId && request.ItemIds.Contains(u.Id))
.ToListAsync(ct);
var http = _httpFactory.CreateClient("unsubscribe");
int ok = 0, fail = 0;
var errors = new List<string>();
foreach (var item in items)
{
item.LastAttemptUtc = DateTimeOffset.UtcNow;
try
{
switch (item.Method)
{
case UnsubscribeMethod.OneClickPost:
var post = await http.PostAsync(item.UnsubscribeTarget,
new StringContent("List-Unsubscribe=One-Click"), ct);
SetResult(item, post.IsSuccessStatusCode);
if (post.IsSuccessStatusCode) ok++; else fail++;
break;
case UnsubscribeMethod.HttpLink:
var get = await http.GetAsync(item.UnsubscribeTarget, ct);
SetResult(item, get.IsSuccessStatusCode);
if (get.IsSuccessStatusCode) ok++; else fail++;
break;
case UnsubscribeMethod.MailTo:
// We never auto-send email; flag for the user to action.
item.Status = UnsubscribeStatus.Skipped;
item.ResultMessage = "mailto unsubscribe must be sent manually.";
break;
default:
item.Status = UnsubscribeStatus.Skipped;
break;
}
}
catch (Exception ex)
{
fail++;
item.Status = UnsubscribeStatus.Failed;
item.ResultMessage = ex.Message;
errors.Add($"{item.UnsubscribeTarget}: {ex.Message}");
}
}
await _db.SaveChangesAsync(ct);
return Result<CleanupResultDto>.Success(new CleanupResultDto(CleanupActionType.RemoveLabel, ok, fail, errors));
}
private static void SetResult(UnsubscribeItem item, bool success)
{
item.Status = success ? UnsubscribeStatus.Succeeded : UnsubscribeStatus.Failed;
item.ResultMessage = success ? "OK" : "Non-success HTTP status.";
}
}
@@ -0,0 +1,43 @@
using InboxIntel.Domain.Enums;
namespace InboxIntel.Infrastructure.Configuration;
public class GoogleOAuthOptions
{
public const string SectionName = "GoogleOAuth";
public string ClientId { get; set; } = string.Empty;
public string ClientSecret { get; set; } = string.Empty;
/// <summary>Scopes requested. Gmail read + modify (no send).</summary>
public string[] Scopes { get; set; } =
{
"openid", "email", "profile",
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/gmail.modify"
};
}
public class GmailSyncOptions
{
public const string SectionName = "GmailSync";
public int PageSize { get; set; } = 100;
public int MaxParallelism { get; set; } = 4;
public int MaxRetries { get; set; } = 5;
/// <summary>Base delay in ms for exponential backoff.</summary>
public int BackoffBaseMs { get; set; } = 500;
/// <summary>Cron-like daily sync hour (UTC) for the scheduled worker.</summary>
public int DailySyncHourUtc { get; set; } = 3;
}
public class AiOptions
{
public const string SectionName = "Ai";
public AiProviderMode Mode { get; set; } = AiProviderMode.Disabled;
// Ollama (local)
public string OllamaBaseUrl { get; set; } = "http://localhost:11434";
public string OllamaModel { get; set; } = "llama3.1";
// OpenAI (cloud, optional)
public string OpenAiApiKey { get; set; } = string.Empty;
public string OpenAiModel { get; set; } = "gpt-4o-mini";
}
@@ -0,0 +1,69 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Domain.Enums;
using InboxIntel.Infrastructure.Ai;
using InboxIntel.Infrastructure.Analytics;
using InboxIntel.Infrastructure.Cleanup;
using InboxIntel.Infrastructure.Configuration;
using InboxIntel.Infrastructure.Export;
using InboxIntel.Infrastructure.Gmail;
using InboxIntel.Infrastructure.Persistence;
using InboxIntel.Infrastructure.Search;
using InboxIntel.Infrastructure.Security;
using InboxIntel.Infrastructure.Sync;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace InboxIntel.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration config)
{
// EF Core / PostgreSQL
services.AddDbContext<AppDbContext>(opt =>
opt.UseNpgsql(config.GetConnectionString("Postgres"),
npg => npg.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName)));
services.AddScoped<IAppDbContext>(sp => sp.GetRequiredService<AppDbContext>());
// Options
services.Configure<GoogleOAuthOptions>(config.GetSection(GoogleOAuthOptions.SectionName));
services.Configure<GmailSyncOptions>(config.GetSection(GmailSyncOptions.SectionName));
services.Configure<AiOptions>(config.GetSection(AiOptions.SectionName));
// Security
services.AddSingleton<ITokenProtector, DataProtectionTokenProtector>();
// Gmail
services.AddScoped<GmailClientFactory>();
services.AddScoped<IGmailService, GmailApiService>();
// Core services
services.AddScoped<ISyncService, SyncService>();
services.AddScoped<IAnalyticsService, AnalyticsService>();
services.AddScoped<ISearchService, SearchService>();
services.AddScoped<ICleanupService, CleanupService>();
services.AddScoped<IUnsubscribeService, UnsubscribeService>();
services.AddScoped<IExportService, ExportService>();
// HTTP clients
services.AddHttpClient("unsubscribe", c => c.Timeout = TimeSpan.FromSeconds(15));
services.AddHttpClient("ollama");
services.AddHttpClient("openai");
// AI provider selected by configured mode.
var aiMode = config.GetSection(AiOptions.SectionName).GetValue<AiProviderMode>("Mode");
switch (aiMode)
{
case AiProviderMode.LocalOllama: services.AddScoped<IAiProvider, OllamaProvider>(); break;
case AiProviderMode.CloudOpenAi: services.AddScoped<IAiProvider, OpenAiProvider>(); break;
default: services.AddScoped<IAiProvider, NullAiProvider>(); break;
}
services.AddScoped<IAiService, AiService>();
// Background worker (daily incremental sync + aggregate refresh)
services.AddHostedService<GmailSyncWorker>();
return services;
}
}
@@ -0,0 +1,112 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using CsvHelper;
using InboxIntel.Application.Abstractions;
using DTOs = InboxIntel.Application.DTOs;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
namespace InboxIntel.Infrastructure.Export;
/// <summary>
/// Builds inbox reports as PDF (QuestPDF), CSV (CsvHelper) or JSON. The report
/// covers the inbox summary, sender stats, cleanup suggestions and storage use.
/// </summary>
public class ExportService : IExportService
{
private readonly IAnalyticsService _analytics;
private readonly IAiService _ai;
public ExportService(IAnalyticsService analytics, IAiService ai)
{
_analytics = analytics;
_ai = ai;
QuestPDF.Settings.License = LicenseType.Community;
}
public async Task<(byte[] Content, string ContentType, string FileName)> ExportReportAsync(Guid userId, ExportFormat format, CancellationToken ct = default)
{
var health = await _analytics.GetInboxHealthAsync(userId, ct);
var topSenders = await _analytics.GetTopSendersAsync(userId, 25, ct);
var suggestions = await _ai.SuggestCleanupAsync(userId, ct);
var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmm");
return format switch
{
ExportFormat.Json => (BuildJson(health, topSenders, suggestions), "application/json", $"inbox-report-{stamp}.json"),
ExportFormat.Csv => (BuildCsv(topSenders), "text/csv", $"sender-stats-{stamp}.csv"),
_ => (BuildPdf(health, topSenders, suggestions), "application/pdf", $"inbox-report-{stamp}.pdf")
};
}
private static byte[] BuildJson(object health, object senders, object suggestions)
{
var payload = new { generatedUtc = DateTimeOffset.UtcNow, health, topSenders = senders, cleanupSuggestions = suggestions };
return JsonSerializer.SerializeToUtf8Bytes(payload, new JsonSerializerOptions { WriteIndented = true });
}
private static byte[] BuildCsv(IEnumerable<DTOs.SenderStatDto> senders)
{
using var ms = new MemoryStream();
using (var writer = new StreamWriter(ms, Encoding.UTF8, leaveOpen: true))
using (var csv = new CsvWriter(writer, CultureInfo.InvariantCulture))
{
csv.WriteRecords(senders);
}
return ms.ToArray();
}
private byte[] BuildPdf(DTOs.InboxHealthDto health, IReadOnlyList<DTOs.SenderStatDto> senders, IReadOnlyList<DTOs.AiCleanupSuggestionDto> suggestions)
{
var doc = Document.Create(container =>
{
container.Page(page =>
{
page.Margin(40);
page.Size(PageSizes.A4);
page.DefaultTextStyle(t => t.FontSize(10));
page.Header().Text("InboxIntel — Inbox Report").FontSize(20).Bold();
page.Content().PaddingVertical(10).Column(col =>
{
col.Item().Text($"Generated: {DateTime.UtcNow:u}").FontColor(Colors.Grey.Medium);
col.Item().PaddingTop(10).Text($"Inbox Health: {health.Score}/100 (Grade {health.Grade})").FontSize(14).Bold();
col.Item().Text($"Total emails: {health.TotalEmails:N0} Unread: {health.UnreadEmails:N0} Newsletters: {health.NewsletterCount:N0}");
col.Item().Text($"Estimated storage: {health.EstimatedStorageBytes / 1_048_576.0:N1} MB");
col.Item().PaddingTop(14).Text("Recommendations").FontSize(13).Bold();
foreach (var rec in health.Recommendations)
col.Item().Text($"• {rec}");
col.Item().PaddingTop(14).Text("Top Senders").FontSize(13).Bold();
col.Item().Table(table =>
{
table.ColumnsDefinition(c => { c.RelativeColumn(3); c.RelativeColumn(1); c.RelativeColumn(1); });
table.Header(h =>
{
h.Cell().Text("Sender").Bold();
h.Cell().Text("Emails").Bold();
h.Cell().Text("Unread").Bold();
});
foreach (var s in senders)
{
table.Cell().Text(s.Address);
table.Cell().Text(s.EmailCount.ToString("N0"));
table.Cell().Text(s.UnreadCount.ToString("N0"));
}
});
col.Item().PaddingTop(14).Text("Cleanup Suggestions").FontSize(13).Bold();
foreach (var sug in suggestions)
col.Item().Text($"• {sug.Title} — {sug.Rationale}");
});
page.Footer().AlignCenter().Text(t => { t.Span("InboxIntel • "); t.CurrentPageNumber(); t.Span(" / "); t.TotalPages(); });
});
});
return doc.GeneratePdf();
}
}
@@ -0,0 +1,57 @@
using Google.Apis.Auth.OAuth2;
using Google.Apis.Auth.OAuth2.Flows;
using Google.Apis.Auth.OAuth2.Responses;
using Google.Apis.Gmail.v1;
using Google.Apis.Services;
using InboxIntel.Application.Abstractions;
using InboxIntel.Infrastructure.Configuration;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace InboxIntel.Infrastructure.Gmail;
/// <summary>
/// Builds an authenticated <see cref="GmailService"/> for a user by decrypting
/// the stored refresh token and letting the Google client library handle access
/// token refresh.
/// </summary>
public class GmailClientFactory
{
private readonly AppDbContext _db;
private readonly ITokenProtector _protector;
private readonly GoogleOAuthOptions _oauth;
public GmailClientFactory(AppDbContext db, ITokenProtector protector, IOptions<GoogleOAuthOptions> oauth)
{
_db = db;
_protector = protector;
_oauth = oauth.Value;
}
public async Task<GmailService> CreateAsync(Guid userId, CancellationToken ct = default)
{
var user = await _db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == userId, ct)
?? throw new InvalidOperationException($"User {userId} not found.");
if (user.EncryptedRefreshToken is null)
throw new InvalidOperationException("User has no stored refresh token. Re-authentication required.");
var refreshToken = _protector.Unprotect(user.EncryptedRefreshToken);
var flow = new GoogleAuthorizationCodeFlow(new GoogleAuthorizationCodeFlow.Initializer
{
ClientSecrets = new ClientSecrets { ClientId = _oauth.ClientId, ClientSecret = _oauth.ClientSecret },
Scopes = _oauth.Scopes
});
var tokenResponse = new TokenResponse { RefreshToken = refreshToken };
var credential = new UserCredential(flow, user.Id.ToString(), tokenResponse);
return new GmailService(new BaseClientService.Initializer
{
HttpClientInitializer = credential,
ApplicationName = "InboxIntel"
});
}
}
@@ -0,0 +1,110 @@
using Google.Apis.Gmail.v1.Data;
using InboxIntel.Application.Abstractions;
using System.Text;
using System.Text.RegularExpressions;
namespace InboxIntel.Infrastructure.Gmail;
/// <summary>
/// Converts a raw Gmail <see cref="Message"/> into the structured
/// <see cref="GmailMessageDetail"/> the sync pipeline persists. Extracts the
/// sender, plain-text body, attachment metadata, and unsubscribe signals.
/// </summary>
public static class GmailMessageParser
{
private static readonly Regex FromRegex = new(@"^(?:(?<name>.*?)\s*)?<?(?<addr>[^<>\s]+@[^<>\s]+)>?$", RegexOptions.Compiled);
private static readonly Regex HttpLinkRegex = new(@"https?://[^>\s,]+", RegexOptions.Compiled);
public static GmailMessageDetail Parse(Message msg)
{
var headers = msg.Payload?.Headers ?? new List<MessagePartHeader>();
string GetHeader(string name) =>
headers.FirstOrDefault(h => string.Equals(h.Name, name, StringComparison.OrdinalIgnoreCase))?.Value ?? string.Empty;
var (fromName, fromAddr) = ParseFrom(GetHeader("From"));
var subject = GetHeader("Subject");
var listUnsub = GetHeader("List-Unsubscribe");
var listUnsubPost = GetHeader("List-Unsubscribe-Post");
var sentMs = msg.InternalDate ?? 0;
var sentAt = DateTimeOffset.FromUnixTimeMilliseconds(sentMs);
var labelIds = msg.LabelIds?.ToList() ?? new List<string>();
var isUnread = labelIds.Contains("UNREAD");
var attachments = new List<(string, string?, long, string?)>();
var bodyBuilder = new StringBuilder();
WalkParts(msg.Payload, bodyBuilder, attachments);
return new GmailMessageDetail(
GmailMessageId: msg.Id,
GmailThreadId: msg.ThreadId,
FromAddress: fromAddr,
FromDisplayName: string.IsNullOrWhiteSpace(fromName) ? null : fromName,
Subject: string.IsNullOrWhiteSpace(subject) ? null : subject,
Snippet: msg.Snippet,
BodyText: bodyBuilder.Length > 0 ? bodyBuilder.ToString() : null,
SentAtUtc: sentAt,
SizeEstimateBytes: msg.SizeEstimate ?? 0,
IsUnread: isUnread,
HasAttachments: attachments.Count > 0,
LabelIds: labelIds,
Attachments: attachments,
HasListUnsubscribe: !string.IsNullOrWhiteSpace(listUnsub),
ListUnsubscribeRaw: string.IsNullOrWhiteSpace(listUnsub) ? null : listUnsub,
SupportsOneClickUnsubscribe: listUnsubPost.Contains("One-Click", StringComparison.OrdinalIgnoreCase));
}
private static (string name, string addr) ParseFrom(string raw)
{
if (string.IsNullOrWhiteSpace(raw)) return (string.Empty, "unknown@unknown");
var m = FromRegex.Match(raw.Trim());
if (!m.Success) return (string.Empty, raw.Trim().ToLowerInvariant());
var name = m.Groups["name"].Value.Trim().Trim('"');
var addr = m.Groups["addr"].Value.Trim().ToLowerInvariant();
return (name, addr);
}
private static void WalkParts(MessagePart? part, StringBuilder body, List<(string, string?, long, string?)> attachments)
{
if (part is null) return;
var isAttachment = !string.IsNullOrEmpty(part.Filename) && part.Body?.AttachmentId is not null;
if (isAttachment)
{
attachments.Add((part.Filename!, part.MimeType, part.Body!.Size ?? 0, part.Body.AttachmentId));
}
else if (part.MimeType == "text/plain" && part.Body?.Data is not null && body.Length < 50_000)
{
body.Append(DecodeBase64Url(part.Body.Data));
}
if (part.Parts is not null)
foreach (var child in part.Parts)
WalkParts(child, body, attachments);
}
private static string DecodeBase64Url(string data)
{
var padded = data.Replace('-', '+').Replace('_', '/');
switch (padded.Length % 4) { case 2: padded += "=="; break; case 3: padded += "="; break; }
try { return Encoding.UTF8.GetString(Convert.FromBase64String(padded)); }
catch { return string.Empty; }
}
/// <summary>Extracts the first usable unsubscribe target from a List-Unsubscribe header.</summary>
public static string? ExtractUnsubscribeTarget(string? listUnsubscribeRaw)
{
if (string.IsNullOrWhiteSpace(listUnsubscribeRaw)) return null;
var http = HttpLinkRegex.Match(listUnsubscribeRaw);
if (http.Success) return http.Value;
var mailtoIdx = listUnsubscribeRaw.IndexOf("mailto:", StringComparison.OrdinalIgnoreCase);
if (mailtoIdx >= 0)
{
var rest = listUnsubscribeRaw[mailtoIdx..].TrimStart('<');
var end = rest.IndexOfAny(new[] { '>', ',', ' ' });
return end > 0 ? rest[..end] : rest;
}
return null;
}
}
@@ -0,0 +1,172 @@
using Google;
using Google.Apis.Gmail.v1;
using Google.Apis.Gmail.v1.Data;
using InboxIntel.Application.Abstractions;
using InboxIntel.Infrastructure.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Polly;
using Polly.Retry;
using System.Net;
using DomainLabel = InboxIntel.Domain.Entities.Label;
namespace InboxIntel.Infrastructure.Gmail;
/// <summary>
/// Gmail REST API wrapper. Every call is wrapped in a Polly retry pipeline that
/// applies exponential backoff with jitter on 429 / 5xx / transient errors,
/// honouring the configured max retry count. Responses are null-checked before use.
/// </summary>
public class GmailApiService : IGmailService
{
private readonly GmailClientFactory _factory;
private readonly GmailSyncOptions _options;
private readonly ILogger<GmailApiService> _logger;
private readonly ResiliencePipeline _pipeline;
public GmailApiService(GmailClientFactory factory, IOptions<GmailSyncOptions> options, ILogger<GmailApiService> logger)
{
_factory = factory;
_options = options.Value;
_logger = logger;
_pipeline = new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions
{
ShouldHandle = new PredicateBuilder()
.Handle<GoogleApiException>(IsTransient)
.Handle<HttpRequestException>(),
MaxRetryAttempts = _options.MaxRetries,
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
Delay = TimeSpan.FromMilliseconds(_options.BackoffBaseMs),
OnRetry = args =>
{
_logger.LogWarning("Gmail API transient failure, retry {Attempt} after {Delay}ms",
args.AttemptNumber, args.RetryDelay.TotalMilliseconds);
return default;
}
})
.Build();
}
private static bool IsTransient(GoogleApiException ex) =>
ex.HttpStatusCode is HttpStatusCode.TooManyRequests
or HttpStatusCode.InternalServerError
or HttpStatusCode.BadGateway
or HttpStatusCode.ServiceUnavailable
or HttpStatusCode.GatewayTimeout;
public async Task<string> GetProfileHistoryIdAsync(Guid userId, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
var profile = await _pipeline.ExecuteAsync(async token =>
await client.Users.GetProfile("me").ExecuteAsync(token), ct);
return profile?.HistoryId?.ToString() ?? throw new InvalidOperationException("Gmail profile returned no historyId.");
}
public async Task<GmailMessagePage> ListMessageIdsAsync(Guid userId, string? pageToken, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
var page = await _pipeline.ExecuteAsync(async token =>
{
var req = client.Users.Messages.List("me");
req.MaxResults = _options.PageSize;
req.PageToken = pageToken;
req.IncludeSpamTrash = false;
return await req.ExecuteAsync(token);
}, ct);
var ids = page?.Messages?.Select(m => m.Id).Where(id => id is not null).ToList() ?? new List<string>();
return new GmailMessagePage(ids!, page?.NextPageToken, (int)(page?.ResultSizeEstimate ?? 0));
}
public async Task<GmailMessageDetail> GetMessageAsync(Guid userId, string gmailMessageId, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
var msg = await _pipeline.ExecuteAsync(async token =>
{
var req = client.Users.Messages.Get("me", gmailMessageId);
req.Format = UsersResource.MessagesResource.GetRequest.FormatEnum.Full;
return await req.ExecuteAsync(token);
}, ct);
if (msg is null) throw new InvalidOperationException($"Gmail returned null for message {gmailMessageId}.");
return GmailMessageParser.Parse(msg);
}
public async Task<GmailHistoryPage> ListHistoryAsync(Guid userId, string startHistoryId, string? pageToken, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
var history = await _pipeline.ExecuteAsync(async token =>
{
var req = client.Users.History.List("me");
req.StartHistoryId = ulong.Parse(startHistoryId);
req.PageToken = pageToken;
return await req.ExecuteAsync(token);
}, ct);
var changed = new List<string>();
var deleted = new List<string>();
foreach (var h in history?.History ?? Enumerable.Empty<History>())
{
if (h.MessagesAdded is not null) changed.AddRange(h.MessagesAdded.Select(m => m.Message.Id));
if (h.MessagesDeleted is not null) deleted.AddRange(h.MessagesDeleted.Select(m => m.Message.Id));
}
return new GmailHistoryPage(changed, deleted, history?.NextPageToken, history?.HistoryId?.ToString());
}
public async Task<IReadOnlyList<DomainLabel>> ListLabelsAsync(Guid userId, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
var resp = await _pipeline.ExecuteAsync(async token =>
await client.Users.Labels.List("me").ExecuteAsync(token), ct);
return resp?.Labels?.Select(l => new DomainLabel
{
UserId = userId,
GmailLabelId = l.Id,
Name = l.Name,
Type = l.Type ?? "user"
}).ToList() ?? new List<DomainLabel>();
}
public async Task BatchModifyAsync(Guid userId, IEnumerable<string> messageIds, IEnumerable<string> addLabelIds, IEnumerable<string> removeLabelIds, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
var body = new BatchModifyMessagesRequest
{
Ids = messageIds.ToList(),
AddLabelIds = addLabelIds.ToList(),
RemoveLabelIds = removeLabelIds.ToList()
};
await _pipeline.ExecuteAsync(async token =>
{
await client.Users.Messages.BatchModify(body, "me").ExecuteAsync(token);
return true;
}, ct);
}
public async Task BatchTrashAsync(Guid userId, IEnumerable<string> messageIds, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
foreach (var id in messageIds)
{
await _pipeline.ExecuteAsync(async token =>
{
await client.Users.Messages.Trash("me", id).ExecuteAsync(token);
return true;
}, ct);
}
}
public async Task BatchDeleteAsync(Guid userId, IEnumerable<string> messageIds, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
var body = new BatchDeleteMessagesRequest { Ids = messageIds.ToList() };
await _pipeline.ExecuteAsync(async token =>
{
await client.Users.Messages.BatchDelete(body, "me").ExecuteAsync(token);
return true;
}, ct);
}
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>InboxIntel.Infrastructure</RootNamespace>
<AssemblyName>InboxIntel.Infrastructure</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Google.Apis.Gmail.v1" Version="1.68.0.3427" />
<PackageReference Include="Google.Apis.Auth" Version="1.68.0" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.7" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
<PackageReference Include="Polly" Version="8.4.1" />
<PackageReference Include="QuestPDF" Version="2024.7.0" />
<PackageReference Include="CsvHelper" Version="33.0.1" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\InboxIntel.Application\InboxIntel.Application.csproj" />
<ProjectReference Include="..\InboxIntel.Domain\InboxIntel.Domain.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,41 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Domain.Common;
using InboxIntel.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using System.Reflection;
namespace InboxIntel.Infrastructure.Persistence;
public class AppDbContext : DbContext, IAppDbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<User> Users => Set<User>();
public DbSet<Email> Emails => Set<Email>();
public DbSet<MailThread> Threads => Set<MailThread>();
public DbSet<Sender> Senders => Set<Sender>();
public DbSet<MailDomain> Domains => Set<MailDomain>();
public DbSet<Attachment> Attachments => Set<Attachment>();
public DbSet<Label> Labels => Set<Label>();
public DbSet<EmailLabel> EmailLabels => Set<EmailLabel>();
public DbSet<SyncState> SyncStates => Set<SyncState>();
public DbSet<AnalyticsAggregate> AnalyticsAggregates => Set<AnalyticsAggregate>();
public DbSet<WidgetLayout> WidgetLayouts => Set<WidgetLayout>();
public DbSet<UnsubscribeItem> UnsubscribeItems => Set<UnsubscribeItem>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
base.OnModelCreating(modelBuilder);
}
public override Task<int> SaveChangesAsync(CancellationToken ct = default)
{
foreach (var entry in ChangeTracker.Entries<AuditableEntity>())
{
if (entry.State == EntityState.Modified)
entry.Entity.UpdatedAtUtc = DateTimeOffset.UtcNow;
}
return base.SaveChangesAsync(ct);
}
}
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace InboxIntel.Infrastructure.Persistence;
/// <summary>
/// Design-time factory so `dotnet ef migrations add ...` works without booting
/// the full API host. Reads the connection string from the EF_CONNECTION env
/// var, falling back to a local default.
/// </summary>
public class AppDbContextFactory : IDesignTimeDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext(string[] args)
{
var conn = Environment.GetEnvironmentVariable("EF_CONNECTION")
?? "Host=localhost;Port=5432;Database=inboxintel;Username=inboxintel;Password=inboxintel";
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseNpgsql(conn)
.Options;
return new AppDbContext(options);
}
}
@@ -0,0 +1,48 @@
using InboxIntel.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace InboxIntel.Infrastructure.Persistence.Configurations;
public class EmailConfiguration : IEntityTypeConfiguration<Email>
{
public void Configure(EntityTypeBuilder<Email> b)
{
b.ToTable("emails");
b.HasKey(e => e.Id);
b.Property(e => e.GmailMessageId).HasMaxLength(64).IsRequired();
b.Property(e => e.Subject).HasMaxLength(1024);
b.Property(e => e.Snippet).HasMaxLength(2048);
b.Property(e => e.ListUnsubscribeRaw).HasMaxLength(2048);
// One Gmail message per user.
b.HasIndex(e => new { e.UserId, e.GmailMessageId }).IsUnique();
// Indexes that power fast sender grouping, time-series, and inbox filters at 100k+ rows.
b.HasIndex(e => new { e.UserId, e.SenderId });
b.HasIndex(e => new { e.UserId, e.SentAtUtc });
b.HasIndex(e => new { e.UserId, e.IsUnread });
b.HasIndex(e => new { e.UserId, e.Category });
b.HasIndex(e => new { e.UserId, e.IsInInbox });
b.HasOne(e => e.Thread)
.WithMany(t => t.Emails)
.HasForeignKey(e => e.ThreadId)
.OnDelete(DeleteBehavior.Cascade);
b.HasOne(e => e.Sender)
.WithMany(s => s.Emails)
.HasForeignKey(e => e.SenderId)
.OnDelete(DeleteBehavior.Restrict);
// PostgreSQL full-text search: generated tsvector over subject + body,
// with a GIN index. Maintained by the database, read-only in code.
b.Property(e => e.SearchVector)
.HasColumnType("tsvector")
.HasComputedColumnSql(
"to_tsvector('english', coalesce(\"Subject\",'') || ' ' || coalesce(\"BodyText\",''))",
stored: true);
b.HasIndex(e => e.SearchVector).HasMethod("GIN");
}
}
@@ -0,0 +1,143 @@
using InboxIntel.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace InboxIntel.Infrastructure.Persistence.Configurations;
public class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> b)
{
b.ToTable("users");
b.HasKey(u => u.Id);
b.Property(u => u.GoogleSubjectId).HasMaxLength(64).IsRequired();
b.Property(u => u.Email).HasMaxLength(320).IsRequired();
b.HasIndex(u => u.GoogleSubjectId).IsUnique();
b.HasIndex(u => u.Email).IsUnique();
// EncryptedRefreshToken is bytea; never indexed, never logged.
}
}
public class MailDomainConfiguration : IEntityTypeConfiguration<MailDomain>
{
public void Configure(EntityTypeBuilder<MailDomain> b)
{
b.ToTable("domains");
b.HasKey(d => d.Id);
b.Property(d => d.Name).HasMaxLength(255).IsRequired();
b.HasIndex(d => new { d.UserId, d.Name }).IsUnique();
}
}
public class SenderConfiguration : IEntityTypeConfiguration<Sender>
{
public void Configure(EntityTypeBuilder<Sender> b)
{
b.ToTable("senders");
b.HasKey(s => s.Id);
b.Property(s => s.Address).HasMaxLength(320).IsRequired();
b.Property(s => s.DisplayName).HasMaxLength(255);
b.HasIndex(s => new { s.UserId, s.Address }).IsUnique();
b.HasIndex(s => new { s.UserId, s.EmailCount });
b.HasOne(s => s.Domain).WithMany(d => d.Senders)
.HasForeignKey(s => s.DomainId).OnDelete(DeleteBehavior.Restrict);
}
}
public class MailThreadConfiguration : IEntityTypeConfiguration<MailThread>
{
public void Configure(EntityTypeBuilder<MailThread> b)
{
b.ToTable("threads");
b.HasKey(t => t.Id);
b.Property(t => t.GmailThreadId).HasMaxLength(64).IsRequired();
b.Property(t => t.Subject).HasMaxLength(1024);
b.HasIndex(t => new { t.UserId, t.GmailThreadId }).IsUnique();
}
}
public class AttachmentConfiguration : IEntityTypeConfiguration<Attachment>
{
public void Configure(EntityTypeBuilder<Attachment> b)
{
b.ToTable("attachments");
b.HasKey(a => a.Id);
b.Property(a => a.FileName).HasMaxLength(512);
b.Property(a => a.MimeType).HasMaxLength(255);
b.HasIndex(a => new { a.UserId, a.MimeType });
b.HasOne(a => a.Email).WithMany(e => e.Attachments)
.HasForeignKey(a => a.EmailId).OnDelete(DeleteBehavior.Cascade);
}
}
public class LabelConfiguration : IEntityTypeConfiguration<Label>
{
public void Configure(EntityTypeBuilder<Label> b)
{
b.ToTable("labels");
b.HasKey(l => l.Id);
b.Property(l => l.GmailLabelId).HasMaxLength(64).IsRequired();
b.Property(l => l.Name).HasMaxLength(255).IsRequired();
b.HasIndex(l => new { l.UserId, l.GmailLabelId }).IsUnique();
}
}
public class EmailLabelConfiguration : IEntityTypeConfiguration<EmailLabel>
{
public void Configure(EntityTypeBuilder<EmailLabel> b)
{
b.ToTable("email_labels");
b.HasKey(el => new { el.EmailId, el.LabelId });
b.HasOne(el => el.Email).WithMany(e => e.EmailLabels)
.HasForeignKey(el => el.EmailId).OnDelete(DeleteBehavior.Cascade);
b.HasOne(el => el.Label).WithMany(l => l.EmailLabels)
.HasForeignKey(el => el.LabelId).OnDelete(DeleteBehavior.Cascade);
}
}
public class SyncStateConfiguration : IEntityTypeConfiguration<SyncState>
{
public void Configure(EntityTypeBuilder<SyncState> b)
{
b.ToTable("sync_states");
b.HasKey(s => s.Id);
b.HasIndex(s => s.UserId).IsUnique();
b.Property(s => s.LastError).HasMaxLength(4000);
}
}
public class AnalyticsAggregateConfiguration : IEntityTypeConfiguration<AnalyticsAggregate>
{
public void Configure(EntityTypeBuilder<AnalyticsAggregate> b)
{
b.ToTable("analytics_aggregates");
b.HasKey(a => a.Id);
b.HasIndex(a => new { a.UserId, a.Day }).IsUnique();
}
}
public class WidgetLayoutConfiguration : IEntityTypeConfiguration<WidgetLayout>
{
public void Configure(EntityTypeBuilder<WidgetLayout> b)
{
b.ToTable("widget_layouts");
b.HasKey(w => w.Id);
b.Property(w => w.WidgetKey).HasMaxLength(64).IsRequired();
b.HasIndex(w => new { w.UserId, w.WidgetKey }).IsUnique();
b.HasOne<User>().WithMany(u => u.WidgetLayouts)
.HasForeignKey(w => w.UserId).OnDelete(DeleteBehavior.Cascade);
}
}
public class UnsubscribeItemConfiguration : IEntityTypeConfiguration<UnsubscribeItem>
{
public void Configure(EntityTypeBuilder<UnsubscribeItem> b)
{
b.ToTable("unsubscribe_items");
b.HasKey(u => u.Id);
b.Property(u => u.UnsubscribeTarget).HasMaxLength(2048);
b.HasIndex(u => new { u.UserId, u.SenderId }).IsUnique();
b.HasOne(u => u.Sender).WithMany()
.HasForeignKey(u => u.SenderId).OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,59 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Application.Common;
using InboxIntel.Application.DTOs;
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
/// (EF.Functions.ToTsVector/Matches translate to @@ / to_tsquery).
/// </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)
{
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 (!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)));
}
var total = await q.CountAsync(ct);
var items = await q
.OrderByDescending(e => e.SentAtUtc)
.Skip((r.Page - 1) * r.PageSize)
.Take(r.PageSize)
.Select(e => new EmailSummaryDto(
e.Id, e.GmailMessageId, e.Subject, e.Snippet,
e.Sender!.Address, e.Sender.DisplayName, e.SentAtUtc,
e.IsUnread, e.HasAttachments, e.SizeEstimateBytes, e.Category))
.ToListAsync(ct);
return new PagedResult<EmailSummaryDto>
{
Items = items, Page = r.Page, PageSize = r.PageSize, TotalCount = total
};
}
}
@@ -0,0 +1,25 @@
using InboxIntel.Application.Abstractions;
using Microsoft.AspNetCore.DataProtection;
using System.Text;
namespace InboxIntel.Infrastructure.Security;
/// <summary>
/// Encrypts OAuth refresh tokens at rest using the ASP.NET Core Data Protection
/// API (AES-256-CBC + HMAC). Keys are persisted to a protected key ring so
/// tokens survive restarts. Plaintext tokens are never logged.
/// </summary>
public class DataProtectionTokenProtector : ITokenProtector
{
private const string Purpose = "InboxIntel.OAuthRefreshToken.v1";
private readonly IDataProtector _protector;
public DataProtectionTokenProtector(IDataProtectionProvider provider)
=> _protector = provider.CreateProtector(Purpose);
public byte[] Protect(string plaintext)
=> _protector.Protect(Encoding.UTF8.GetBytes(plaintext));
public string Unprotect(byte[] ciphertext)
=> Encoding.UTF8.GetString(_protector.Unprotect(ciphertext));
}
@@ -0,0 +1,74 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Infrastructure.Configuration;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace InboxIntel.Infrastructure.Sync;
/// <summary>
/// Background worker that runs a daily incremental sync for every user and
/// refreshes analytics aggregates. Non-blocking: it runs in its own scope and
/// never touches the request pipeline. Failures are logged and retried on the
/// next tick rather than crashing the host.
/// </summary>
public class GmailSyncWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly GmailSyncOptions _options;
private readonly ILogger<GmailSyncWorker> _logger;
public GmailSyncWorker(IServiceScopeFactory scopeFactory, IOptions<GmailSyncOptions> options, ILogger<GmailSyncWorker> logger)
{
_scopeFactory = scopeFactory;
_options = options.Value;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("GmailSyncWorker started; daily sync hour = {Hour}:00 UTC", _options.DailySyncHourUtc);
while (!stoppingToken.IsCancellationRequested)
{
try
{
var now = DateTimeOffset.UtcNow;
if (now.Hour == _options.DailySyncHourUtc)
await RunForAllUsersAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "GmailSyncWorker tick failed");
}
// Re-evaluate hourly. A production deployment may swap this for Hangfire/cron.
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
}
}
private async Task RunForAllUsersAsync(CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var sync = scope.ServiceProvider.GetRequiredService<ISyncService>();
var analytics = scope.ServiceProvider.GetRequiredService<IAnalyticsService>();
var userIds = await db.Users.Select(u => u.Id).ToListAsync(ct);
foreach (var userId in userIds)
{
try
{
await sync.RunIncrementalSyncAsync(userId, ct);
await analytics.RefreshAggregatesAsync(userId, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Scheduled sync failed for user {UserId}", userId);
}
}
}
}
@@ -0,0 +1,28 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Domain.Enums;
namespace InboxIntel.Infrastructure.Sync;
/// <summary>
/// Fast, dependency-free first-pass classifier applied during sync. The AI
/// layer can later refine these labels, but this guarantees every email has a
/// sensible category even when AI is disabled.
/// </summary>
public static class HeuristicClassifier
{
private static readonly string[] FinanceHints = { "invoice", "receipt", "payment", "statement", "bank", "transaction", "billing" };
private static readonly string[] SocialHints = { "facebook", "twitter", "linkedin", "instagram", "tiktok" };
private static readonly string[] NoReplyHints = { "noreply", "no-reply", "donotreply", "newsletter", "mailer", "notifications" };
public static EmailCategory Classify(GmailMessageDetail d)
{
var subject = (d.Subject ?? string.Empty).ToLowerInvariant();
var from = d.FromAddress.ToLowerInvariant();
if (d.HasListUnsubscribe) return EmailCategory.Newsletter;
if (FinanceHints.Any(h => subject.Contains(h))) return EmailCategory.Finance;
if (SocialHints.Any(h => from.Contains(h))) return EmailCategory.Social;
if (NoReplyHints.Any(h => from.Contains(h))) return EmailCategory.Notification;
return EmailCategory.Personal;
}
}
@@ -0,0 +1,246 @@
using InboxIntel.Application.Abstractions;
using InboxIntel.Domain.Entities;
using InboxIntel.Domain.Enums;
using InboxIntel.Infrastructure.Gmail;
using InboxIntel.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace InboxIntel.Infrastructure.Sync;
/// <summary>
/// Orchestrates Gmail synchronisation. Full sync pages through every message;
/// incremental sync replays the Gmail history feed since the last historyId.
/// Progress is checkpointed to <see cref="SyncState"/> so an interrupted run
/// resumes from its last page token instead of restarting.
/// </summary>
public class SyncService : ISyncService
{
private readonly AppDbContext _db;
private readonly IGmailService _gmail;
private readonly ILogger<SyncService> _logger;
public SyncService(AppDbContext db, IGmailService gmail, ILogger<SyncService> logger)
{
_db = db;
_gmail = gmail;
_logger = logger;
}
public async Task<SyncStatus> GetStatusAsync(Guid userId, CancellationToken ct = default)
=> (await GetOrCreateStateAsync(userId, ct)).Status;
public async Task RunFullSyncAsync(Guid userId, CancellationToken ct = default)
{
var state = await GetOrCreateStateAsync(userId, ct);
state.Status = SyncStatus.Running;
state.LastSyncType = SyncType.Full;
state.StartedUtc = DateTimeOffset.UtcNow;
state.LastError = null;
await _db.SaveChangesAsync(ct);
try
{
await SyncLabelsAsync(userId, ct);
string? pageToken = state.ResumePageToken; // resume support
do
{
var page = await _gmail.ListMessageIdsAsync(userId, pageToken, ct);
foreach (var messageId in page.MessageIds)
{
if (await _db.Emails.AnyAsync(e => e.UserId == userId && e.GmailMessageId == messageId, ct))
continue;
var detail = await _gmail.GetMessageAsync(userId, messageId, ct);
await UpsertMessageAsync(userId, detail, ct);
state.MessagesProcessed++;
}
pageToken = page.NextPageToken;
state.ResumePageToken = pageToken; // checkpoint
state.TotalMessagesEstimate = page.ResultSizeEstimate;
await _db.SaveChangesAsync(ct);
}
while (pageToken is not null && !ct.IsCancellationRequested);
state.LastHistoryId = await _gmail.GetProfileHistoryIdAsync(userId, ct);
state.ResumePageToken = null;
state.Status = SyncStatus.Completed;
state.CompletedUtc = DateTimeOffset.UtcNow;
state.LastSuccessfulSyncUtc = DateTimeOffset.UtcNow;
state.ConsecutiveFailures = 0;
await _db.SaveChangesAsync(ct);
}
catch (Exception ex)
{
await MarkFailedAsync(state, ex, ct);
throw;
}
}
public async Task RunIncrementalSyncAsync(Guid userId, CancellationToken ct = default)
{
var state = await GetOrCreateStateAsync(userId, ct);
if (string.IsNullOrEmpty(state.LastHistoryId))
{
await RunFullSyncAsync(userId, ct); // no watermark yet -> full sync
return;
}
state.Status = SyncStatus.Running;
state.LastSyncType = SyncType.Incremental;
state.StartedUtc = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(ct);
try
{
string? pageToken = null;
string? newHistoryId = state.LastHistoryId;
do
{
var page = await _gmail.ListHistoryAsync(userId, state.LastHistoryId!, pageToken, ct);
foreach (var id in page.ChangedMessageIds.Distinct())
{
var detail = await _gmail.GetMessageAsync(userId, id, ct);
await UpsertMessageAsync(userId, detail, ct);
}
foreach (var id in page.DeletedMessageIds.Distinct())
{
var existing = await _db.Emails.FirstOrDefaultAsync(e => e.UserId == userId && e.GmailMessageId == id, ct);
if (existing is not null) _db.Emails.Remove(existing);
}
if (page.NewHistoryId is not null) newHistoryId = page.NewHistoryId;
pageToken = page.NextPageToken;
await _db.SaveChangesAsync(ct);
}
while (pageToken is not null && !ct.IsCancellationRequested);
state.LastHistoryId = newHistoryId;
state.Status = SyncStatus.Completed;
state.CompletedUtc = DateTimeOffset.UtcNow;
state.LastSuccessfulSyncUtc = DateTimeOffset.UtcNow;
state.ConsecutiveFailures = 0;
await _db.SaveChangesAsync(ct);
}
catch (Exception ex)
{
await MarkFailedAsync(state, ex, ct);
throw;
}
}
private async Task MarkFailedAsync(SyncState state, Exception ex, CancellationToken ct)
{
_logger.LogError(ex, "Sync failed for user {UserId}", state.UserId);
state.Status = SyncStatus.Failed;
state.ConsecutiveFailures++;
state.LastError = ex.Message;
await _db.SaveChangesAsync(ct);
}
private async Task<SyncState> GetOrCreateStateAsync(Guid userId, CancellationToken ct)
{
var state = await _db.SyncStates.FirstOrDefaultAsync(s => s.UserId == userId, ct);
if (state is null)
{
state = new SyncState { UserId = userId };
_db.SyncStates.Add(state);
await _db.SaveChangesAsync(ct);
}
return state;
}
private async Task SyncLabelsAsync(Guid userId, CancellationToken ct)
{
var remote = await _gmail.ListLabelsAsync(userId, ct);
foreach (var label in remote)
{
var existing = await _db.Labels.FirstOrDefaultAsync(l => l.UserId == userId && l.GmailLabelId == label.GmailLabelId, ct);
if (existing is null) _db.Labels.Add(label);
else { existing.Name = label.Name; existing.Type = label.Type; }
}
await _db.SaveChangesAsync(ct);
}
/// <summary>Resolves domain/sender/thread, then inserts the email and attachment metadata.</summary>
private async Task UpsertMessageAsync(Guid userId, GmailMessageDetail d, CancellationToken ct)
{
var sender = await ResolveSenderAsync(userId, d.FromAddress, d.FromDisplayName, ct);
var thread = await ResolveThreadAsync(userId, d.GmailThreadId, d.Subject, d.Snippet, d.SentAtUtc, ct);
var email = new Email
{
UserId = userId,
GmailMessageId = d.GmailMessageId,
ThreadId = thread.Id,
SenderId = sender.Id,
Subject = d.Subject,
Snippet = d.Snippet,
BodyText = d.BodyText,
SentAtUtc = d.SentAtUtc,
ReceivedAtUtc = d.SentAtUtc,
SizeEstimateBytes = d.SizeEstimateBytes,
IsUnread = d.IsUnread,
IsInInbox = d.LabelIds.Contains("INBOX"),
IsStarred = d.LabelIds.Contains("STARRED"),
IsImportant = d.LabelIds.Contains("IMPORTANT"),
HasAttachments = d.HasAttachments,
HasListUnsubscribe = d.HasListUnsubscribe,
ListUnsubscribeRaw = d.ListUnsubscribeRaw,
SupportsOneClickUnsubscribe = d.SupportsOneClickUnsubscribe,
Category = HeuristicClassifier.Classify(d)
};
_db.Emails.Add(email);
foreach (var (fileName, mime, size, attId) in d.Attachments)
{
_db.Attachments.Add(new Attachment
{
UserId = userId, EmailId = email.Id, FileName = fileName,
MimeType = mime, SizeBytes = size, GmailAttachmentId = attId
});
}
// Maintain sender rollups for fast grouping.
sender.EmailCount++;
if (d.IsUnread) sender.UnreadCount++;
sender.TotalSizeBytes += d.SizeEstimateBytes;
sender.LastReceivedUtc = d.SentAtUtc;
if (d.HasListUnsubscribe) sender.HasUnsubscribe = true;
thread.MessageCount++;
thread.LastMessageUtc = d.SentAtUtc;
}
private async Task<Sender> ResolveSenderAsync(Guid userId, string address, string? displayName, CancellationToken ct)
{
var sender = await _db.Senders.FirstOrDefaultAsync(s => s.UserId == userId && s.Address == address, ct);
if (sender is not null) return sender;
var domainName = address.Contains('@') ? address[(address.IndexOf('@') + 1)..] : "unknown";
var domain = await _db.Domains.FirstOrDefaultAsync(x => x.UserId == userId && x.Name == domainName, ct);
if (domain is null)
{
domain = new MailDomain { UserId = userId, Name = domainName };
_db.Domains.Add(domain);
}
domain.EmailCount++;
sender = new Sender { UserId = userId, Address = address, DisplayName = displayName, Domain = domain, DomainId = domain.Id };
_db.Senders.Add(sender);
return sender;
}
private async Task<MailThread> ResolveThreadAsync(Guid userId, string gmailThreadId, string? subject, string? snippet, DateTimeOffset sentAt, CancellationToken ct)
{
var thread = await _db.Threads.FirstOrDefaultAsync(t => t.UserId == userId && t.GmailThreadId == gmailThreadId, ct);
if (thread is not null) return thread;
thread = new MailThread
{
UserId = userId, GmailThreadId = gmailThreadId, Subject = subject,
Snippet = snippet, FirstMessageUtc = sentAt
};
_db.Threads.Add(thread);
return thread;
}
}