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 GetAsync(Guid id, CancellationToken cancellationToken) { EnsureOwnerScope(); return db.EmailSendAttempts.AsNoTracking().FirstOrDefaultAsync(item => item.Id == id, cancellationToken); } public async Task 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.AsNoTracking().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.AsNoTracking().FirstOrDefaultAsync(item => item.ClientRequestId == request.ClientRequestId, cancellationToken); if (existing is not null) return Existing(existing, request.PayloadHash); throw; } } public Task 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 MarkSentAsync(Guid id, string? providerMessageId, CancellationToken cancellationToken) => CompleteAsync(id, EmailSendStatuses.Sent, providerMessageId, null, cancellationToken); public Task MarkFailedAsync(Guid id, string failureCategory, CancellationToken cancellationToken) => CompleteAsync(id, EmailSendStatuses.Failed, null, failureCategory, cancellationToken); public Task MarkUncertainAsync(Guid id, string failureCategory, CancellationToken cancellationToken) => CompleteAsync(id, EmailSendStatuses.Uncertain, null, failureCategory, cancellationToken); private Task 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."); } }