ee5ef7e12a
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.
179 lines
9.4 KiB
C#
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.");
|
|
}
|
|
}
|