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 Dispatch & Execution Flow
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

MethodEndpointDescriptionAuth
POST/api/v1/emails/sendSend manual transactional email (queues message)🔒 Required
POST/api/v1/emails/send-bulkSend transactional email to multiple recipients🔒 Required
GET/api/v1/emails/search-recipientsSearch matching recipients for administration panel selection🔒 Required
GET/api/v1/emails/sentPaginated 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/statisticsRetrieve overall totals and status counts of sent emails🔒 Required
POST/api/v1/emails/{id}/resendRe-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 StrategyActive EnvironmentExecution ModeBehavior
SmtpEmailSenderProduction / StagingActive SMTP ClientSends fully styled HTML emails via System.Net.Mail using verified credentials and brand layout rules
DevEmailSenderDevelopmentLogger Console StubIntercepts dispatch requests and logs styled HTML to the console log, creating mock SentEmailLog entries
NullEmailSenderTestingNo-Op StubAccepts 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.