Files
jobtrackingapp/JobTrackerApi/Services/EmailSendAttemptStore.cs
T
cesnimda 653f011be2
CI and Deploy / test (pull_request) Failing after 1m21s
CI and Deploy / deploy (pull_request) Has been skipped
feat(email): add durable send ledger
Tracks only idempotency and delivery metadata; recipient, subject, and body are excluded. No provider send path is enabled.
2026-08-09 23:32:27 +02:00

125 lines
6.3 KiB
C#

using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
public sealed record CreateEmailSendAttempt(int JobApplicationId, string Provider, string ClientRequestId, string PayloadHash);
public sealed record EmailSendAttemptCreation(EmailSendAttempt Attempt, bool Created);
public sealed class EmailSendConflictException(string message) : InvalidOperationException(message);
public sealed class EmailSendAttemptStore(JobTrackerContext db, TimeProvider timeProvider)
{
private DateTime UtcNow => timeProvider.GetUtcNow().UtcDateTime;
public Task<EmailSendAttempt?> GetAsync(Guid id, CancellationToken cancellationToken)
{
EnsureOwnerScope();
return db.EmailSendAttempts.AsNoTracking().FirstOrDefaultAsync(item => item.Id == id, cancellationToken);
}
public async Task<EmailSendAttemptCreation> CreateAsync(CreateEmailSendAttempt request, CancellationToken cancellationToken)
{
var ownerUserId = db.CurrentUserId ?? throw new InvalidOperationException("Email send creation requires an authenticated owner scope.");
Validate(request);
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);
if (existing is not null) return Existing(existing, request.PayloadHash);
var attempt = new EmailSendAttempt
{
Id = Guid.NewGuid(),
OwnerUserId = ownerUserId,
JobApplicationId = request.JobApplicationId,
Provider = request.Provider.Trim().ToLowerInvariant(),
ClientRequestId = request.ClientRequestId.Trim(),
PayloadHash = request.PayloadHash.Trim().ToLowerInvariant(),
CreatedAtUtc = UtcNow,
};
db.EmailSendAttempts.Add(attempt);
try
{
await db.SaveChangesAsync(cancellationToken);
return new EmailSendAttemptCreation(attempt, true);
}
catch (DbUpdateException)
{
db.Entry(attempt).State = EntityState.Detached;
existing = await db.EmailSendAttempts.FirstOrDefaultAsync(item => item.ClientRequestId == request.ClientRequestId, cancellationToken);
if (existing is not null) return Existing(existing, request.PayloadHash);
throw;
}
}
public Task<int> BeginAsync(Guid id, CancellationToken cancellationToken)
{
EnsureOwnerScope();
var now = UtcNow;
return db.EmailSendAttempts
.Where(item => item.Id == id && item.Status == EmailSendStatuses.Pending)
.ExecuteUpdateAsync(setters => setters
.SetProperty(item => item.Status, EmailSendStatuses.Sending)
.SetProperty(item => item.StartedAtUtc, now), cancellationToken);
}
public Task<int> MarkSentAsync(Guid id, string? providerMessageId, CancellationToken cancellationToken) =>
CompleteAsync(id, EmailSendStatuses.Sent, providerMessageId, null, cancellationToken);
public Task<int> MarkFailedAsync(Guid id, string failureCategory, CancellationToken cancellationToken) =>
CompleteAsync(id, EmailSendStatuses.Failed, null, failureCategory, cancellationToken);
public Task<int> MarkUncertainAsync(Guid id, string failureCategory, CancellationToken cancellationToken) =>
CompleteAsync(id, EmailSendStatuses.Uncertain, null, failureCategory, cancellationToken);
private Task<int> CompleteAsync(Guid id, string status, string? providerMessageId, string? failureCategory, CancellationToken cancellationToken)
{
EnsureOwnerScope();
ValidateOptional(providerMessageId, 256, nameof(providerMessageId));
ValidateOptional(failureCategory, 64, nameof(failureCategory));
var now = UtcNow;
return db.EmailSendAttempts
.Where(item => item.Id == id && item.Status == EmailSendStatuses.Sending)
.ExecuteUpdateAsync(setters => setters
.SetProperty(item => item.Status, status)
.SetProperty(item => item.ProviderMessageId, providerMessageId)
.SetProperty(item => item.FailureCategory, failureCategory)
.SetProperty(item => item.CompletedAtUtc, now), cancellationToken);
}
private static EmailSendAttemptCreation Existing(EmailSendAttempt existing, string payloadHash)
{
if (!string.Equals(existing.PayloadHash, payloadHash.Trim(), StringComparison.OrdinalIgnoreCase))
throw new EmailSendConflictException("The client request ID was already used for different email content.");
return new EmailSendAttemptCreation(existing, false);
}
private static void Validate(CreateEmailSendAttempt request)
{
if (request.JobApplicationId <= 0) throw new ArgumentOutOfRangeException(nameof(request.JobApplicationId));
ValidateRequired(request.Provider, 32, nameof(request.Provider));
ValidateRequired(request.ClientRequestId, 128, nameof(request.ClientRequestId));
ValidateRequired(request.PayloadHash, 64, nameof(request.PayloadHash));
if (!Guid.TryParse(request.ClientRequestId, out _)) throw new ArgumentException("Client request ID must be a UUID.", nameof(request.ClientRequestId));
if (request.PayloadHash.Length != 64 || request.PayloadHash.Any(value => !Uri.IsHexDigit(value)))
throw new ArgumentException("Payload hash must be a SHA-256 hex digest.", nameof(request.PayloadHash));
}
private static void ValidateRequired(string value, int maxLength, string name)
{
if (string.IsNullOrWhiteSpace(value) || value.Trim().Length > maxLength) throw new ArgumentException($"{name} is required and must be at most {maxLength} characters.", name);
}
private static void ValidateOptional(string? value, int maxLength, string name)
{
if (value?.Length > maxLength) throw new ArgumentException($"{name} must be at most {maxLength} characters.", name);
}
private void EnsureOwnerScope()
{
if (string.IsNullOrWhiteSpace(db.CurrentUserId)) throw new InvalidOperationException("Email send access requires an authenticated owner scope.");
}
}