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.
This commit is contained in:
cesnimda
2026-08-09 23:57:38 +02:00
parent d44c8ceb08
commit 123fc5555a
5 changed files with 391 additions and 4 deletions
+5 -1
View File
@@ -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<EmailController>.Instance);
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
@@ -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<EmailController.SendResult>(Assert.IsType<OkObjectResult>(first.Result).Value).Status);
Assert.True(Assert.IsType<EmailController.SendResult>(Assert.IsType<OkObjectResult>(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<BadRequestObjectResult>(unconfirmed.Result);
var other = await fixture.Controller(provider, "user-2").Send(Request(fixture.JobId, confirmed: true), default);
Assert.IsType<NotFoundResult>(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<ObjectResult>((await controller.Send(request, default)).Result);
var duplicate = Assert.IsType<ConflictObjectResult>((await controller.Send(request, default)).Result);
Assert.Equal(StatusCodes.Status502BadGateway, failed.StatusCode);
Assert.Equal(EmailSendStatuses.Failed, Assert.IsType<EmailController.SendResult>(failed.Value).Status);
Assert.Equal(EmailSendStatuses.Failed, Assert.IsType<EmailController.SendResult>(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<ObjectResult>((await controller.Send(request, default)).Result);
var duplicate = Assert.IsType<ConflictObjectResult>((await controller.Send(request, default)).Result);
Assert.Equal(StatusCodes.Status409Conflict, uncertain.StatusCode);
Assert.Equal(EmailSendStatuses.Uncertain, Assert.IsType<EmailController.SendResult>(uncertain.Value).Status);
Assert.Equal(EmailSendStatuses.Uncertain, Assert.IsType<EmailController.SendResult>(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<BadRequestObjectResult>((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<ObjectResult>((await controller.Send(Request(fixture.JobId, confirmed: true), default)).Result);
Assert.Equal(StatusCodes.Status502BadGateway, failed.StatusCode);
Assert.Equal(EmailSendStatuses.Failed, Assert.IsType<EmailController.SendResult>(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<EmailDeliveryRequest, EmailDeliveryResult>? send = null, Exception? connectionError = null) : IEmailProvider
{
public int SendCount { get; private set; }
public string ProviderKey => "gmail";
public Task<EmailConnectionInfo?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken) =>
connectionError is null
? Task.FromResult<EmailConnectionInfo?>(new("gmail", "owner@gmail.test", true))
: Task.FromException<EmailConnectionInfo?>(connectionError);
public Task<IReadOnlyList<EmailMessageSummary>> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<IReadOnlyList<EmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<EmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken) => throw new NotSupportedException();
public Task<EmailDeliveryResult> 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<JobTrackerContext> options, JobTrackerContext db, int jobId) : IAsyncDisposable
{
private readonly List<JobTrackerContext> contexts = new();
public JobTrackerContext Db { get; } = db;
public int JobId { get; } = jobId;
public static async Task<Fixture> CreateAsync()
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<JobTrackerContext>().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<EmailController>.Instance)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) }, "test"))
}
}
};
}
private static JobTrackerContext CreateDb(DbContextOptions<JobTrackerContext> options, string userId)
{
var currentUser = new Mock<ICurrentUserService>();
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();
}
}
}
+165 -1
View File
@@ -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<EmailController> 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<ActionResult<IReadOnlyList<ProviderStatus>>> GetProviders(CancellationToken cancellationToken)
{
@@ -110,9 +125,158 @@ public sealed class EmailController(IEmailProviderRegistry providers) : Controll
detail.Attachments));
}
[HttpPost("send")]
[EnableRateLimiting("email-send")]
public async Task<ActionResult<SendResult>> 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",
+11
View File
@@ -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"}",
@@ -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;
}