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
@@ -0,0 +1,104 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class EmailSendAttemptStoreTests
{
[Fact]
public async Task Creation_is_idempotent_and_rejects_payload_reuse()
{
await using var fixture = await Fixture.CreateAsync();
var store = fixture.Store("user-1");
var requestId = Guid.NewGuid().ToString();
var first = await store.CreateAsync(new(fixture.JobId, "gmail", requestId, Hash('a')), default);
var duplicate = await store.CreateAsync(new(fixture.JobId, "gmail", requestId, Hash('a')), default);
Assert.True(first.Created);
Assert.False(duplicate.Created);
Assert.Equal(first.Attempt.Id, duplicate.Attempt.Id);
await Assert.ThrowsAsync<EmailSendConflictException>(() =>
store.CreateAsync(new(fixture.JobId, "gmail", requestId, Hash('b')), default));
}
[Fact]
public async Task State_machine_never_restarts_terminal_or_uncertain_attempts()
{
await using var fixture = await Fixture.CreateAsync();
var store = fixture.Store("user-1");
var created = await store.CreateAsync(new(fixture.JobId, "microsoft", Guid.NewGuid().ToString(), Hash('c')), default);
Assert.Equal(1, await store.BeginAsync(created.Attempt.Id, default));
Assert.Equal(0, await store.BeginAsync(created.Attempt.Id, default));
Assert.Equal(1, await store.MarkUncertainAsync(created.Attempt.Id, "transport_interrupted", default));
Assert.Equal(0, await store.MarkSentAsync(created.Attempt.Id, "late-message", default));
var stored = await store.GetAsync(created.Attempt.Id, default);
Assert.Equal(EmailSendStatuses.Uncertain, stored!.Status);
Assert.Equal("transport_interrupted", stored.FailureCategory);
Assert.NotNull(stored.CompletedAtUtc);
}
[Fact]
public async Task Direct_attempt_ids_are_tenant_scoped_and_store_no_message_content()
{
await using var fixture = await Fixture.CreateAsync();
var ownerStore = fixture.Store("user-1");
var created = await ownerStore.CreateAsync(new(fixture.JobId, "gmail", Guid.NewGuid().ToString(), Hash('d')), default);
Assert.Null(await fixture.Store("user-2").GetAsync(created.Attempt.Id, default));
Assert.DoesNotContain(typeof(EmailSendAttempt).GetProperties(), property =>
property.Name.Contains("Subject", StringComparison.OrdinalIgnoreCase) ||
property.Name.Contains("Body", StringComparison.OrdinalIgnoreCase) ||
property.Name.Contains("Recipient", StringComparison.OrdinalIgnoreCase));
}
private static string Hash(char value) => new(value, 64);
private sealed class Fixture(SqliteConnection connection, DbContextOptions<JobTrackerContext> options, int jobId) : IAsyncDisposable
{
private readonly List<JobTrackerContext> contexts = new();
public int JobId { get; } = jobId;
public static async Task<Fixture> CreateAsync()
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(connection).Options;
await using var db = CreateDb(options, "user-1");
await db.Database.EnsureCreatedAsync();
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
db.Companies.Add(company);
await db.SaveChangesAsync();
var job = new JobApplication { JobTitle = "Backend", CompanyId = company.Id, OwnerUserId = "user-1" };
db.JobApplications.Add(job);
await db.SaveChangesAsync();
return new Fixture(connection, options, job.Id);
}
public EmailSendAttemptStore Store(string userId)
{
var db = CreateDb(options, userId);
contexts.Add(db);
return new EmailSendAttemptStore(db, TimeProvider.System);
}
private static JobTrackerContext CreateDb(DbContextOptions<JobTrackerContext> options, string userId)
{
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(service => service.UserId).Returns(userId);
return new JobTrackerContext(options, currentUser.Object);
}
public async ValueTask DisposeAsync()
{
foreach (var context in contexts) await context.DisposeAsync();
await connection.DisposeAsync();
}
}
}
+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.");
}
}