From 123fc5555a07036619ed49c3dea2b9573678b2d3 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 9 Aug 2026 23:57:38 +0200 Subject: [PATCH] feat(email): add explicit send API Require tenant-owned jobs, explicit confirmation, canonical request IDs, and rate limiting before provider delivery. Persist sent correspondence with a content-free idempotency ledger, and never retry uncertain outcomes automatically. --- JobTrackerApi.Tests/EmailControllerTests.cs | 6 +- .../EmailSendControllerTests.cs | 208 ++++++++++++++++++ JobTrackerApi/Controllers/EmailController.cs | 166 +++++++++++++- JobTrackerApi/Program.cs | 11 + .../Services/EmailSendAttemptStore.cs | 4 +- 5 files changed, 391 insertions(+), 4 deletions(-) create mode 100644 JobTrackerApi.Tests/EmailSendControllerTests.cs diff --git a/JobTrackerApi.Tests/EmailControllerTests.cs b/JobTrackerApi.Tests/EmailControllerTests.cs index 692b7d8..c011f73 100644 --- a/JobTrackerApi.Tests/EmailControllerTests.cs +++ b/JobTrackerApi.Tests/EmailControllerTests.cs @@ -3,6 +3,9 @@ using JobTrackerApi.Controllers; using JobTrackerApi.Services.EmailProviders; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using JobTrackerApi.Services; +using JobTrackerApi.Tests.TestSupport; using Xunit; namespace JobTrackerApi.Tests; @@ -67,7 +70,8 @@ public sealed class EmailControllerTests private static EmailController CreateController(params IEmailProvider[] providers) { - var controller = new EmailController(new EmailProviderRegistry(providers)); + var db = TestHostFactory.CreateInMemoryDb(); + var controller = new EmailController(new EmailProviderRegistry(providers), db, new EmailSendAttemptStore(db, TimeProvider.System), NullLogger.Instance); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext diff --git a/JobTrackerApi.Tests/EmailSendControllerTests.cs b/JobTrackerApi.Tests/EmailSendControllerTests.cs new file mode 100644 index 0000000..42df13b --- /dev/null +++ b/JobTrackerApi.Tests/EmailSendControllerTests.cs @@ -0,0 +1,208 @@ +using System.Security.Claims; +using JobTrackerApi.Controllers; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using JobTrackerApi.Services.EmailProviders; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class EmailSendControllerTests +{ + [Fact] + public async Task Confirmed_send_is_recorded_once_and_duplicate_returns_existing_result() + { + await using var fixture = await Fixture.CreateAsync(); + var provider = new FakeProvider(); + var controller = fixture.Controller(provider, "user-1"); + var request = Request(fixture.JobId, confirmed: true); + + var first = await controller.Send(request, default); + var duplicate = await controller.Send(request, default); + + Assert.Equal(EmailSendStatuses.Sent, Assert.IsType(Assert.IsType(first.Result).Value).Status); + Assert.True(Assert.IsType(Assert.IsType(duplicate.Result).Value).Duplicate); + Assert.Equal(1, provider.SendCount); + Assert.Equal(1, await fixture.Db.Correspondences.CountAsync()); + Assert.Equal(1, await fixture.Db.JobEvents.CountAsync(item => item.Type == "EmailSent")); + Assert.DoesNotContain("Body", (await fixture.Db.JobEvents.SingleAsync()).Note ?? string.Empty); + Assert.Equal(EmailSendStatuses.Sent, (await fixture.Db.EmailSendAttempts.AsNoTracking().SingleAsync()).Status); + } + + [Fact] + public async Task Missing_confirmation_or_cross_tenant_job_never_reserves_or_sends() + { + await using var fixture = await Fixture.CreateAsync(); + var provider = new FakeProvider(); + + var unconfirmed = await fixture.Controller(provider, "user-1").Send(Request(fixture.JobId, confirmed: false), default); + Assert.IsType(unconfirmed.Result); + + var other = await fixture.Controller(provider, "user-2").Send(Request(fixture.JobId, confirmed: true), default); + Assert.IsType(other.Result); + Assert.Equal(0, provider.SendCount); + Assert.Equal(0, await fixture.Db.EmailSendAttempts.IgnoreQueryFilters().CountAsync()); + } + + [Fact] + public async Task Known_rejection_is_failed_and_requires_a_new_request_id() + { + await using var fixture = await Fixture.CreateAsync(); + var provider = new FakeProvider(_ => throw new EmailProviderDeliveryException("provider_rejected", false, "Rejected")); + var controller = fixture.Controller(provider, "user-1"); + var request = Request(fixture.JobId, confirmed: true); + + var failed = Assert.IsType((await controller.Send(request, default)).Result); + var duplicate = Assert.IsType((await controller.Send(request, default)).Result); + + Assert.Equal(StatusCodes.Status502BadGateway, failed.StatusCode); + Assert.Equal(EmailSendStatuses.Failed, Assert.IsType(failed.Value).Status); + Assert.Equal(EmailSendStatuses.Failed, Assert.IsType(duplicate.Value).Status); + Assert.Equal(1, provider.SendCount); + Assert.Empty(await fixture.Db.Correspondences.ToListAsync()); + } + + [Fact] + public async Task Transport_interruption_is_uncertain_and_is_never_retried() + { + await using var fixture = await Fixture.CreateAsync(); + var provider = new FakeProvider(_ => throw new EmailProviderDeliveryException("transport_interrupted", true, "Uncertain")); + var controller = fixture.Controller(provider, "user-1"); + var request = Request(fixture.JobId, confirmed: true); + + var uncertain = Assert.IsType((await controller.Send(request, default)).Result); + var duplicate = Assert.IsType((await controller.Send(request, default)).Result); + + Assert.Equal(StatusCodes.Status409Conflict, uncertain.StatusCode); + Assert.Equal(EmailSendStatuses.Uncertain, Assert.IsType(uncertain.Value).Status); + Assert.Equal(EmailSendStatuses.Uncertain, Assert.IsType(duplicate.Value).Status); + Assert.Equal(1, provider.SendCount); + } + + [Fact] + public async Task Invalid_or_missing_fields_never_reserve_or_send() + { + await using var fixture = await Fixture.CreateAsync(); + var provider = new FakeProvider(); + var controller = fixture.Controller(provider, "user-1"); + + var invalid = new[] + { + Request(fixture.JobId, confirmed: true) with { Provider = null }, + Request(fixture.JobId, confirmed: true) with { ClientRequestId = null }, + Request(fixture.JobId, confirmed: true) with { To = null }, + Request(fixture.JobId, confirmed: true) with { Subject = null }, + Request(fixture.JobId, confirmed: true) with { BodyText = null }, + Request(fixture.JobId, confirmed: true) with { ThreadId = new string('x', 513) }, + }; + + foreach (var request in invalid) + Assert.IsType((await controller.Send(request, default)).Result); + + Assert.Equal(0, provider.SendCount); + Assert.Equal(0, await fixture.Db.EmailSendAttempts.CountAsync()); + } + + [Fact] + public async Task Connection_check_failure_is_definite_and_never_calls_send() + { + await using var fixture = await Fixture.CreateAsync(); + var provider = new FakeProvider(connectionError: new HttpRequestException("Synthetic connection failure")); + var controller = fixture.Controller(provider, "user-1"); + + var failed = Assert.IsType((await controller.Send(Request(fixture.JobId, confirmed: true), default)).Result); + + Assert.Equal(StatusCodes.Status502BadGateway, failed.StatusCode); + Assert.Equal(EmailSendStatuses.Failed, Assert.IsType(failed.Value).Status); + Assert.Equal(0, provider.SendCount); + Assert.Equal(EmailSendStatuses.Failed, (await fixture.Db.EmailSendAttempts.AsNoTracking().SingleAsync()).Status); + } + + private static EmailController.SendRequest Request(int jobId, bool confirmed) => new( + jobId, + "gmail", + "00000000-0000-0000-0000-000000000123", + "recruiter@example.test", + "Interview follow-up", + "Synthetic body", + "thread-1", + confirmed); + + private sealed class FakeProvider(Func? send = null, Exception? connectionError = null) : IEmailProvider + { + public int SendCount { get; private set; } + public string ProviderKey => "gmail"; + public Task GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken) => + connectionError is null + ? Task.FromResult(new("gmail", "owner@gmail.test", true)) + : Task.FromException(connectionError); + public Task> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task SendAsync(string ownerUserId, EmailDeliveryRequest request, CancellationToken cancellationToken) + { + SendCount++; + return Task.FromResult(send?.Invoke(request) ?? new EmailDeliveryResult("message-1", request.ThreadId)); + } + } + + private sealed class Fixture(SqliteConnection connection, DbContextOptions options, JobTrackerContext db, int jobId) : IAsyncDisposable + { + private readonly List contexts = new(); + public JobTrackerContext Db { get; } = db; + public int JobId { get; } = jobId; + + public static async Task CreateAsync() + { + var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; + var db = CreateDb(options, "user-1"); + await db.Database.EnsureCreatedAsync(); + var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; + db.Companies.Add(company); + await db.SaveChangesAsync(); + var job = new JobApplication { JobTitle = "Backend", CompanyId = company.Id, OwnerUserId = "user-1" }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + return new Fixture(connection, options, db, job.Id); + } + + public EmailController Controller(IEmailProvider provider, string userId) + { + var context = userId == "user-1" ? Db : CreateDb(options, userId); + if (!ReferenceEquals(context, Db)) contexts.Add(context); + return new EmailController(new EmailProviderRegistry(new[] { provider }), context, new EmailSendAttemptStore(context, TimeProvider.System), NullLogger.Instance) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) }, "test")) + } + } + }; + } + + private static JobTrackerContext CreateDb(DbContextOptions options, string userId) + { + var currentUser = new Mock(); + currentUser.SetupGet(service => service.UserId).Returns(userId); + return new JobTrackerContext(options, currentUser.Object); + } + + public async ValueTask DisposeAsync() + { + foreach (var context in contexts) await context.DisposeAsync(); + await Db.DisposeAsync(); + await connection.DisposeAsync(); + } + } +} diff --git a/JobTrackerApi/Controllers/EmailController.cs b/JobTrackerApi/Controllers/EmailController.cs index 0d521f8..cfb9fab 100644 --- a/JobTrackerApi/Controllers/EmailController.cs +++ b/JobTrackerApi/Controllers/EmailController.cs @@ -1,7 +1,16 @@ using System.Security.Claims; using JobTrackerApi.Services.EmailProviders; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.EntityFrameworkCore; +using System.Net.Mail; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; namespace JobTrackerApi.Controllers; @@ -20,9 +29,15 @@ public sealed record EmailMessageDetailDto( [ApiController] [Route("api/email")] [Authorize(AuthenticationSchemes = "local")] -public sealed class EmailController(IEmailProviderRegistry providers) : ControllerBase +public sealed class EmailController( + IEmailProviderRegistry providers, + JobTrackerContext db, + EmailSendAttemptStore attempts, + ILogger logger) : ControllerBase { public sealed record ProviderStatus(string Provider, string DisplayName, bool Connected, string? Address, bool CanRead, bool CanSend); + public sealed record SendRequest(int JobApplicationId, string? Provider, string? ClientRequestId, string? To, string? Subject, string? BodyText, string? ThreadId, bool Confirmed); + public sealed record SendResult(Guid AttemptId, string Status, bool Duplicate, string? ExternalMessageId, string? ExternalThreadId, string? FailureCategory); [HttpGet("providers")] public async Task>> GetProviders(CancellationToken cancellationToken) { @@ -110,9 +125,158 @@ public sealed class EmailController(IEmailProviderRegistry providers) : Controll detail.Attachments)); } + [HttpPost("send")] + [EnableRateLimiting("email-send")] + public async Task> Send([FromBody] SendRequest request, CancellationToken cancellationToken) + { + var ownerUserId = GetOwnerUserId(); + if (ownerUserId is null) return Unauthorized(); + if (!request.Confirmed) return BadRequest("Explicit send confirmation is required."); + if (request.JobApplicationId <= 0) return BadRequest("A valid job application is required."); + var provider = providers.Get(request.Provider); + if (provider is null) return BadRequest("Unknown email provider."); + if (!Guid.TryParse(request.ClientRequestId, out var requestId)) return BadRequest("clientRequestId must be a UUID."); + var recipient = request.To?.Trim(); + var subject = request.Subject?.Trim(); + var bodyText = request.BodyText; + var threadId = string.IsNullOrWhiteSpace(request.ThreadId) ? null : request.ThreadId.Trim(); + if (!MailAddress.TryCreate(recipient, out _) || recipient.Length > 320) return BadRequest("A valid recipient is required."); + if (string.IsNullOrWhiteSpace(subject) || subject.Length > 998) return BadRequest("Subject is required and must be at most 998 characters."); + if (string.IsNullOrWhiteSpace(bodyText) || bodyText.Length > 200_000) return BadRequest("Body is required and must be at most 200000 characters."); + if (threadId?.Length > 512) return BadRequest("Thread ID must be at most 512 characters."); + + var job = await db.JobApplications.Include(item => item.Company) + .FirstOrDefaultAsync(item => item.Id == request.JobApplicationId, cancellationToken); + if (job is null) return NotFound(); + + var normalizedProvider = provider.ProviderKey.ToLowerInvariant(); + var clientRequestId = requestId.ToString("D"); + var payloadHash = ComputePayloadHash(job.Id, normalizedProvider, recipient, subject, bodyText, threadId); + EmailSendAttemptCreation reservation; + try + { + reservation = await attempts.CreateAsync(new CreateEmailSendAttempt(job.Id, normalizedProvider, clientRequestId, payloadHash), cancellationToken); + } + catch (EmailSendConflictException ex) + { + return Conflict(new ProblemDetails { Title = "Idempotency conflict", Detail = ex.Message }); + } + + if (!reservation.Created) + { + var existing = reservation.Attempt; + if (existing.Status == EmailSendStatuses.Sent) + return Ok(new SendResult(existing.Id, existing.Status, true, existing.ProviderMessageId, null, existing.FailureCategory)); + return Conflict(new SendResult(existing.Id, existing.Status, true, existing.ProviderMessageId, null, existing.FailureCategory)); + } + + if (await attempts.BeginAsync(reservation.Attempt.Id, cancellationToken) != 1) + return Conflict(new SendResult(reservation.Attempt.Id, EmailSendStatuses.Pending, true, null, null, null)); + + EmailConnectionInfo? connection; + try + { + connection = await provider.GetConnectionAsync(ownerUserId, cancellationToken); + } + catch (OperationCanceledException) + { + await attempts.MarkFailedAsync(reservation.Attempt.Id, "cancelled_before_delivery", CancellationToken.None); + throw; + } + catch (Exception ex) + { + logger.LogWarning("Email connection check failed for attempt {AttemptId} ({ExceptionType})", reservation.Attempt.Id, ex.GetType().Name); + await attempts.MarkFailedAsync(reservation.Attempt.Id, "connection_check_failed", CancellationToken.None); + return StatusCode(StatusCodes.Status502BadGateway, + new SendResult(reservation.Attempt.Id, EmailSendStatuses.Failed, false, null, null, "connection_check_failed")); + } + if (connection is null || !connection.CanSend) + { + await attempts.MarkFailedAsync(reservation.Attempt.Id, "reauthorization_required", CancellationToken.None); + return Conflict(new SendResult(reservation.Attempt.Id, EmailSendStatuses.Failed, false, null, null, "reauthorization_required")); + } + + EmailDeliveryResult delivery; + try + { + delivery = await provider.SendAsync(ownerUserId, new EmailDeliveryRequest(recipient, subject, bodyText, threadId), cancellationToken); + } + catch (EmailProviderDeliveryException ex) + { + if (ex.Uncertain) await attempts.MarkUncertainAsync(reservation.Attempt.Id, ex.Category, CancellationToken.None); + else await attempts.MarkFailedAsync(reservation.Attempt.Id, ex.Category, CancellationToken.None); + var status = ex.Uncertain ? EmailSendStatuses.Uncertain : EmailSendStatuses.Failed; + return StatusCode(ex.Uncertain ? StatusCodes.Status409Conflict : StatusCodes.Status502BadGateway, + new SendResult(reservation.Attempt.Id, status, false, null, null, ex.Category)); + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected email delivery failure for attempt {AttemptId}", reservation.Attempt.Id); + await attempts.MarkUncertainAsync(reservation.Attempt.Id, "unexpected_delivery_error", CancellationToken.None); + return StatusCode(StatusCodes.Status409Conflict, + new SendResult(reservation.Attempt.Id, EmailSendStatuses.Uncertain, false, null, null, "unexpected_delivery_error")); + } + + try + { + await using var transaction = db.Database.IsRelational() ? await db.Database.BeginTransactionAsync(CancellationToken.None) : null; + db.Correspondences.Add(new Correspondence + { + JobApplicationId = job.Id, + From = "Me", + Direction = "outbound", + Subject = subject, + Channel = "Email", + ExternalMessageId = delivery.ExternalMessageId, + ExternalThreadId = delivery.ExternalThreadId ?? threadId, + ExternalFrom = connection.Address, + ExternalTo = recipient, + Provider = normalizedProvider, + Content = bodyText, + Date = DateTime.UtcNow, + }); + db.JobEvents.Add(new JobEvent + { + JobApplicationId = job.Id, + Type = "EmailSent", + NewValue = normalizedProvider, + Note = $"attempt:{reservation.Attempt.Id}", + At = DateTime.UtcNow, + }); + if (await attempts.MarkSentAsync(reservation.Attempt.Id, delivery.ExternalMessageId, CancellationToken.None) != 1) + throw new InvalidOperationException("The email attempt could not be finalized."); + await db.SaveChangesAsync(CancellationToken.None); + if (transaction is not null) await transaction.CommitAsync(CancellationToken.None); + } + catch (Exception ex) + { + logger.LogError(ex, "Email was accepted but local persistence failed for attempt {AttemptId}", reservation.Attempt.Id); + db.ChangeTracker.Clear(); + await attempts.MarkUncertainAsync(reservation.Attempt.Id, "local_persistence_failed", CancellationToken.None); + return StatusCode(StatusCodes.Status409Conflict, + new SendResult(reservation.Attempt.Id, EmailSendStatuses.Uncertain, false, delivery.ExternalMessageId, delivery.ExternalThreadId, "local_persistence_failed")); + } + + return Ok(new SendResult(reservation.Attempt.Id, EmailSendStatuses.Sent, false, delivery.ExternalMessageId, delivery.ExternalThreadId, null)); + } + private string? GetOwnerUserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"); + private static string ComputePayloadHash(int jobApplicationId, string provider, string recipient, string subject, string bodyText, string? threadId) + { + var canonical = JsonSerializer.Serialize(new + { + JobApplicationId = jobApplicationId, + Provider = provider, + To = recipient.ToLowerInvariant(), + Subject = subject, + Body = bodyText, + ThreadId = threadId ?? string.Empty, + }); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical))).ToLowerInvariant(); + } + private static string GetDisplayName(string provider) => provider.ToLowerInvariant() switch { "gmail" => "Gmail", diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index c7ed338..fb07049 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -429,6 +429,17 @@ builder.Services.AddRateLimiter(options => QueueLimit = 0, })); + options.AddPolicy("email-send", context => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: $"email-send:{context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? context.User.FindFirst("sub")?.Value ?? context.Connection.RemoteIpAddress?.ToString() ?? "unknown"}", + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 10, + Window = TimeSpan.FromMinutes(1), + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = 0, + })); + options.AddPolicy("public-pdf", context => RateLimitPartition.GetFixedWindowLimiter( partitionKey: $"public-pdf:{context.Request.RouteValues["slug"]?.ToString() ?? "unknown"}", diff --git a/JobTrackerApi/Services/EmailSendAttemptStore.cs b/JobTrackerApi/Services/EmailSendAttemptStore.cs index a4d115b..f68af79 100644 --- a/JobTrackerApi/Services/EmailSendAttemptStore.cs +++ b/JobTrackerApi/Services/EmailSendAttemptStore.cs @@ -26,7 +26,7 @@ public sealed class EmailSendAttemptStore(JobTrackerContext db, TimeProvider tim if (!await db.JobApplications.AnyAsync(job => job.Id == request.JobApplicationId, cancellationToken)) throw new InvalidOperationException("The job application does not exist in the current owner scope."); - var existing = await db.EmailSendAttempts.FirstOrDefaultAsync(item => item.ClientRequestId == request.ClientRequestId, cancellationToken); + var existing = await db.EmailSendAttempts.AsNoTracking().FirstOrDefaultAsync(item => item.ClientRequestId == request.ClientRequestId, cancellationToken); if (existing is not null) return Existing(existing, request.PayloadHash); var attempt = new EmailSendAttempt @@ -48,7 +48,7 @@ public sealed class EmailSendAttemptStore(JobTrackerContext db, TimeProvider tim catch (DbUpdateException) { db.Entry(attempt).State = EntityState.Detached; - existing = await db.EmailSendAttempts.FirstOrDefaultAsync(item => item.ClientRequestId == request.ClientRequestId, cancellationToken); + existing = await db.EmailSendAttempts.AsNoTracking().FirstOrDefaultAsync(item => item.ClientRequestId == request.ClientRequestId, cancellationToken); if (existing is not null) return Existing(existing, request.PayloadHash); throw; }