653f011be2
Tracks only idempotency and delivery metadata; recipient, subject, and body are excluded. No provider send path is enabled.
125 lines
6.3 KiB
C#
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.");
|
|
}
|
|
}
|