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