fix(email): gate draft attempt rotation
CI and Deploy / test (pull_request) Successful in 4m23s
CI and Deploy / deploy (pull_request) Has been skipped

Rotate persisted delivery identity only after the matching owner attempt is definitively failed. Keep stale, foreign, pending, sent, and uncertain drafts non-retryable.
This commit is contained in:
cesnimda
2026-08-10 10:21:37 +02:00
parent 114e3b66ba
commit 29de2632f6
4 changed files with 123 additions and 3 deletions
@@ -39,6 +39,7 @@ public sealed class EmailDraftsController(
string? ThreadId);
public sealed record UpdateDraftRequest(long Revision, string? To, string? Subject, string? BodyText);
public sealed record NewAttemptRequest(long Revision);
[HttpGet]
public async Task<ActionResult<IReadOnlyList<DraftDto>>> List(
@@ -165,6 +166,40 @@ public sealed class EmailDraftsController(
: NotFound();
}
[HttpPost("{id:guid}/new-attempt")]
public async Task<ActionResult<DraftDto>> NewAttempt(Guid id, NewAttemptRequest request, CancellationToken cancellationToken)
{
var ownerUserId = GetOwnerUserId();
if (ownerUserId is null) return Unauthorized();
if (request.Revision <= 0) return BadRequest("A positive revision is required.");
var draft = await db.EmailDrafts.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == id && item.OwnerUserId == ownerUserId, cancellationToken);
if (draft is null) return NotFound();
if (draft.Revision != request.Revision)
return Conflict(new ProblemDetails { Title = "Draft revision conflict", Detail = "Reload the latest draft before creating a new attempt." });
var failedAttempt = await db.EmailSendAttempts.AsNoTracking().AnyAsync(attempt =>
attempt.OwnerUserId == ownerUserId &&
attempt.ClientRequestId == draft.ClientRequestId &&
attempt.Status == EmailSendStatuses.Failed,
cancellationToken);
if (!failedAttempt)
return Conflict(new ProblemDetails { Title = "A new attempt is not allowed", Detail = "Only a definitively failed delivery can receive a new attempt identity." });
var newClientRequestId = Guid.NewGuid().ToString("D");
var now = timeProvider.GetUtcNow().UtcDateTime;
var affected = await db.EmailDrafts
.Where(item => item.Id == id && item.OwnerUserId == ownerUserId && item.Revision == request.Revision && item.ClientRequestId == draft.ClientRequestId)
.ExecuteUpdateAsync(setters => setters
.SetProperty(item => item.ClientRequestId, newClientRequestId)
.SetProperty(item => item.Revision, item => item.Revision + 1)
.SetProperty(item => item.UpdatedAtUtc, now), cancellationToken);
if (affected != 1)
return Conflict(new ProblemDetails { Title = "Draft revision conflict", Detail = "Reload the latest draft before creating a new attempt." });
return await Get(id, cancellationToken);
}
private string? GetOwnerUserId() =>
User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");