feat(email): add durable send ledger
Tracks only idempotency and delivery metadata; recipient, subject, and body are excluded. No provider send path is enabled.
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user