feat(email): add durable send ledger
CI and Deploy / test (pull_request) Failing after 1m21s
CI and Deploy / deploy (pull_request) Has been skipped

Tracks only idempotency and delivery metadata; recipient, subject, and body are excluded. No provider send path is enabled.
This commit is contained in:
cesnimda
2026-08-09 23:32:27 +02:00
parent 3a7e4f1088
commit 653f011be2
8 changed files with 3077 additions and 0 deletions
+21
View File
@@ -61,6 +61,7 @@ namespace JobTrackerApi.Data
public DbSet<InterviewPrepItem> InterviewPrepItems => Set<InterviewPrepItem>();
public DbSet<UserOperation> UserOperations => Set<UserOperation>();
public DbSet<UserNotification> UserNotifications => Set<UserNotification>();
public DbSet<EmailSendAttempt> EmailSendAttempts => Set<EmailSendAttempt>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -259,6 +260,26 @@ namespace JobTrackerApi.Data
.HasForeignKey<UserNotification>(x => x.OperationId)
.OnDelete(DeleteBehavior.SetNull);
modelBuilder.Entity<EmailSendAttempt>()
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<EmailSendAttempt>().Property(x => x.OwnerUserId).HasMaxLength(255);
modelBuilder.Entity<EmailSendAttempt>().Property(x => x.Provider).HasMaxLength(32);
modelBuilder.Entity<EmailSendAttempt>().Property(x => x.ClientRequestId).HasMaxLength(128);
modelBuilder.Entity<EmailSendAttempt>().Property(x => x.PayloadHash).HasMaxLength(64);
modelBuilder.Entity<EmailSendAttempt>().Property(x => x.Status).HasMaxLength(32);
modelBuilder.Entity<EmailSendAttempt>().Property(x => x.ProviderMessageId).HasMaxLength(256);
modelBuilder.Entity<EmailSendAttempt>().Property(x => x.FailureCategory).HasMaxLength(64);
modelBuilder.Entity<EmailSendAttempt>()
.HasIndex(x => new { x.OwnerUserId, x.ClientRequestId })
.IsUnique();
modelBuilder.Entity<EmailSendAttempt>()
.HasIndex(x => new { x.OwnerUserId, x.CreatedAtUtc });
modelBuilder.Entity<EmailSendAttempt>()
.HasOne(x => x.JobApplication)
.WithMany()
.HasForeignKey(x => x.JobApplicationId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<TailoredCvDraft>()
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,91 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations
{
/// <inheritdoc />
public partial class AddEmailSendAttempts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
if (ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase))
{
migrationBuilder.Sql("""
CREATE TABLE `EmailSendAttempts` (
`Id` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
`OwnerUserId` varchar(255) NOT NULL,
`JobApplicationId` int NOT NULL,
`Provider` varchar(32) NOT NULL,
`ClientRequestId` varchar(128) NOT NULL,
`PayloadHash` char(64) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
`Status` varchar(32) NOT NULL,
`ProviderMessageId` varchar(256) NULL,
`FailureCategory` varchar(64) NULL,
`CreatedAtUtc` datetime(6) NOT NULL,
`StartedAtUtc` datetime(6) NULL,
`CompletedAtUtc` datetime(6) NULL,
CONSTRAINT `PK_EmailSendAttempts` PRIMARY KEY (`Id`),
CONSTRAINT `FK_EmailSendAttempts_JobApplications_JobApplicationId`
FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
) CHARACTER SET=utf8mb4;
""");
}
else
{
migrationBuilder.CreateTable(
name: "EmailSendAttempts",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
OwnerUserId = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
JobApplicationId = table.Column<int>(type: "INTEGER", nullable: false),
Provider = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
ClientRequestId = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
PayloadHash = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
ProviderMessageId = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
FailureCategory = table.Column<string>(type: "TEXT", maxLength: 64, nullable: true),
CreatedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
StartedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: true),
CompletedAtUtc = table.Column<DateTime>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_EmailSendAttempts", x => x.Id);
table.ForeignKey(
name: "FK_EmailSendAttempts_JobApplications_JobApplicationId",
column: x => x.JobApplicationId,
principalTable: "JobApplications",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
}
migrationBuilder.CreateIndex(
name: "IX_EmailSendAttempts_JobApplicationId",
table: "EmailSendAttempts",
column: "JobApplicationId");
migrationBuilder.CreateIndex(
name: "IX_EmailSendAttempts_OwnerUserId_ClientRequestId",
table: "EmailSendAttempts",
columns: new[] { "OwnerUserId", "ClientRequestId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_EmailSendAttempts_OwnerUserId_CreatedAtUtc",
table: "EmailSendAttempts",
columns: new[] { "OwnerUserId", "CreatedAtUtc" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "EmailSendAttempts");
}
}
}
@@ -1098,6 +1098,69 @@ namespace JobTrackerApi.Migrations
b.ToTable("CvVariantVersions");
});
modelBuilder.Entity("JobTrackerApi.Models.EmailSendAttempt", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("ClientRequestId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<DateTime?>("CompletedAtUtc")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("FailureCategory")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("JobApplicationId")
.HasColumnType("INTEGER");
b.Property<string>("OwnerUserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("PayloadHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("ProviderMessageId")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime?>("StartedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("JobApplicationId");
b.HasIndex("OwnerUserId", "ClientRequestId")
.IsUnique();
b.HasIndex("OwnerUserId", "CreatedAtUtc");
b.ToTable("EmailSendAttempts");
});
modelBuilder.Entity("JobTrackerApi.Models.GmailConnection", b =>
{
b.Property<int>("Id")
@@ -2383,6 +2446,17 @@ namespace JobTrackerApi.Migrations
b.Navigation("CvVariant");
});
modelBuilder.Entity("JobTrackerApi.Models.EmailSendAttempt", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
.WithMany()
.HasForeignKey("JobApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("JobApplication");
});
modelBuilder.Entity("JobTrackerApi.Models.InterviewPrepItem", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
+27
View File
@@ -0,0 +1,27 @@
namespace JobTrackerApi.Models;
public static class EmailSendStatuses
{
public const string Pending = "pending";
public const string Sending = "sending";
public const string Sent = "sent";
public const string Failed = "failed";
public const string Uncertain = "uncertain";
}
public sealed class EmailSendAttempt
{
public Guid Id { get; set; }
public string OwnerUserId { get; set; } = string.Empty;
public int JobApplicationId { get; set; }
public JobApplication JobApplication { get; set; } = null!;
public string Provider { get; set; } = string.Empty;
public string ClientRequestId { get; set; } = string.Empty;
public string PayloadHash { get; set; } = string.Empty;
public string Status { get; set; } = EmailSendStatuses.Pending;
public string? ProviderMessageId { get; set; }
public string? FailureCategory { get; set; }
public DateTime CreatedAtUtc { get; set; }
public DateTime? StartedAtUtc { get; set; }
public DateTime? CompletedAtUtc { get; set; }
}
+1
View File
@@ -45,6 +45,7 @@ builder.Services.AddScoped<ICurrentUserService>(sp => sp.GetRequiredService<Curr
builder.Services.AddSingleton<BackgroundTenantRunner>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddScoped<UserOperationStore>();
builder.Services.AddScoped<EmailSendAttemptStore>();
builder.Services.AddScoped<AiOperationAdmission>();
builder.Services.AddScoped<StrategySnapshotService>();
builder.Services.AddSingleton<IAiOperationHandler, StrategySnapshotOperationHandler>();
@@ -0,0 +1,124 @@
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.");
}
}