chore: init project
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user