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; /// /// 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. /// public class GmailApiService : IGmailService { private readonly GmailClientFactory _factory; private readonly GmailSyncOptions _options; private readonly ILogger _logger; private readonly ResiliencePipeline _pipeline; // Cache the authenticated client per user so parallel message fetches don't // each rebuild it (which would hit the shared DbContext concurrently). private readonly SemaphoreSlim _clientLock = new(1, 1); private Google.Apis.Gmail.v1.GmailService? _cachedClient; private Guid _cachedUserId; public GmailApiService(GmailClientFactory factory, IOptions options, ILogger logger) { _factory = factory; _options = options.Value; _logger = logger; _pipeline = new ResiliencePipelineBuilder() .AddRetry(new RetryStrategyOptions { ShouldHandle = new PredicateBuilder() .Handle(IsTransient) .Handle(), 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; /// Builds the Gmail client once per user and reuses it (thread-safe). private async Task GetClientAsync(Guid userId, CancellationToken ct) { if (_cachedClient is not null && _cachedUserId == userId) return _cachedClient; await _clientLock.WaitAsync(ct); try { if (_cachedClient is null || _cachedUserId != userId) { _cachedClient = await _factory.CreateAsync(userId, ct); _cachedUserId = userId; } return _cachedClient; } finally { _clientLock.Release(); } } public async Task GetProfileHistoryIdAsync(Guid userId, CancellationToken ct = default) { var client = await GetClientAsync(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 ListMessageIdsAsync(Guid userId, string? pageToken, CancellationToken ct = default) { var client = await GetClientAsync(userId, ct); var page = await _pipeline.ExecuteAsync(async token => { var req = client.Users.Messages.List("me"); req.MaxResults = _options.PageSize; req.PageToken = pageToken; // Include spam & trash so those folders aren't structurally empty; their state is // captured via the SPAM/TRASH labels (IsTrashed + EmailLabels) during upsert. req.IncludeSpamTrash = true; return await req.ExecuteAsync(token); }, ct); var ids = page?.Messages?.Select(m => m.Id).Where(id => id is not null).ToList() ?? new List(); return new GmailMessagePage(ids!, page?.NextPageToken, (int)(page?.ResultSizeEstimate ?? 0)); } public async Task GetMessageAsync(Guid userId, string gmailMessageId, CancellationToken ct = default) { var client = await GetClientAsync(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 ListHistoryAsync(Guid userId, string startHistoryId, string? pageToken, CancellationToken ct = default) { var client = await GetClientAsync(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(); var deleted = new List(); foreach (var h in history?.History ?? Enumerable.Empty()) { 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> ListLabelsAsync(Guid userId, CancellationToken ct = default) { var client = await GetClientAsync(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(); } public async Task BatchModifyAsync(Guid userId, IEnumerable messageIds, IEnumerable addLabelIds, IEnumerable removeLabelIds, CancellationToken ct = default) { var client = await GetClientAsync(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 messageIds, CancellationToken ct = default) { var client = await GetClientAsync(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 messageIds, CancellationToken ct = default) { var client = await GetClientAsync(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); } }