Skip to content

Email Outbox Pattern

Reliable async email delivery using database persistence.

Overview

flowchart TB
    subgraph Request["Request Handler"]
        A[Create AppTask] --> B[Save to DB]
    end

    subgraph Job["EmailSendJob"]
        C[Query Pending Tasks] --> 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

AppTask Entity

public class AppTask : AuditableEntity
{
    public Guid TaskId { 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; }
}

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

Queueing an Email

var task = new AppTask
{
    Type = TaskType.EmailSend,
    ObjectType = ObjectType.EmailConfirmation,
    Priority = TaskPriority.Normal,
    Status = TaskStatus.Pending,
    Data = JsonSerializer.Serialize(new EmailConfirmationData
    {
        UserId = user.UserId,
        Token = confirmationToken,
        Email = user.Email
    })
};

context.Tasks.Add(task);
await context.SaveChangesAsync();

EmailSendJob Processing

Processes tasks one at a time in priority order:

var task = await context.Tasks
    .Where(t => t.Type == TaskType.EmailSend
        && t.Status == TaskStatus.Pending
        && t.ScheduledAt <= now)
    .OrderByDescending(t => t.Priority)
    .ThenBy(t => t.CreatedDate)
    .FirstOrDefaultAsync(ct);

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:

var html = await templateRenderer.RenderAsync<EmailConfirmationTemplate>(
    new EmailConfirmationModel
    {
        UserName = user.Nickname,
        ConfirmUrl = $"{appDomain}/confirm-email?token={token}",
        Language = user.Language
    });