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 record EmailSendAttemptRecoveryResult(int FailedPending, int UncertainSending); public sealed class EmailSendConflictException(string message) : InvalidOperationException(message); public sealed class EmailSendAttemptStore(JobTrackerContext db, TimeProvider timeProvider) { public static readonly TimeSpan AbandonedAge = TimeSpan.FromMinutes(15); 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); public async Task ReconcileAbandonedAsync(CancellationToken cancellationToken) { var now = UtcNow; var cutoff = now - AbandonedAge; var candidates = await db.EmailSendAttempts.IgnoreQueryFilters().AsNoTracking() .Where(item => (item.Status == EmailSendStatuses.Pending && item.CreatedAtUtc <= cutoff) || (item.Status == EmailSendStatuses.Sending && (item.StartedAtUtc ?? item.CreatedAtUtc) <= cutoff)) .Select(item => new { item.Id, item.OwnerUserId, item.Status }) .ToListAsync(cancellationToken); if (candidates.Count == 0) return new EmailSendAttemptRecoveryResult(0, 0); await using var transaction = db.Database.IsRelational() ? await db.Database.BeginTransactionAsync(cancellationToken) : null; var failedPending = 0; var uncertainSending = 0; foreach (var candidate in candidates) { var failed = candidate.Status == EmailSendStatuses.Pending; var terminalStatus = failed ? EmailSendStatuses.Failed : EmailSendStatuses.Uncertain; var failureCategory = failed ? "process_stopped_before_delivery" : "process_interrupted"; var affected = await db.EmailSendAttempts.IgnoreQueryFilters() .Where(item => item.Id == candidate.Id && item.Status == candidate.Status && (failed ? item.CreatedAtUtc <= cutoff : (item.StartedAtUtc ?? item.CreatedAtUtc) <= cutoff)) .ExecuteUpdateAsync(setters => setters .SetProperty(item => item.Status, terminalStatus) .SetProperty(item => item.FailureCategory, failureCategory) .SetProperty(item => item.CompletedAtUtc, now), cancellationToken); if (affected != 1) continue; if (failed) failedPending++; else uncertainSending++; db.UserNotifications.Add(new UserNotification { Id = Guid.NewGuid(), OwnerUserId = candidate.OwnerUserId, Kind = failed ? "email.send.stopped" : "email.send.uncertain", Title = failed ? "Email send stopped" : "Check email delivery", Message = failed ? "An email attempt stopped before provider delivery. Review the draft before trying again." : "Email delivery could not be confirmed after an interruption. Check the provider Sent folder and do not resend automatically.", LinkPath = "/correspondence", CreatedAtUtc = now, }); } await db.SaveChangesAsync(cancellationToken); if (transaction is not null) await transaction.CommitAsync(cancellationToken); return new EmailSendAttemptRecoveryResult(failedPending, uncertainSending); } 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."); } }