eccf66eaed
CI / backend (push) Successful in 52s
CI / frontend (push) Successful in 11s
CI / format (push) Successful in 1m8s
CI / db-tests (push) Successful in 49s
Deploy Staging / deploy (push) Successful in 24s
CI / backend (pull_request) Successful in 53s
CI / frontend (pull_request) Successful in 11s
CI / format (pull_request) Successful in 49s
CI / db-tests (pull_request) Successful in 52s
Security / secrets (push) Successful in 3s
Security / dependencies (push) Successful in 58s
Security / sast (push) Successful in 33s
Security / secrets (pull_request) Successful in 3s
Security / dependencies (pull_request) Successful in 58s
Security / sast (pull_request) Successful in 37s
198 lines
8.2 KiB
C#
198 lines
8.2 KiB
C#
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;
|
|
|
|
// 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<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;
|
|
|
|
/// <summary>Builds the Gmail client once per user and reuses it (thread-safe).</summary>
|
|
private async Task<Google.Apis.Gmail.v1.GmailService> 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<string> 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<GmailMessagePage> 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<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 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<GmailHistoryPage> 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<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 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<DomainLabel>();
|
|
}
|
|
|
|
public async Task BatchModifyAsync(Guid userId, IEnumerable<string> messageIds, IEnumerable<string> addLabelIds, IEnumerable<string> 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<string> 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<string> 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);
|
|
}
|
|
}
|