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
+5 -2
View File
@@ -12,7 +12,7 @@ public sealed class EmailControllerTests
[Fact] [Fact]
public async Task Provider_status_is_owner_scoped_and_does_not_claim_send_support() public async Task Provider_status_is_owner_scoped_and_does_not_claim_send_support()
{ {
var gmail = new FakeProvider("gmail", new EmailConnectionInfo("gmail", "owner@gmail.test")); var gmail = new FakeProvider("gmail", new EmailConnectionInfo("gmail", "owner@gmail.test", CanSend: true));
var outlook = new FakeProvider("microsoft", null); var outlook = new FakeProvider("microsoft", null);
var controller = CreateController(gmail, outlook); var controller = CreateController(gmail, outlook);
@@ -27,7 +27,7 @@ public sealed class EmailControllerTests
Assert.Equal("Gmail", status.DisplayName); Assert.Equal("Gmail", status.DisplayName);
Assert.True(status.Connected); Assert.True(status.Connected);
Assert.True(status.CanRead); Assert.True(status.CanRead);
Assert.False(status.CanSend); Assert.True(status.CanSend);
}, },
status => status =>
{ {
@@ -113,5 +113,8 @@ public sealed class EmailControllerTests
Array.Empty<string>(), Array.Empty<string>(),
Array.Empty<EmailAttachmentRef>())); Array.Empty<EmailAttachmentRef>()));
} }
public Task<EmailDeliveryResult> SendAsync(string ownerUserId, EmailDeliveryRequest request, CancellationToken cancellationToken) =>
Task.FromResult(new EmailDeliveryResult("message-1", "thread-1"));
} }
} }
@@ -0,0 +1,146 @@
using System.Net;
using System.Text;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Services.EmailProviders;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class EmailProviderDeliveryTests
{
[Fact]
public async Task Authorization_urls_request_explicit_send_consent()
{
await using var db = TestHostFactory.CreateInMemoryDb();
var protection = new EphemeralDataProtectionProvider();
var clients = new ClientFactory(new Handler(_ => throw new InvalidOperationException("No HTTP expected.")));
var gmail = new GmailOAuthService(Config(), db, protection, clients, new MemoryCache(new MemoryCacheOptions()));
var graph = new MicrosoftGraphOAuthService(Config(), db, protection, clients, new MemoryCache(new MemoryCacheOptions()));
Assert.Contains(GmailOAuthService.SendScope, Uri.UnescapeDataString(gmail.BuildAuthorizationUrl("user-1", "https://app.test/gmail")));
Assert.Contains(MicrosoftGraphOAuthService.SendScope, Uri.UnescapeDataString(graph.BuildAuthorizationUrl("user-1", "https://app.test/graph")));
}
[Fact]
public async Task Gmail_send_uses_send_scope_plain_text_and_thread_without_exposing_provider_errors()
{
await using var db = TestHostFactory.CreateInMemoryDb();
var protection = new EphemeralDataProtectionProvider();
var protector = protection.CreateProtector("gmail-oauth-tokens-v1");
db.GmailConnections.Add(new GmailConnection
{
OwnerUserId = "user-1",
GmailAddress = "owner@gmail.test",
EncryptedAccessToken = protector.Protect("access-token"),
EncryptedRefreshToken = protector.Protect("refresh-token"),
AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
Scope = $"https://www.googleapis.com/auth/gmail.readonly {GmailOAuthService.SendScope}",
});
await db.SaveChangesAsync();
string? captured = null;
var service = new GmailOAuthService(Config(), db, protection, new ClientFactory(new Handler(async request =>
{
Assert.Equal("https://gmail.googleapis.com/gmail/v1/users/me/messages/send", request.RequestUri!.ToString());
Assert.Equal("access-token", request.Headers.Authorization?.Parameter);
captured = await request.Content!.ReadAsStringAsync();
return Json(HttpStatusCode.OK, "{\"id\":\"gmail-message-1\",\"threadId\":\"thread-1\"}");
})), new MemoryCache(new MemoryCacheOptions()));
var result = await service.SendAsync("user-1", new GmailSendRequest("recruiter@example.test", "Hei intervju", "Plain body", "thread-1"), default);
Assert.Equal("gmail-message-1", result.MessageId);
Assert.Contains("thread-1", captured);
Assert.DoesNotContain("Plain body", captured);
}
[Fact]
public async Task Graph_send_uses_mail_send_scope_and_classifies_rejection_as_known()
{
await using var db = TestHostFactory.CreateInMemoryDb();
var protection = new EphemeralDataProtectionProvider();
var protector = protection.CreateProtector("microsoft-graph-oauth-tokens-v1");
db.MicrosoftGraphConnections.Add(new MicrosoftGraphConnection
{
OwnerUserId = "user-1",
MailAddress = "owner@outlook.test",
EncryptedAccessToken = protector.Protect("access-token"),
EncryptedRefreshToken = protector.Protect("refresh-token"),
AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
Scope = "Mail.Read Mail.Send",
});
await db.SaveChangesAsync();
var service = new MicrosoftGraphOAuthService(Config(), db, protection, new ClientFactory(new Handler(request =>
{
Assert.Equal("https://graph.microsoft.com/v1.0/me/sendMail", request.RequestUri!.ToString());
return Task.FromResult(Json(HttpStatusCode.BadRequest, "{\"error\":{\"message\":\"private provider detail\"}}"));
})), new MemoryCache(new MemoryCacheOptions()));
var error = await Assert.ThrowsAsync<EmailProviderDeliveryException>(() =>
service.SendAsync("user-1", new MicrosoftGraphSendRequest("recruiter@example.test", "Subject", "Body"), default));
Assert.False(error.Uncertain);
Assert.Equal("provider_rejected", error.Category);
Assert.DoesNotContain("private provider detail", error.Message);
}
[Fact]
public async Task Transport_interruption_is_uncertain_and_missing_scope_requires_reauthorization()
{
await using var db = TestHostFactory.CreateInMemoryDb();
var protection = new EphemeralDataProtectionProvider();
var protector = protection.CreateProtector("gmail-oauth-tokens-v1");
db.GmailConnections.Add(new GmailConnection
{
OwnerUserId = "user-1",
GmailAddress = "owner@gmail.test",
EncryptedAccessToken = protector.Protect("access-token"),
EncryptedRefreshToken = protector.Protect("refresh-token"),
AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
Scope = GmailOAuthService.SendScope,
});
await db.SaveChangesAsync();
var interrupted = new GmailOAuthService(Config(), db, protection, new ClientFactory(new Handler(_ => throw new HttpRequestException("network detail"))), new MemoryCache(new MemoryCacheOptions()));
var uncertain = await Assert.ThrowsAsync<EmailProviderDeliveryException>(() =>
interrupted.SendAsync("user-1", new GmailSendRequest("recruiter@example.test", "Subject", "Body", null), default));
Assert.True(uncertain.Uncertain);
Assert.Equal("transport_interrupted", uncertain.Category);
Assert.DoesNotContain("network detail", uncertain.Message);
var connection = await db.GmailConnections.SingleAsync();
connection.Scope = "https://www.googleapis.com/auth/gmail.readonly";
await db.SaveChangesAsync();
var reauthorization = await Assert.ThrowsAsync<EmailProviderDeliveryException>(() =>
interrupted.SendAsync("user-1", new GmailSendRequest("recruiter@example.test", "Subject", "Body", null), default));
Assert.False(reauthorization.Uncertain);
Assert.Equal("reauthorization_required", reauthorization.Category);
}
private static IConfiguration Config() => new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Google:ClientId"] = "google-client-test",
["Microsoft:ClientId"] = "microsoft-client-test",
}).Build();
private static HttpResponseMessage Json(HttpStatusCode status, string body) => new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
private sealed class ClientFactory(HttpMessageHandler handler) : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new(handler, disposeHandler: false);
}
private sealed class Handler(Func<HttpRequestMessage, Task<HttpResponseMessage>> handler) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => handler(request);
}
}
@@ -27,6 +27,19 @@ public sealed class MicrosoftGraphProviderTests
Assert.NotNull(connection); Assert.NotNull(connection);
Assert.Equal("microsoft", connection!.ProviderKey); Assert.Equal("microsoft", connection!.ProviderKey);
Assert.Equal("user@outlook.test", connection.Address); Assert.Equal("user@outlook.test", connection.Address);
Assert.False(connection.CanSend);
}
[Fact]
public async Task GetConnectionAsync_reports_send_only_after_mail_send_consent()
{
var graph = new Mock<IMicrosoftGraphOAuthService>();
graph.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new JobTrackerApi.Models.MicrosoftGraphConnection { OwnerUserId = "user-1", MailAddress = "user@outlook.test", Scope = "Mail.Read Mail.Send" });
var connection = await new MicrosoftGraphProvider(graph.Object).GetConnectionAsync("user-1", default);
Assert.True(connection!.CanSend);
} }
[Fact] [Fact]
+1 -1
View File
@@ -39,7 +39,7 @@ public sealed class EmailController(IEmailProviderRegistry providers) : Controll
connection is not null, connection is not null,
connection?.Address, connection?.Address,
CanRead: connection is not null, CanRead: connection is not null,
CanSend: false)); CanSend: connection?.CanSend ?? false));
} }
return Ok(statuses); return Ok(statuses);
@@ -21,7 +21,7 @@ namespace JobTrackerApi.Services.EmailProviders
public async Task<EmailConnectionInfo?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken) public async Task<EmailConnectionInfo?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken)
{ {
var connection = await _gmail.GetConnectionAsync(ownerUserId, 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) public async Task<IReadOnlyList<EmailMessageSummary>> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
@@ -57,6 +57,12 @@ namespace JobTrackerApi.Services.EmailProviders
attachments); 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) private static EmailMessageSummary ToSummary(GmailMessageSummary m)
=> new(m.Id, m.ThreadId, m.Subject, m.From, m.To, m.Date, m.Snippet); => 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> /// <summary>Full message content (body + attachments metadata).</summary>
Task<EmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken); 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); 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); 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) private static EmailMessageSummary ToSummary(ImapMessageSummary m)
=> new(m.Id, m.ThreadKey, m.Subject, m.From, m.To, m.Date, m.Snippet); => 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) public async Task<EmailConnectionInfo?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken)
{ {
var connection = await _graph.GetConnectionAsync(ownerUserId, 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) public async Task<IReadOnlyList<EmailMessageSummary>> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
@@ -57,6 +57,12 @@ namespace JobTrackerApi.Services.EmailProviders
attachments); 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) private static EmailMessageSummary ToSummary(MicrosoftGraphMessageSummary m)
=> new(m.Id, m.ConversationId, m.Subject, m.From, m.To, m.Date, m.Snippet); => new(m.Id, m.ConversationId, m.Subject, m.From, m.To, m.Date, m.Snippet);
} }
+66 -1
View File
@@ -7,6 +7,8 @@ using JobTrackerApi.Models;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using JobTrackerApi.Services.EmailProviders;
using MimeKit;
namespace JobTrackerApi.Services; 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<GmailQueryMatchedMessage>> ListJobCandidateMessagesAsync(string ownerUserId, IEnumerable<string> queries, int maxResultsPerQuery, CancellationToken cancellationToken);
Task<IReadOnlyList<GmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken); Task<IReadOnlyList<GmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken);
Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, 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); 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 GmailQueryMatchedMessage(GmailMessageSummary Message, IReadOnlyList<string> MatchedQueries);
public sealed record GmailMessageAttachment(string? FileName, string? MimeType, long? SizeBytes, string? GmailAttachmentId, bool Inline); 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 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 internal sealed class GmailTokenResponse
{ {
@@ -41,7 +46,8 @@ internal sealed class GmailTokenResponse
public sealed class GmailOAuthService : IGmailOAuthService 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 IConfiguration _cfg;
private readonly JobTrackerContext _db; private readonly JobTrackerContext _db;
private readonly IDataProtector _protector; 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) private async Task<string> GetValidAccessTokenAsync(string ownerUserId, CancellationToken cancellationToken)
{ {
var connection = await _db.GmailConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, 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.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using JobTrackerApi.Services.EmailProviders;
namespace JobTrackerApi.Services; 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>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken);
Task<IReadOnlyList<MicrosoftGraphMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string conversationId, CancellationToken cancellationToken); Task<IReadOnlyList<MicrosoftGraphMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string conversationId, CancellationToken cancellationToken);
Task<MicrosoftGraphMessageDetail> GetMessageAsync(string ownerUserId, string messageId, 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 MicrosoftGraphOAuthExchangeResult(string MailAddress);
public sealed record MicrosoftGraphMessageSummary(string Id, string ConversationId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet); 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 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 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 internal sealed class MicrosoftGraphTokenResponse
{ {
@@ -42,7 +46,8 @@ internal sealed class MicrosoftGraphTokenResponse
/// </summary> /// </summary>
public sealed class MicrosoftGraphOAuthService : IMicrosoftGraphOAuthService 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 IConfiguration _cfg;
private readonly JobTrackerContext _db; private readonly JobTrackerContext _db;
private readonly IDataProtector _protector; 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) 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"; var url = $"https://graph.microsoft.com/v1.0/me/messages/{Uri.EscapeDataString(messageId)}/attachments?$select=id,name,contentType,size,isInline";
@@ -187,7 +187,7 @@ export default function CorrespondenceInboxPage() {
size="small" size="small"
color={provider.connected ? "success" : "default"} color={provider.connected ? "success" : "default"}
variant="outlined" variant="outlined"
label={`${provider.displayName}: ${provider.connected ? provider.address || "Connected" : "Not connected"}${provider.canSend ? " · Send enabled" : " · Read only"}`} label={`${provider.displayName}: ${provider.connected ? provider.address || "Connected" : "Not connected"}${provider.canSend ? " · Send access granted" : " · Read only"}`}
/> />
))} ))}
</Box> </Box>