Completing an account operation and delivering its email need not happen at the same moment. OfficeFighters separates message creation in EmailOutboxWriter from delivery in OutboxDispatcher and a MailKit SMTP adapter. The source exposes a concrete state machine rather than a background task that simply hopes to finish.
Persist the delivery intention
EmailOutboxWriter serializes a message, protects the payload and adds a Pending email_outbox row. It does not call SaveChanges. The producer can therefore save the business change and notification intention in one DbContext transaction. That producer transaction is essential; the presence of an outbox table alone does not establish atomicity.
A unique idempotency_key constrains duplicate intentions. Its value must identify the same logical notification across repeated execution. A newly random value on each execution would identify separate records rather than deduplicate the business action.
Protect the sensitive payload
The writer uses the outbox-v1 Data Protection purpose. The dispatcher uses the same purpose to recover the message. Losing the key ring makes pending payloads unreadable and produces a permanent rejection. Backup and deployment practices therefore need to preserve the required keys alongside the database.
Payload protection does not encrypt every column. Recipient and template metadata remain separate fields. Operational access and backup handling need to account for those fields too.
Claim work atomically
EmailOutboxWorker delegates a batch of up to twenty due rows on its roughly two-second cycle. The dispatcher reads candidate identifiers and then performs a conditional update for each one.
// OutboxDispatcher: only one worker claims this pending row.
var claimed = await db.EmailOutboxes
.Where(o => o.Id == id && o.Status == (short)OutboxStatus.Pending)
.ExecuteUpdateAsync(s => s
.SetProperty(o => o.Status, (short)OutboxStatus.Sending)
.SetProperty(o => o.NextAttemptAt, now), ct);
if (claimed != 1) continue;Two workers may read the same candidate, but only the worker that changes Pending to Sending proceeds normally. The affected-row count, rather than the earlier selection, establishes ownership.
Classify failures before retrying
SmtpEmailSender parses the recipient, builds text and HTML alternatives and connects using the configured STARTTLS policy. It uses a fifteen-second timeout. SMTP 5xx is classified as permanent; network and transient protocol failures can be retried. Error metadata is restricted instead of exposing complete provider details to the caller.
// OutboxDispatcher.Backoff: jitter is between 0 and 25 percent.
var baseSeconds = Math.Min(
10 * Math.Pow(2, Math.Max(0, attempt - 1)), 3600);
return TimeSpan.FromSeconds(
baseSeconds * (1 + Random.Shared.NextDouble() * 0.25));Each attempt increments AttemptCount. A temporary failure returns the row to Pending with a later due time. Eight attempts exhaust the limit. The formula starts with a ten-second exponential base and adds up to twenty-five percent jitter. The one-hour cap applies to the base, so the final jittered delay can exceed an hour.
Remove payloads at the appropriate state
Success records Sent, a sent timestamp and a provider identifier, then clears the protected payload. Permanent rejection clears it too. Exhausting retries for a temporary failure can retain the payload for the configured retention period, allowing the administrative retry path after a provider problem has been corrected.
Understand the crash window
A Sending row older than five minutes can return to Pending. This recovers work abandoned by a crashed process. There is still a window after SMTP acceptance and before the Sent update. A crash there can cause another accepted send. Conditional claims and unique producer keys solve important local races, but they do not prove exactly-once delivery through an external SMTP service.
The existing dispatcher tests use scripted senders for competing claims, temporary failure followed by success, permanent rejection, unreadable payloads and stale Sending recovery. Operationally, oldest-pending age and Failed counts provide more evidence than a worker heartbeat alone.
Kaynaklar / References
Microsoft Learn — Data Protection
More in the OfficeFighters series
- OfficeFighters — managing game assets with Supabase Storage and S3
- OfficeFighters — real-time game communication with SignalR and WebSocket
- OfficeFighters — Phaser, fixed-step simulation and snapshot interpolation
- OfficeFighters — JWT, refresh-token rotation and multi-tab session management
- OfficeFighters — PostgreSQL, EF Core and a DB-first data model