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]
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<string>(),
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.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<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]