Email sending infrastructure: outbox pattern, IEmailSender (Console/Brevo), EmailOutboxMessage entity, background processor, configuration, DI registration. Use when working on email delivery, outbox processing, retry logic, or adding new email providers.
BosDAT uses the transactional outbox pattern: emails are queued to the database, then a background worker renders templates and delivers via a provider (Console or Brevo).
Service code
└── IEmailService.QueueEmailAsync() ← writes to DB
└── EmailOutboxMessage (Status=Pending)
└── EmailOutboxProcessorBackgroundService (Worker)
├── IEmailTemplateRenderer.RenderAsync() ← Razor → HTML
└── IEmailSender.SendAsync() ← Console or Brevo
| File | Purpose |
|---|---|
Core/Entities/EmailOutboxMessage.cs | Rich domain entity with state machine |
Core/Enums/EmailStatus.cs |
| Pending, Processing, Sent, Failed, DeadLetter |
Core/Interfaces/Services/IEmailSender.cs | Provider abstraction (single + batch) |
Core/Interfaces/Services/IEmailService.cs | Queue entry-point |
Infrastructure/Email/ConsoleEmailSender.cs | Dev provider — logs to console |
Infrastructure/Email/BrevoEmailSender.cs | Prod provider — Brevo SMTP API |
Infrastructure/Email/EmailSettings.cs | Configuration classes |
Infrastructure/Services/EmailService.cs | Outbox queuing implementation |
Worker/Services/EmailOutboxProcessorBackgroundService.cs | Background processor |
Worker/Configuration/WorkerSettings.cs | Polling interval, batch size |
Rich domain model with factory method and state transitions:
public class EmailOutboxMessage : BaseEntity
{
// Content
public string To { get; private set; }
public string Subject { get; private set; }
public string TemplateName { get; private set; } // e.g. "InvitationEmail"
public string TemplateDataJson { get; private set; } // Serialized model
// Status tracking
public EmailStatus Status { get; private set; }
public int RetryCount { get; private set; }
public DateTime? NextAttemptAtUtc { get; private set; }
// Results
public string? ProviderMessageId { get; private set; }
public string? LastError { get; private set; } // Max 2000 chars
public DateTime? SentAtUtc { get; private set; }
public uint ConcurrencyToken { get; set; } // PostgreSQL xmin row version
}
Create() → Pending
└── MarkProcessing() → Processing
├── MarkSent(messageId) → Sent ✓
└── MarkFailed(error) → Failed (retry scheduled)
└── after 5 retries → DeadLetter ✗
Exponential backoff: 5^retryCount minutes (5, 25, 125, 625, 3125 min). After 5 failures → DeadLetter.
email_outbox_messagesix_email_outbox_status_next_attempt on (Status, NextAttemptAtUtc)xmin row version prevents race conditionsjsonbpublic interface IEmailSender
{
Task<string> SendAsync(string to, string subject, string htmlBody,
CancellationToken cancellationToken = default);
Task<IReadOnlyList<string>> SendBatchAsync(IReadOnlyList<EmailMessage> messages,
CancellationToken cancellationToken = default);
}
public record EmailMessage(string To, string Subject, string HtmlBody);
Returns provider message IDs for tracking.
Logs emails to console instead of sending. Generates console-{guid} message IDs.
SingletonEmailSettings:Provider is "Console" (default)Calls the Brevo transactional email API.
POST https://api.brevo.com/v3/smtp/email
Headers: api-key, accept: application/json
Request body: { sender, to, subject, htmlContent }
Response: { messageId }
Uses messageVersions — up to 1000 per request.
Request body: { sender, messageVersions: [{ to, subject, htmlContent }, ...] }
Response: { messageIds: [...] }
Important: No outer to field in batch mode — all recipients go inside messageVersions.
services.AddHttpClient<IEmailSender, BrevoEmailSender>();
Registered via AddHttpClient for proper HttpClient lifecycle management.
| Class | JSON root | Fields |
|---|---|---|
BrevoSendRequest | single send | sender, to, subject, htmlContent |
BrevoBatchRequest | batch send | sender, messageVersions |
BrevoMessageVersion | batch item | to, subject, htmlContent |
BrevoContact | sender/recipient | email, name |
BrevoSendResponse | single response | messageId |
BrevoBatchResponse | batch response | messageIds |
All use [JsonPropertyName] for camelCase serialization.
public class EmailService(IUnitOfWork uow) : IEmailService
{
public async Task QueueEmailAsync(string to, string subject, string templateName,
object templateData, CancellationToken cancellationToken = default)
{
var message = EmailOutboxMessage.Create(to, subject, templateName, templateData);
await uow.EmailOutboxMessages.AddAsync(message, cancellationToken);
// Caller must call uow.SaveChangesAsync()
}
}
Key: Does NOT call SaveChangesAsync — participates in the caller's transaction.
EmailOutboxProcessorBackgroundService runs in BosDAT.Worker:
NextAttemptAtUtc <= UtcNowCreatedAt (FIFO)MarkProcessing() + SaveChanges (claim it)TemplateDataJson → Dictionary<string, object>IEmailTemplateRendererIEmailSenderMarkSent() or MarkFailed() + SaveChangesDbUpdateConcurrencyException → another processor claimed it (safe to skip)OperationCanceledException → graceful shutdown// Worker/appsettings.json
{
"WorkerSettings": {
"EmailOutboxJob": {
"Enabled": true,
"PollingIntervalSeconds": 10,
"BatchSize": 20
}
}
}
ServiceCollectionExtensions.cs)public static IServiceCollection AddEmailServices(
this IServiceCollection services, IConfiguration configuration)
{
services.Configure<EmailSettings>(
configuration.GetSection(EmailSettings.SectionName));
services.AddScoped<IEmailService, EmailService>();
services.AddSingleton<IEmailTemplateRenderer, EmailTemplateRenderer>();
var provider = configuration[$"{EmailSettings.SectionName}:Provider"] ?? "Console";
if (provider.Equals("Brevo", StringComparison.OrdinalIgnoreCase))
services.AddHttpClient<IEmailSender, BrevoEmailSender>();
else
services.AddSingleton<IEmailSender, ConsoleEmailSender>();
return services;
}
Program.cs)Same registrations plus:
builder.Services.AddHostedService<EmailOutboxProcessorBackgroundService>();
// IUnitOfWork
IRepository<EmailOutboxMessage> EmailOutboxMessages { get; }
// appsettings.json (both API and Worker)
{
"EmailSettings": {
"Provider": "Console", // "Console" or "Brevo"
"FromEmail": "[email protected]",
"FromName": "BosDAT",
"Brevo": {
"ApiKey": "" // Set via environment variable in production
}
}
}
public class EmailSettings
{
public const string SectionName = "EmailSettings";
public string Provider { get; set; } = "Console";
public required string FromEmail { get; set; }
public required string FromName { get; set; }
public BrevoSettings Brevo { get; set; } = new();
}
public class BrevoSettings
{
public string ApiKey { get; set; } = string.Empty;
}
IEmailSender in Infrastructure/Email/NewProviderEmailSender.csEmailSettings if neededAddEmailServices() and Worker Program.cs:
else if (provider.Equals("NewProvider", StringComparison.OrdinalIgnoreCase))
services.AddHttpClient<IEmailSender, NewProviderEmailSender>();
appsettings.json with provider configCore.Tests/Entities/EmailOutboxMessageTests.cs)Test state machine transitions:
Create() sets correct initial stateMarkProcessing/Sent/Failed transitionsAPI.Tests/Services/EmailServiceTests.cs)Mock IUnitOfWork, verify:
EmailOutboxMessage with correct propertiesSaveChangesAsyncInfrastructure.Tests/Email/)Mock HttpMessageHandler to test: