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,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;
}
}