Files
jobtrackingapp/JobTrackerApi/Services/EmailSendAttemptStore.cs
T
cesnimda ee5ef7e12a fix(email): reconcile abandoned sends
Age stale pre-delivery attempts to failed and in-delivery attempts to uncertain without provider I/O. Notify each owner with content-free guidance and keep recovery idempotent across replicas and restarts.
2026-08-10 00:16:56 +02:00

179 lines
9.4 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 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<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.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<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);
public async Task<EmailSendAttemptRecoveryResult> 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<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.");
}
}