From e9937accd8c4cad63db2998bbd73add95f3792c1 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 9 Aug 2026 23:47:21 +0200 Subject: [PATCH] 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. --- JobTrackerApi.Tests/EmailControllerTests.cs | 7 +- .../EmailProviderDeliveryTests.cs | 146 ++++++++++++++++++ .../MicrosoftGraphProviderTests.cs | 13 ++ JobTrackerApi/Controllers/EmailController.cs | 2 +- .../Services/EmailProviders/GmailProvider.cs | 8 +- .../Services/EmailProviders/IEmailProvider.cs | 14 +- .../Services/EmailProviders/ImapProvider.cs | 3 + .../EmailProviders/MicrosoftGraphProvider.cs | 8 +- JobTrackerApi/Services/GmailOAuthService.cs | 67 +++++++- .../Services/MicrosoftGraphOAuthService.cs | 70 ++++++++- .../src/views/CorrespondenceInboxPage.tsx | 2 +- 11 files changed, 331 insertions(+), 9 deletions(-) create mode 100644 JobTrackerApi.Tests/EmailProviderDeliveryTests.cs diff --git a/JobTrackerApi.Tests/EmailControllerTests.cs b/JobTrackerApi.Tests/EmailControllerTests.cs index ab132bb..692b7d8 100644 --- a/JobTrackerApi.Tests/EmailControllerTests.cs +++ b/JobTrackerApi.Tests/EmailControllerTests.cs @@ -12,7 +12,7 @@ public sealed class EmailControllerTests [Fact] 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 controller = CreateController(gmail, outlook); @@ -27,7 +27,7 @@ public sealed class EmailControllerTests Assert.Equal("Gmail", status.DisplayName); Assert.True(status.Connected); Assert.True(status.CanRead); - Assert.False(status.CanSend); + Assert.True(status.CanSend); }, status => { @@ -113,5 +113,8 @@ public sealed class EmailControllerTests Array.Empty(), Array.Empty())); } + + public Task SendAsync(string ownerUserId, EmailDeliveryRequest request, CancellationToken cancellationToken) => + Task.FromResult(new EmailDeliveryResult("message-1", "thread-1")); } } diff --git a/JobTrackerApi.Tests/EmailProviderDeliveryTests.cs b/JobTrackerApi.Tests/EmailProviderDeliveryTests.cs new file mode 100644 index 0000000..262be67 --- /dev/null +++ b/JobTrackerApi.Tests/EmailProviderDeliveryTests.cs @@ -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(() => + 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(() => + 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(() => + 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 + { + ["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> handler) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => handler(request); + } +} diff --git a/JobTrackerApi.Tests/MicrosoftGraphProviderTests.cs b/JobTrackerApi.Tests/MicrosoftGraphProviderTests.cs index 41ab0f7..73abe58 100644 --- a/JobTrackerApi.Tests/MicrosoftGraphProviderTests.cs +++ b/JobTrackerApi.Tests/MicrosoftGraphProviderTests.cs @@ -27,6 +27,19 @@ public sealed class MicrosoftGraphProviderTests Assert.NotNull(connection); Assert.Equal("microsoft", connection!.ProviderKey); 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(); + graph.Setup(service => service.GetConnectionAsync("user-1", It.IsAny())) + .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] diff --git a/JobTrackerApi/Controllers/EmailController.cs b/JobTrackerApi/Controllers/EmailController.cs index 22a1764..0d521f8 100644 --- a/JobTrackerApi/Controllers/EmailController.cs +++ b/JobTrackerApi/Controllers/EmailController.cs @@ -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); diff --git a/JobTrackerApi/Services/EmailProviders/GmailProvider.cs b/JobTrackerApi/Services/EmailProviders/GmailProvider.cs index 4a1c5dc..1a310ce 100644 --- a/JobTrackerApi/Services/EmailProviders/GmailProvider.cs +++ b/JobTrackerApi/Services/EmailProviders/GmailProvider.cs @@ -21,7 +21,7 @@ namespace JobTrackerApi.Services.EmailProviders public async Task 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> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken) @@ -57,6 +57,12 @@ namespace JobTrackerApi.Services.EmailProviders attachments); } + public async Task 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); } diff --git a/JobTrackerApi/Services/EmailProviders/IEmailProvider.cs b/JobTrackerApi/Services/EmailProviders/IEmailProvider.cs index c7a448e..a46c163 100644 --- a/JobTrackerApi/Services/EmailProviders/IEmailProvider.cs +++ b/JobTrackerApi/Services/EmailProviders/IEmailProvider.cs @@ -23,9 +23,21 @@ namespace JobTrackerApi.Services.EmailProviders /// Full message content (body + attachments metadata). Task GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken); + + Task 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); diff --git a/JobTrackerApi/Services/EmailProviders/ImapProvider.cs b/JobTrackerApi/Services/EmailProviders/ImapProvider.cs index b2c264e..9e861e0 100644 --- a/JobTrackerApi/Services/EmailProviders/ImapProvider.cs +++ b/JobTrackerApi/Services/EmailProviders/ImapProvider.cs @@ -57,6 +57,9 @@ namespace JobTrackerApi.Services.EmailProviders attachments); } + public Task 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); } diff --git a/JobTrackerApi/Services/EmailProviders/MicrosoftGraphProvider.cs b/JobTrackerApi/Services/EmailProviders/MicrosoftGraphProvider.cs index da2ee06..a287806 100644 --- a/JobTrackerApi/Services/EmailProviders/MicrosoftGraphProvider.cs +++ b/JobTrackerApi/Services/EmailProviders/MicrosoftGraphProvider.cs @@ -21,7 +21,7 @@ namespace JobTrackerApi.Services.EmailProviders public async Task 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> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken) @@ -57,6 +57,12 @@ namespace JobTrackerApi.Services.EmailProviders attachments); } + public async Task 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); } diff --git a/JobTrackerApi/Services/GmailOAuthService.cs b/JobTrackerApi/Services/GmailOAuthService.cs index c6ef0a4..5570189 100644 --- a/JobTrackerApi/Services/GmailOAuthService.cs +++ b/JobTrackerApi/Services/GmailOAuthService.cs @@ -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> ListJobCandidateMessagesAsync(string ownerUserId, IEnumerable queries, int maxResultsPerQuery, CancellationToken cancellationToken); Task> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken); Task GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken); + Task 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 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 Labels, IReadOnlyList 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 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 GetValidAccessTokenAsync(string ownerUserId, CancellationToken cancellationToken) { var connection = await _db.GmailConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken); diff --git a/JobTrackerApi/Services/MicrosoftGraphOAuthService.cs b/JobTrackerApi/Services/MicrosoftGraphOAuthService.cs index fc296fb..9df2f08 100644 --- a/JobTrackerApi/Services/MicrosoftGraphOAuthService.cs +++ b/JobTrackerApi/Services/MicrosoftGraphOAuthService.cs @@ -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> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken); Task> ListThreadMessagesAsync(string ownerUserId, string conversationId, CancellationToken cancellationToken); Task GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken); + Task 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 Labels, IReadOnlyList 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 /// 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 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> 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"; diff --git a/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx b/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx index fc5b26b..2c4f963 100644 --- a/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx +++ b/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx @@ -187,7 +187,7 @@ export default function CorrespondenceInboxPage() { size="small" color={provider.connected ? "success" : "default"} 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"}`} /> ))}