feat(email): add delivery adapters
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:
@@ -39,7 +39,7 @@ public sealed class EmailController(IEmailProviderRegistry providers) : Controll
|
||||
connection is not null,
|
||||
connection?.Address,
|
||||
CanRead: connection is not null,
|
||||
CanSend: false));
|
||||
CanSend: connection?.CanSend ?? false));
|
||||
}
|
||||
|
||||
return Ok(statuses);
|
||||
|
||||
@@ -21,7 +21,7 @@ namespace JobTrackerApi.Services.EmailProviders
|
||||
public async Task<EmailConnectionInfo?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
|
||||
return connection is null ? null : new EmailConnectionInfo("gmail", connection.GmailAddress ?? "");
|
||||
return connection is null ? null : new EmailConnectionInfo("gmail", connection.GmailAddress ?? "", GmailOAuthService.HasSendScope(connection.Scope));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<EmailMessageSummary>> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
|
||||
@@ -57,6 +57,12 @@ namespace JobTrackerApi.Services.EmailProviders
|
||||
attachments);
|
||||
}
|
||||
|
||||
public async Task<EmailDeliveryResult> SendAsync(string ownerUserId, EmailDeliveryRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _gmail.SendAsync(ownerUserId, new GmailSendRequest(request.To, request.Subject, request.BodyText, request.ThreadId), cancellationToken);
|
||||
return new EmailDeliveryResult(result.MessageId, result.ThreadId);
|
||||
}
|
||||
|
||||
private static EmailMessageSummary ToSummary(GmailMessageSummary m)
|
||||
=> new(m.Id, m.ThreadId, m.Subject, m.From, m.To, m.Date, m.Snippet);
|
||||
}
|
||||
|
||||
@@ -23,9 +23,21 @@ namespace JobTrackerApi.Services.EmailProviders
|
||||
|
||||
/// <summary>Full message content (body + attachments metadata).</summary>
|
||||
Task<EmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
|
||||
|
||||
Task<EmailDeliveryResult> SendAsync(string ownerUserId, EmailDeliveryRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record EmailConnectionInfo(string ProviderKey, string Address);
|
||||
public sealed record EmailConnectionInfo(string ProviderKey, string Address, bool CanSend = false);
|
||||
|
||||
public sealed record EmailDeliveryRequest(string To, string Subject, string BodyText, string? ThreadId = null);
|
||||
public sealed record EmailDeliveryResult(string? ExternalMessageId, string? ExternalThreadId);
|
||||
|
||||
public sealed class EmailProviderDeliveryException(string category, bool uncertain, string message, Exception? innerException = null)
|
||||
: Exception(message, innerException)
|
||||
{
|
||||
public string Category { get; } = category;
|
||||
public bool Uncertain { get; } = uncertain;
|
||||
}
|
||||
|
||||
public sealed record EmailMessageSummary(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet);
|
||||
|
||||
|
||||
@@ -57,6 +57,9 @@ namespace JobTrackerApi.Services.EmailProviders
|
||||
attachments);
|
||||
}
|
||||
|
||||
public Task<EmailDeliveryResult> SendAsync(string ownerUserId, EmailDeliveryRequest request, CancellationToken cancellationToken) =>
|
||||
throw new EmailProviderDeliveryException("unsupported_provider", false, "This IMAP connection does not include an outgoing-mail transport.");
|
||||
|
||||
private static EmailMessageSummary ToSummary(ImapMessageSummary m)
|
||||
=> new(m.Id, m.ThreadKey, m.Subject, m.From, m.To, m.Date, m.Snippet);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ namespace JobTrackerApi.Services.EmailProviders
|
||||
public async Task<EmailConnectionInfo?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await _graph.GetConnectionAsync(ownerUserId, cancellationToken);
|
||||
return connection is null ? null : new EmailConnectionInfo("microsoft", connection.MailAddress ?? "");
|
||||
return connection is null ? null : new EmailConnectionInfo("microsoft", connection.MailAddress ?? "", MicrosoftGraphOAuthService.HasSendScope(connection.Scope));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<EmailMessageSummary>> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
|
||||
@@ -57,6 +57,12 @@ namespace JobTrackerApi.Services.EmailProviders
|
||||
attachments);
|
||||
}
|
||||
|
||||
public async Task<EmailDeliveryResult> SendAsync(string ownerUserId, EmailDeliveryRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
await _graph.SendAsync(ownerUserId, new MicrosoftGraphSendRequest(request.To, request.Subject, request.BodyText), cancellationToken);
|
||||
return new EmailDeliveryResult(null, null);
|
||||
}
|
||||
|
||||
private static EmailMessageSummary ToSummary(MicrosoftGraphMessageSummary m)
|
||||
=> new(m.Id, m.ConversationId, m.Subject, m.From, m.To, m.Date, m.Snippet);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -6,6 +6,7 @@ using JobTrackerApi.Models;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using JobTrackerApi.Services.EmailProviders;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
@@ -19,12 +20,15 @@ public interface IMicrosoftGraphOAuthService
|
||||
Task<IReadOnlyList<MicrosoftGraphMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<MicrosoftGraphMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string conversationId, CancellationToken cancellationToken);
|
||||
Task<MicrosoftGraphMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
|
||||
Task<MicrosoftGraphSendResult> SendAsync(string ownerUserId, MicrosoftGraphSendRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record MicrosoftGraphOAuthExchangeResult(string MailAddress);
|
||||
public sealed record MicrosoftGraphMessageSummary(string Id, string ConversationId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet);
|
||||
public sealed record MicrosoftGraphMessageAttachment(string? FileName, string? MimeType, long? SizeBytes, string? GraphAttachmentId, bool Inline);
|
||||
public sealed record MicrosoftGraphMessageDetail(string Id, string ConversationId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet, string BodyText, string? BodyHtml, IReadOnlyList<string> Labels, IReadOnlyList<MicrosoftGraphMessageAttachment> Attachments);
|
||||
public sealed record MicrosoftGraphSendRequest(string To, string Subject, string BodyText);
|
||||
public sealed record MicrosoftGraphSendResult();
|
||||
|
||||
internal sealed class MicrosoftGraphTokenResponse
|
||||
{
|
||||
@@ -42,7 +46,8 @@ internal sealed class MicrosoftGraphTokenResponse
|
||||
/// </summary>
|
||||
public sealed class MicrosoftGraphOAuthService : IMicrosoftGraphOAuthService
|
||||
{
|
||||
private const string Scope = "openid email profile offline_access https://graph.microsoft.com/Mail.Read";
|
||||
public const string SendScope = "https://graph.microsoft.com/Mail.Send";
|
||||
private const string Scope = $"openid email profile offline_access https://graph.microsoft.com/Mail.Read {SendScope}";
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly JobTrackerContext _db;
|
||||
private readonly IDataProtector _protector;
|
||||
@@ -280,6 +285,69 @@ public sealed class MicrosoftGraphOAuthService : IMicrosoftGraphOAuthService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<MicrosoftGraphSendResult> SendAsync(string ownerUserId, MicrosoftGraphSendRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await _db.MicrosoftGraphConnections.AsNoTracking().FirstOrDefaultAsync(item => item.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (connection is null || !HasSendScope(connection.Scope))
|
||||
throw new EmailProviderDeliveryException("reauthorization_required", false, "Reconnect Outlook and approve send access before sending.");
|
||||
ValidateSendRequest(request.To, request.Subject, request.BodyText);
|
||||
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
message = new
|
||||
{
|
||||
subject = request.Subject.Trim(),
|
||||
body = new { contentType = "Text", content = request.BodyText },
|
||||
toRecipients = new[] { new { emailAddress = new { address = request.To.Trim() } } },
|
||||
},
|
||||
saveToSentItems = true,
|
||||
});
|
||||
|
||||
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://graph.microsoft.com/v1.0/me/sendMail",
|
||||
new StringContent(payload, System.Text.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, "Microsoft Graph rejected the send request.");
|
||||
}
|
||||
|
||||
return new MicrosoftGraphSendResult();
|
||||
}
|
||||
catch (EmailProviderDeliveryException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException)
|
||||
{
|
||||
throw new EmailProviderDeliveryException("transport_interrupted", true, "Outlook delivery status is uncertain.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool HasSendScope(string? scope)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scope)) return false;
|
||||
return scope.Split(' ', StringSplitOptions.RemoveEmptyEntries).Any(value =>
|
||||
string.Equals(value, SendScope, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(value, "Mail.Send", StringComparison.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 static async Task<IReadOnlyList<MicrosoftGraphMessageAttachment>> ListAttachmentsAsync(HttpClient client, string messageId, CancellationToken cancellationToken)
|
||||
{
|
||||
var url = $"https://graph.microsoft.com/v1.0/me/messages/{Uri.EscapeDataString(messageId)}/attachments?$select=id,name,contentType,size,isInline";
|
||||
|
||||
Reference in New Issue
Block a user