fix(email): gate draft attempt rotation
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:
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user