Moonglade used to send email notifications through a standalone service named Moonglade.Email.

That service was deployed as an Azure Function. The blog application submitted email notification messages, Azure Storage Queue held those messages, and the function processed them asynchronously before sending email through Azure Communication Services or SMTP.

It worked. But for a personal blogging system, it was more infrastructure than I wanted to keep.

Moonglade usually sends very few notification emails: new comments, admin replies, Webmentions, and test messages. Even for a busy post, the workload is tiny compared with what Azure Queue Storage and Azure Functions are designed to handle.

So I moved email notification processing back into the main Moonglade application.

Before: A Separate Azure Function Service

The old design looked like this:

sequenceDiagram
    participant Blog as Moonglade Web App
    participant Api as Moonglade.Email HTTP Function
    participant Queue as Azure Storage Queue
    participant Function as Azure Function Queue Trigger
    participant Template as Email Template Renderer
    participant Provider as ACS / SMTP

    Blog->>Api: Send email request
    Api->>Queue: Enqueue email message
    Queue->>Function: Trigger with queued message
    Function->>Template: Render email template
    Template-->>Function: Return rendered email
    Function->>Provider: Send email

This architecture had a few benefits:

  • email sending did not block comment submission
  • queue messages survived restarts
  • retries were handled outside the web request
  • email delivery was separated from the blog process

But it also required:

  • another repository
  • another deployment unit
  • Azure Functions
  • Azure Storage Queue
  • extra secrets and configuration
  • more operational knowledge for a small personal blog

For Moonglade, the cost was no longer justified.

After: An In-App Database Outbox

The new design keeps asynchronous and durable delivery, but removes the standalone Azure Function and Azure Storage Queue.

flowchart TD
    Event["Comment / Reply / Webmention"] --> Queue["IEmailNotificationQueue"]
    Queue --> Table["EmailOutboxMessage Table"]
    Table --> Worker["EmailOutboxWorker"]
    Worker --> Processor["EmailOutboxMessageProcessor"]
    Processor --> Builder["MessageBuilder"]
    Builder --> Dispatcher["EmailDispatcher"]
    Dispatcher --> ACS["Azure Communication Services"]
    Dispatcher --> SMTP["SMTP"]

The database is now the durable queue.

This is intentionally simple. Moonglade already requires SQL Server or PostgreSQL, so using the existing database avoids introducing RabbitMQ, Redis, Azure Queue Storage, or another hosted dependency.

Code Structure

The email implementation now lives inside the main Moonglade solution:

src/
  Moonglade.Email/
    CommentEvent.cs
    CommentReplyEvent.cs
    MentionEvent.cs
    TestEmailEvent.cs
    ServiceCollectionExtensions.cs
    Core/
      IEmailNotificationQueue.cs
      IEmailOutboxStore.cs
      DbEmailNotificationQueue.cs
      EmailOutboxWorker.cs
      EmailOutboxMessageProcessor.cs
      EmailOutboxWorkerOptions.cs

  Moonglade.Data/
    Entities/
      EmailOutboxMessageEntity.cs
      EmailOutboxMessageStatus.cs
    Configurations/
      EmailOutboxMessageConfiguration.cs

The main idea is:

  • event handlers enqueue email work
  • the database stores pending messages
  • a hosted service polls and claims messages
  • the processor sends emails and records success, retry, or dead-letter status

Event Handlers Only Enqueue Work

Request handlers do not send email directly. They publish LiteBus events such as CommentEvent, CommentReplyEvent, or MentionEvent.

The email event handlers turn those events into outbox messages.

public class CommentNotificationEventHandler(
    IEmailNotificationQueue queue,
    IBlogConfig blogConfig) : IEventHandler<CommentEvent>
{
    public async Task HandleAsync(CommentEvent notification, CancellationToken ct)
    {
        if (!blogConfig.NotificationSettings.EnableEmailSending)
        {
            return;
        }

        var payload = new NewCommentPayload
        {
            Username = notification.Username,
            Email = notification.Email,
            IpAddress = notification.IPAddress,
            PostTitle = notification.PostTitle,
            CommentContent = ContentProcessor.MarkdownToCommentHtml(notification.CommentContent)
        };

        await queue.EnqueueAsync(new EmailNotification
        {
            MessageType = MessageTypes.NewCommentNotification,
            DistributionList = blogConfig.GeneralSettings.OwnerEmail,
            MessageBody = JsonSerializer.Serialize(payload, MoongladeJsonSerializerOptions.Default)
        }, ct);
    }
}

This keeps the web request path short. The user action only persists a notification message. Actual email delivery happens later.

The Outbox Table

