feat(email): add delivery adapters
CI and Deploy / test (pull_request) Failing after 1m37s
CI and Deploy / deploy (pull_request) Has been skipped

Gmail and Graph request explicit send consent and classify provider rejection separately from uncertain transport failure. IMAP remains read-only; no send API is exposed.
This commit is contained in:
cesnimda
2026-08-09 23:47:21 +02:00
parent 429f427f49
commit e9937accd8
11 changed files with 331 additions and 9 deletions
+66 -1
View File
@@ -7,6 +7,8 @@ using JobTrackerApi.Models;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using JobTrackerApi.Services.EmailProviders;
using MimeKit;
namespace JobTrackerApi.Services;
@@ -22,6 +24,7 @@ public interface IGmailOAuthService
Task<IReadOnlyList<GmailQueryMatchedMessage>> ListJobCandidateMessagesAsync(string ownerUserId, IEnumerable<string> queries, int maxResultsPerQuery, CancellationToken cancellationToken);
Task<IReadOnlyList<GmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken);
Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
Task<GmailSendResult> SendAsync(string ownerUserId, GmailSendRequest request, CancellationToken cancellationToken);
}
public sealed record GmailOAuthExchangeResult(string GmailAddress);
@@ -29,6 +32,8 @@ public sealed record GmailMessageSummary(string Id, string ThreadId, string Subj
public sealed record GmailQueryMatchedMessage(GmailMessageSummary Message, IReadOnlyList<string> MatchedQueries);
public sealed record GmailMessageAttachment(string? FileName, string? MimeType, long? SizeBytes, string? GmailAttachmentId, bool Inline);
public sealed record GmailMessageDetail(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet, string BodyText, string? BodyHtml, IReadOnlyList<string> Labels, IReadOnlyList<GmailMessageAttachment> Attachments);
public sealed record GmailSendRequest(string To, string Subject, string BodyText, string? ThreadId);
public sealed record GmailSendResult(string? MessageId, string? ThreadId);
internal sealed class GmailTokenResponse
{
@@ -41,7 +46,8 @@ internal sealed class GmailTokenResponse
public sealed class GmailOAuthService : IGmailOAuthService
{
private const string Scope = "openid email profile https://www.googleapis.com/auth/gmail.readonly";
public const string SendScope = "https://www.googleapis.com/auth/gmail.send";
private const string Scope = $"openid email profile https://www.googleapis.com/auth/gmail.readonly {SendScope}";
private readonly IConfiguration _cfg;
private readonly JobTrackerContext _db;
private readonly IDataProtector _protector;
@@ -362,6 +368,65 @@ public sealed class GmailOAuthService : IGmailOAuthService
}
}
public async Task<GmailSendResult> SendAsync(string ownerUserId, GmailSendRequest request, CancellationToken cancellationToken)
{
var connection = await _db.GmailConnections.AsNoTracking().FirstOrDefaultAsync(item => item.OwnerUserId == ownerUserId, cancellationToken);
if (connection is null || !HasSendScope(connection.Scope))
throw new EmailProviderDeliveryException("reauthorization_required", false, "Reconnect Gmail and approve send access before sending.");
ValidateSendRequest(request.To, request.Subject, request.BodyText);
var message = new MimeMessage();
message.To.Add(MailboxAddress.Parse(request.To.Trim()));
message.Subject = request.Subject.Trim();
message.Body = new TextPart("plain") { Text = request.BodyText };
await using var stream = new MemoryStream();
await message.WriteToAsync(stream, cancellationToken);
var raw = Convert.ToBase64String(stream.ToArray()).TrimEnd('=').Replace('+', '-').Replace('/', '_');
var payload = JsonSerializer.Serialize(new { raw, threadId = string.IsNullOrWhiteSpace(request.ThreadId) ? null : request.ThreadId.Trim() });
try
{
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
using var response = await client.PostAsync(
"https://gmail.googleapis.com/gmail/v1/users/me/messages/send",
new StringContent(payload, Encoding.UTF8, "application/json"),
cancellationToken);
if (!response.IsSuccessStatusCode)
{
var category = response.StatusCode is System.Net.HttpStatusCode.Unauthorized or System.Net.HttpStatusCode.Forbidden
? "reauthorization_required"
: "provider_rejected";
throw new EmailProviderDeliveryException(category, false, "Gmail rejected the send request.");
}
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
return new GmailSendResult(
document.RootElement.TryGetProperty("id", out var id) ? id.GetString() : null,
document.RootElement.TryGetProperty("threadId", out var threadId) ? threadId.GetString() : request.ThreadId);
}
catch (EmailProviderDeliveryException)
{
throw;
}
catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException)
{
throw new EmailProviderDeliveryException("transport_interrupted", true, "Gmail delivery status is uncertain.", ex);
}
}
public static bool HasSendScope(string? scope) =>
!string.IsNullOrWhiteSpace(scope) && scope.Split(' ', StringSplitOptions.RemoveEmptyEntries).Contains(SendScope, StringComparer.OrdinalIgnoreCase);
private static void ValidateSendRequest(string to, string subject, string bodyText)
{
if (string.IsNullOrWhiteSpace(to)) throw new ArgumentException("Recipient is required.", nameof(to));
if (string.IsNullOrWhiteSpace(subject)) throw new ArgumentException("Subject is required.", nameof(subject));
if (string.IsNullOrWhiteSpace(bodyText)) throw new ArgumentException("Body is required.", nameof(bodyText));
if (to.Length > 320 || subject.Length > 998 || bodyText.Length > 200_000) throw new ArgumentException("Email content exceeds the supported limit.");
}
private async Task<string> GetValidAccessTokenAsync(string ownerUserId, CancellationToken cancellationToken)
{
var connection = await _db.GmailConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);