chore: init project
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
using Google.Apis.Auth.OAuth2;
|
||||
using Google.Apis.Auth.OAuth2.Flows;
|
||||
using Google.Apis.Auth.OAuth2.Responses;
|
||||
using Google.Apis.Gmail.v1;
|
||||
using Google.Apis.Services;
|
||||
using InboxIntel.Application.Abstractions;
|
||||
using InboxIntel.Infrastructure.Configuration;
|
||||
using InboxIntel.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace InboxIntel.Infrastructure.Gmail;
|
||||
|
||||
/// <summary>
|
||||
/// Builds an authenticated <see cref="GmailService"/> for a user by decrypting
|
||||
/// the stored refresh token and letting the Google client library handle access
|
||||
/// token refresh.
|
||||
/// </summary>
|
||||
public class GmailClientFactory
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ITokenProtector _protector;
|
||||
private readonly GoogleOAuthOptions _oauth;
|
||||
|
||||
public GmailClientFactory(AppDbContext db, ITokenProtector protector, IOptions<GoogleOAuthOptions> oauth)
|
||||
{
|
||||
_db = db;
|
||||
_protector = protector;
|
||||
_oauth = oauth.Value;
|
||||
}
|
||||
|
||||
public async Task<GmailService> CreateAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var user = await _db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == userId, ct)
|
||||
?? throw new InvalidOperationException($"User {userId} not found.");
|
||||
|
||||
if (user.EncryptedRefreshToken is null)
|
||||
throw new InvalidOperationException("User has no stored refresh token. Re-authentication required.");
|
||||
|
||||
var refreshToken = _protector.Unprotect(user.EncryptedRefreshToken);
|
||||
|
||||
var flow = new GoogleAuthorizationCodeFlow(new GoogleAuthorizationCodeFlow.Initializer
|
||||
{
|
||||
ClientSecrets = new ClientSecrets { ClientId = _oauth.ClientId, ClientSecret = _oauth.ClientSecret },
|
||||
Scopes = _oauth.Scopes
|
||||
});
|
||||
|
||||
var tokenResponse = new TokenResponse { RefreshToken = refreshToken };
|
||||
var credential = new UserCredential(flow, user.Id.ToString(), tokenResponse);
|
||||
|
||||
return new GmailService(new BaseClientService.Initializer
|
||||
{
|
||||
HttpClientInitializer = credential,
|
||||
ApplicationName = "InboxIntel"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using Google.Apis.Gmail.v1.Data;
|
||||
using InboxIntel.Application.Abstractions;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace InboxIntel.Infrastructure.Gmail;
|
||||
|
||||
/// <summary>
|
||||
/// Converts a raw Gmail <see cref="Message"/> into the structured
|
||||
/// <see cref="GmailMessageDetail"/> the sync pipeline persists. Extracts the
|
||||
/// sender, plain-text body, attachment metadata, and unsubscribe signals.
|
||||
/// </summary>
|
||||
public static class GmailMessageParser
|
||||
{
|
||||
private static readonly Regex FromRegex = new(@"^(?:(?<name>.*?)\s*)?<?(?<addr>[^<>\s]+@[^<>\s]+)>?$", RegexOptions.Compiled);
|
||||
private static readonly Regex HttpLinkRegex = new(@"https?://[^>\s,]+", RegexOptions.Compiled);
|
||||
|
||||
public static GmailMessageDetail Parse(Message msg)
|
||||
{
|
||||
var headers = msg.Payload?.Headers ?? new List<MessagePartHeader>();
|
||||
string GetHeader(string name) =>
|
||||
headers.FirstOrDefault(h => string.Equals(h.Name, name, StringComparison.OrdinalIgnoreCase))?.Value ?? string.Empty;
|
||||
|
||||
var (fromName, fromAddr) = ParseFrom(GetHeader("From"));
|
||||
var subject = GetHeader("Subject");
|
||||
var listUnsub = GetHeader("List-Unsubscribe");
|
||||
var listUnsubPost = GetHeader("List-Unsubscribe-Post");
|
||||
|
||||
var sentMs = msg.InternalDate ?? 0;
|
||||
var sentAt = DateTimeOffset.FromUnixTimeMilliseconds(sentMs);
|
||||
|
||||
var labelIds = msg.LabelIds?.ToList() ?? new List<string>();
|
||||
var isUnread = labelIds.Contains("UNREAD");
|
||||
|
||||
var attachments = new List<(string, string?, long, string?)>();
|
||||
var bodyBuilder = new StringBuilder();
|
||||
WalkParts(msg.Payload, bodyBuilder, attachments);
|
||||
|
||||
return new GmailMessageDetail(
|
||||
GmailMessageId: msg.Id,
|
||||
GmailThreadId: msg.ThreadId,
|
||||
FromAddress: fromAddr,
|
||||
FromDisplayName: string.IsNullOrWhiteSpace(fromName) ? null : fromName,
|
||||
Subject: string.IsNullOrWhiteSpace(subject) ? null : subject,
|
||||
Snippet: msg.Snippet,
|
||||
BodyText: bodyBuilder.Length > 0 ? bodyBuilder.ToString() : null,
|
||||
SentAtUtc: sentAt,
|
||||
SizeEstimateBytes: msg.SizeEstimate ?? 0,
|
||||
IsUnread: isUnread,
|
||||
HasAttachments: attachments.Count > 0,
|
||||
LabelIds: labelIds,
|
||||
Attachments: attachments,
|
||||
HasListUnsubscribe: !string.IsNullOrWhiteSpace(listUnsub),
|
||||
ListUnsubscribeRaw: string.IsNullOrWhiteSpace(listUnsub) ? null : listUnsub,
|
||||
SupportsOneClickUnsubscribe: listUnsubPost.Contains("One-Click", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static (string name, string addr) ParseFrom(string raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return (string.Empty, "unknown@unknown");
|
||||
var m = FromRegex.Match(raw.Trim());
|
||||
if (!m.Success) return (string.Empty, raw.Trim().ToLowerInvariant());
|
||||
var name = m.Groups["name"].Value.Trim().Trim('"');
|
||||
var addr = m.Groups["addr"].Value.Trim().ToLowerInvariant();
|
||||
return (name, addr);
|
||||
}
|
||||
|
||||
private static void WalkParts(MessagePart? part, StringBuilder body, List<(string, string?, long, string?)> attachments)
|
||||
{
|
||||
if (part is null) return;
|
||||
|
||||
var isAttachment = !string.IsNullOrEmpty(part.Filename) && part.Body?.AttachmentId is not null;
|
||||
if (isAttachment)
|
||||
{
|
||||
attachments.Add((part.Filename!, part.MimeType, part.Body!.Size ?? 0, part.Body.AttachmentId));
|
||||
}
|
||||
else if (part.MimeType == "text/plain" && part.Body?.Data is not null && body.Length < 50_000)
|
||||
{
|
||||
body.Append(DecodeBase64Url(part.Body.Data));
|
||||
}
|
||||
|
||||
if (part.Parts is not null)
|
||||
foreach (var child in part.Parts)
|
||||
WalkParts(child, body, attachments);
|
||||
}
|
||||
|
||||
private static string DecodeBase64Url(string data)
|
||||
{
|
||||
var padded = data.Replace('-', '+').Replace('_', '/');
|
||||
switch (padded.Length % 4) { case 2: padded += "=="; break; case 3: padded += "="; break; }
|
||||
try { return Encoding.UTF8.GetString(Convert.FromBase64String(padded)); }
|
||||
catch { return string.Empty; }
|
||||
}
|
||||
|
||||
/// <summary>Extracts the first usable unsubscribe target from a List-Unsubscribe header.</summary>
|
||||
public static string? ExtractUnsubscribeTarget(string? listUnsubscribeRaw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(listUnsubscribeRaw)) return null;
|
||||
var http = HttpLinkRegex.Match(listUnsubscribeRaw);
|
||||
if (http.Success) return http.Value;
|
||||
var mailtoIdx = listUnsubscribeRaw.IndexOf("mailto:", StringComparison.OrdinalIgnoreCase);
|
||||
if (mailtoIdx >= 0)
|
||||
{
|
||||
var rest = listUnsubscribeRaw[mailtoIdx..].TrimStart('<');
|
||||
var end = rest.IndexOfAny(new[] { '>', ',', ' ' });
|
||||
return end > 0 ? rest[..end] : rest;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user