The outbox message entity stores everything needed to process, retry, and diagnose email delivery.

public class EmailOutboxMessageEntity
{
    public Guid Id { get; set; }
    public string MessageType { get; set; }
    public string DistributionList { get; set; }
    public string MessageBody { get; set; }
    public EmailOutboxMessageStatus Status { get; set; }
    public int AttemptCount { get; set; }
    public DateTime CreatedTimeUtc { get; set; }
    public DateTime? LastAttemptTimeUtc { get; set; }
    public DateTime? NotBeforeUtc { get; set; }
    public DateTime? LockedUntilUtc { get; set; }
    public string LockedBy { get; set; }
    public DateTime? SentTimeUtc { get; set; }
    public string LastError { get; set; }
    public Guid ConcurrencyToken { get; set; } = Guid.NewGuid();
}

The dequeue path has an index optimized for pending and retryable messages:

builder.HasIndex(
    nameof(EmailOutboxMessageEntity.Status),
    nameof(EmailOutboxMessageEntity.NotBeforeUtc),
    nameof(EmailOutboxMessageEntity.LockedUntilUtc),
    nameof(EmailOutboxMessageEntity.CreatedTimeUtc))
    .HasDatabaseName("IX_EmailOutboxMessage_Dequeue");

The status model is simple:

Pending -> Processing -> Succeeded
Pending -> Processing -> Failed -> Processing -> Succeeded
Pending -> Processing -> Failed -> DeadLettered

Claiming Messages With A Lease

The outbox store claims one available message at a time. A worker sets LockedBy, LockedUntilUtc, increments AttemptCount, and updates the concurrency token.

entity.Status = EmailOutboxMessageStatus.Processing;
entity.LockedBy = request.WorkerId;
entity.LockedUntilUtc = request.UtcNow.Add(request.LeaseDuration);
entity.LastAttemptTimeUtc = request.UtcNow;
entity.AttemptCount++;
entity.ConcurrencyToken = Guid.NewGuid();

await db.SaveChangesAsync(cancellationToken);

Claimable messages include:

return db.EmailOutboxMessage.Where(m =>
    (m.Status == EmailOutboxMessageStatus.Pending ||
     m.Status == EmailOutboxMessageStatus.Failed ||
     (m.Status == EmailOutboxMessageStatus.Processing && m.LockedUntilUtc <= utcNow)) &&
    (m.NotBeforeUtc == null || m.NotBeforeUtc <= utcNow) &&
    (m.LockedUntilUtc == null || m.LockedUntilUtc <= utcNow));

This gives the system basic durability:

  • pending messages survive app restarts
  • failed messages can be retried later
  • stale processing locks can be recovered
  • multiple workers can avoid processing the same message through optimistic concurrency

The Background Worker

Email delivery runs in an ASP.NET Core hosted service.

public class EmailOutboxWorker(
    IServiceScopeFactory scopeFactory,
    IOptions<EmailOutboxWorkerOptions> options,
    ILogger<EmailOutboxWorker> logger) : BackgroundService
{
    private readonly string _workerId = $"{Environment.MachineName}-{Guid.NewGuid():N}";

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var workerOptions = options.Value;
        if (!workerOptions.Enabled)
        {
            logger.LogInformation("EmailOutboxWorker is disabled.");
            return;
        }

        logger.LogInformation("EmailOutboxWorker started with worker ID {WorkerId}.", _workerId);

        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await ProcessBatchAsync(workerOptions, stoppingToken);
                await Task.Delay(workerOptions.PollInterval, stoppingToken);
            }
            catch (OperationCanceledException)
            {
                break;
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Error in EmailOutboxWorker.");
                await Task.Delay(workerOptions.PollInterval, stoppingToken);
            }
        }
    }
}

The worker is intentionally boring. It polls, processes a small batch, waits, and repeats.

That is enough for Moonglade’s workload.

Processing, Retry, And Dead Letter

The processor validates the message, builds the email, sends it to each recipient, and then updates the outbox state.

var failures = await SendToRecipientsAsync(message, recipients, cancellationToken);

if (failures.Count == 0)
{
    await outboxStore.CompleteAsync(message.Id, utcNow, cancellationToken);
    return;
}

if (failures.Count != recipients.Length)
{
    await outboxStore.CompleteAsync(message.Id, utcNow, cancellationToken);
    return;
}

var transientFailures = failures.Count(f => f.Kind == EmailDeliveryFailureKind.Transient);

if (transientFailures == 0)
{
    await outboxStore.DeadLetterAsync(
        new EmailOutboxFailure(message.Id, errorMessage, utcNow),
        cancellationToken);
    return;
}

