Skip to content

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 Processing status
  • 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);