Email System
Pluggable email delivery pipeline with queue strategies, background processing, and HTML sanitization.
Email Pipeline Architecture
The Email System follows a pipeline architecture: Controller → EmailService → Queue → Sender → SMTP. The queue strategy is pluggable — InMemoryQueue for development and a background queue (e.g., Hangfire) for production.
Email Event Trigger
Invoke QueueEmailAsync
IEmailQueue Interface
InMemory provider active
EmailQueue (Channels)
HangfireEmailQueue (Db)
EmailQueueWorker (BgService)
Hangfire Job Processor
Resolve sender
IEmailSender.ForRole(role)
Production context
SmtpEmailSender (SMTP Client)
DevEmailSender (Console)
NullEmailSender (Test stub)
SentEmailLog (Audit Log)
Email Controller Endpoints
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| POST | /api/v1/emails/send | Send manual transactional email (queues message) | 🔒 Required |
| POST | /api/v1/emails/send-bulk | Send transactional email to multiple recipients | 🔒 Required |
| GET | /api/v1/emails/search-recipients | Search matching recipients for administration panel selection | 🔒 Required |
| GET | /api/v1/emails/sent | Paginated log history of sent emails (filters by status, date range) | 🔒 Required |
| GET | /api/v1/emails/{id} | Retrieve single sent email audit log details | 🔒 Required |
| GET | /api/v1/emails/statistics | Retrieve overall totals and status counts of sent emails | 🔒 Required |
| POST | /api/v1/emails/{id}/resend | Re-enqueue a failed or completed email for redelivery | 🔒 Required |
| DELETE | /api/v1/emails/{id} | Cancel/remove a pending email from the queue (soft-delete) | 🔒 Required |
Queue Implementations
In-Memory Channel Queue
EmailQueue.csC#
// Bounded System.Threading.Channels for backpressure control (max 1000 items)
public sealed class EmailQueue : IEmailQueue
{
private readonly Channel<EmailMessage> _queue;
public EmailQueue()
{
var options = new BoundedChannelOptions(1000)
{
FullMode = BoundedChannelFullMode.Wait
};
_queue = Channel.CreateBounded<EmailMessage>(options);
}
public async ValueTask QueueEmailAsync(EmailMessage message, CancellationToken ct = default)
{
await _queue.Writer.WriteAsync(message, ct);
}
public async ValueTask<EmailMessage?> DequeueAsync(CancellationToken ct)
{
try
{
return await _queue.Reader.ReadAsync(ct);
}
catch (OperationCanceledException)
{
return null;
}
}
}Hangfire Distributed Background Queue
HangfireEmailQueue.csC#
// Hangfire-backed email queue: Jobs are serialized and persisted to DB
public sealed class HangfireEmailQueue : IEmailQueue
{
public ValueTask QueueEmailAsync(EmailMessage message, CancellationToken ct = default)
{
if (!string.IsNullOrEmpty(message.TemplateName) && message.TemplateData is not null)
{
var placeholders = new Dictionary<string, string>(message.TemplateData);
BackgroundJob.Enqueue<EmailJobProcessor>(
processor => processor.SendTemplatedEmailAsync(
message.To, message.TemplateName, placeholders, message.SenderRole
)
);
}
else
{
BackgroundJob.Enqueue<EmailJobProcessor>(
processor => processor.SendEmailAsync(
message.To, message.Subject, message.Body, message.SenderRole
)
);
}
return ValueTask.CompletedTask;
}
public ValueTask<EmailMessage?> DequeueAsync(CancellationToken ct)
{
throw new NotSupportedException("Hangfire processes jobs automatically via its own workers");
}
}Pluggable Senders & Delivery Strategies
| Sender Strategy | Active Environment | Execution Mode | Behavior |
|---|---|---|---|
| SmtpEmailSender | Production / Staging | Active SMTP Client | Sends fully styled HTML emails via System.Net.Mail using verified credentials and brand layout rules |
| DevEmailSender | Development | Logger Console Stub | Intercepts dispatch requests and logs styled HTML to the console log, creating mock SentEmailLog entries |
| NullEmailSender | Testing | No-Op Stub | Accepts all emails and returns task completion instantly, skipping any logging or output operations |
Background Queue Worker Loop
EmailQueueWorker.csC#
// Hosted background worker loop processing System.Threading.Channels messages
public sealed class EmailQueueWorker : BackgroundService
{
private readonly IEmailQueue _emailQueue;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<EmailQueueWorker> _logger;
public EmailQueueWorker(
IEmailQueue emailQueue,
IServiceProvider serviceProvider,
ILogger<EmailQueueWorker> logger)
{
_emailQueue = emailQueue;
_serviceProvider = serviceProvider;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Email queue worker started");
while (!stoppingToken.IsCancellationRequested)
{
try
{
var message = await _emailQueue.DequeueAsync(stoppingToken);
if (message is null) continue;
await ProcessEmailAsync(message, stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing email from queue");
// 5-second backoff prevents tight loop CPU lockups on connection failures
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
}
}
}Error Handling & Sanitization
- SMTP Connection Failures: Exceptions are caught, logged, and worker thread sleeps for 5 seconds before processing subsequent queue elements.
- Hangfire Retry Policies: Automatic retry filters retry failed background deliveries (3 attempts) with a custom exponential delay (10s, 60s, 300s).
- Deferred Email Processing: Scheduled emails are kept pending in the database (SentEmailLog) and processed in batches of 50 by EmailProcessingJob.
- Gmail/Yahoo Bulk Compliances: Every SMTP email header contains List-Unsubscribe, List-Unsubscribe-Post='List-Unsubscribe=One-Click', Return-Path, and X-Mailer values to avoid spam classification.
- Email Sender Roles: Specific roles (Auth, Billing, Sales, Support) resolve automatically to Gmail/Google Workspace group aliases to delegating sends dynamically.
HTML Sanitization & Anti-XSS
All HTML email templates and user-submitted message content pass through an HTML sanitizer to eliminate malicious script execution and security vulnerabilities:
- Strips script elements (<script>) and nested code structures entirely from the body.
- Blocks all javascript: scheme references in link anchors (href) and image src attributes.
- Removes inline Javascript event handler attributes (such as onload, onclick, onerror, and onmouseover) from HTML nodes.