await outboxStore.MarkFailedAsync(
    new EmailOutboxFailure(message.Id, errorMessage, utcNow),
    utcNow.Add(GetRetryDelay(message.AttemptCount)),
    cancellationToken);

The retry delay grows until it reaches the configured maximum.

private TimeSpan GetRetryDelay(int attemptCount)
{
    var delay = options.Value.InitialRetryDelay;

    for (var i = 1; i < attemptCount; i++)
    {
        delay = delay + delay;
        if (delay >= options.Value.MaxRetryDelay)
        {
            return options.Value.MaxRetryDelay;
        }
    }

    return delay;
}

This is not a full message broker. It is a small durable outbox designed for a personal blog.

Dependency Injection

The email system is registered as part of the main application.

services.AddScoped<DbEmailNotificationQueue>();
services.AddScoped<IEmailOutboxStore>(sp => sp.GetRequiredService<DbEmailNotificationQueue>());
services.AddScoped<IEmailNotificationQueue>(sp => sp.GetRequiredService<DbEmailNotificationQueue>());
services.AddScoped<IEmailOutboxMessageProcessor, EmailOutboxMessageProcessor>();
services.AddHostedService<EmailOutboxWorker>();

The same database-backed implementation serves both roles:

  • IEmailNotificationQueue for enqueueing
  • IEmailOutboxStore for worker-side claiming and state transitions

Configuration

The outbox worker is configured under the main Email section.

{
  "Email": {
    "Provider": "AzureCommunication",
    "OutboxWorker": {
      "Enabled": true,
      "BatchSize": 5,
      "PollIntervalSeconds": 30,
      "LeaseDurationSeconds": 300,
      "MaxAttempts": 3,
      "InitialRetryDelaySeconds": 60,
      "MaxRetryDelaySeconds": 3600
    }
  }
}

Email:OutboxWorker:Enabled=false stops in-process delivery but does not prevent enqueueing. That can be useful if only one app instance should process the outbox.

A Linux Docker Issue

After deploying to Azure App Service on Linux with Docker, I saw this error repeatedly:

System.UnauthorizedAccessException:
Access to the path '/app/mailConfiguration.xml' is denied.

The container runs as the non-root app user. The published files in /app are owned by root and should be treated as read-only.

The issue was caused by initializing the email template helper with a file path. Internally, that path-based constructor required write access.

The fix was to load the XML file explicitly with read-only access, deserialize it, and pass the configuration object to the email helper.

private static MailConfiguration LoadMailConfiguration()
{
    var configSource = Path.Join(AppContext.BaseDirectory, "mailConfiguration.xml");
    if (!File.Exists(configSource))
    {
        configSource = Path.Join(AppContext.BaseDirectory, "Moonglade.Email", "mailConfiguration.xml");
    }

    var serializer = new XmlSerializer(typeof(MailConfiguration));
    using var stream = File.Open(configSource, FileMode.Open, FileAccess.Read, FileShare.Read);

    return serializer.Deserialize(stream) as MailConfiguration
        ?? throw new InvalidOperationException("Configuration file for EmailHelper is invalid.");
}

Then DI can create the helper without requiring write access to /app.

services.AddSingleton<IEmailHelper>(_ => new EmailHelper(LoadMailConfiguration()));

I verified the fix locally with Docker Desktop:

  • the container ran as user app
  • /app/mailConfiguration.xml was owned by root
  • writing to the file failed with Permission denied
  • EmailOutboxWorker started successfully
  • no UnauthorizedAccessException appeared in the logs

Why This Design Fits Moonglade

This migration is not about building a better queue than Azure Storage Queue.

It is about choosing the right amount of infrastructure.

For Moonglade, the email workload is small. The important requirements are:

  • do not block user requests while sending email
  • persist notifications across app restarts
  • retry transient failures
  • avoid losing messages
  • reduce Azure dependencies
  • keep deployment simple

A database outbox meets those requirements with fewer moving parts.

Deprecating The Standalone Repository

With email processing integrated into the main application, the standalone Moonglade.Email Azure Functions repository is deprecated and will be archived.

Future email notification development will happen in the main Moonglade repository.

That leaves Moonglade with a simpler model:

  • one application
  • one deployment
  • one database
  • one configuration surface
  • one place to maintain email behavior

Final Thoughts

Azure Functions and Azure Storage Queue are good services. They were not the problem.

The problem was that the architecture had become larger than the product requirement.

For a personal blog, reliability matters, but simplicity matters too. Moving email notifications from Azure Functions into Moonglade’s own database outbox keeps delivery asynchronous and durable while removing an entire external service from the deployment.

That is the kind of tradeoff I want Moonglade to make more often.