Email Outbox Pattern¶
Reliable async email delivery using database persistence.
Overview¶
flowchart TB
subgraph Request["Request Handler"]
A[Create BackgroundTask] --> B[Save to DB]
end
subgraph Job["EmailSendJob"]
C[Claim Pending Task] --> D[Render Template]
D --> E[Send via Resend]
E -->|Success| F[Mark Completed]
E -->|Failure| G[Increment Retry]
end
B -.->|Hangfire Schedule| C Why Outbox?¶
| Problem | Solution |
|---|---|
| Email API down | Tasks retry automatically |
| Request timeout | Email sends async |
| Duplicate sends | Idempotent task processing |
| Lost emails | Database persistence |
BackgroundTask Entity¶
public sealed class BackgroundTask
{
public Guid BackgroundTaskId { get; set; }
public TaskType Type { get; set; }
public ObjectType ObjectType { get; set; }
public TaskStatus Status { get; set; }
public TaskPriority Priority { get; set; }
public string? Data { get; set; }
public DateTime ScheduledAt { get; set; }
public DateTime? CompletedAt { get; set; }
public DateTime? FailedAt { get; set; }
public int RetryCount { get; set; }
public string? LastError { get; set; }
public Guid? UserId { get; set; }
public DateTime CreatedDate { get; set; }
public DateTime? UpdatedDate { get; set; }
}
Note: BackgroundTask does not extend AuditableEntity. It has its own CreatedDate/UpdatedDate properties that are set directly in DataContext.SaveChangesAsync.
Task statuses:
| Status | Description |
|---|---|
Pending | Awaiting processing |
Processing | Claimed by a worker, being processed |
Completed | Successfully processed |
Failed | Permanently failed after max retries |
Email Types (ObjectType)¶
| Type | Purpose |
|---|---|
EmailConfirmation | Verify user email address |
PasswordReset | Password recovery link |
RegistrationExpiring | Warning before registration expires |
RegistrationExpired | Notification that registration was declined |
PendingUsersReminder | Admin reminder about pending user approvals |
Queueing an Email¶
var task = new BackgroundTask
{
Type = TaskType.EmailSend,
ObjectType = ObjectType.EmailConfirmation,
Priority = TaskPriority.Normal,
Status = TaskStatus.Pending,
Data = JsonSerializer.Serialize(new EmailConfirmationEmail(
to: user.Email,
language: "en",
appDomain: config.AppDomain,
confirmLink: confirmLink,
validityText: "24 hours",
isInitialRegistration: true
))
};
context.BackgroundTasks.Add(task);
await context.SaveChangesAsync();
EmailSendJob Processing¶
Extends BackgroundTaskProcessJob for concurrent-safe processing:
public sealed class EmailSendJob : BackgroundTaskProcessJob<EmailSendJob>
{
protected override TaskType TaskType => TaskType.EmailSend;
protected override int BatchSize => TaskConstants.EmailSend.BatchSize;
protected override async Task ProcessTaskAsync(BackgroundTask task, DateTime now)
{
var email = DeserializeEmail(task);
await _emailService.SendAsync(email, CancellationToken);
task.Status = TaskStatus.Completed;
task.CompletedAt = now;
}
}
The base class handles:
- Claiming tasks with
Processingstatus - Concurrency conflicts (another worker claimed task)
- Batch processing with configurable size
On failure, tasks are rescheduled with exponential backoff until max retries exceeded.
Schedule¶
Configurable via Hangfire:JobsCron:EmailSendJob in appsettings.
Template Rendering¶
Uses Blazor template engine with shared EmailLayout component:
// All templates use EmailLayout for consistent structure
<EmailLayout Language="@(Model?.Language ?? Language.English)" AppDomain="@Model?.AppDomain">
@if (Model?.Language == Language.Czech)
{
<h2>Czech content...</h2>
}
else
{
<h2>English content...</h2>
}
</EmailLayout>
Email models use the Language enum directly:
var email = new EmailConfirmationEmail(
user.Email,
user.Language, // Language enum, not string
emailConfig.AppDomain,
confirmLink,
validityText,
isInitialRegistration: true);
Related¶
- Background Jobs — Job scheduling
- Jobs Pattern — Job base classes
- Integrations — Resend email service