I recently migrated email notifications in my blogging system, Moonglade, from Azure Communication Services to Resend. Along the way, I consolidated email delivery into a single SMTP implementation.

For a personal blog, the email module has a straightforward job: notify the right people when someone leaves a comment, receives a reply, or sends a Webmention. It should be reliable, easy to configure, and easy to move between email services.

Those requirements guided this migration. The goals were to simplify the architecture and reduce dependence on a particular cloud vendor. The result uses no Resend SDK and introduces no ResendEmailSender. Resend connects through SMTP, using the same implementation that can serve other compatible SMTP services.

Where the Complexity Came From

Before the migration, Moonglade supported both SMTP and Azure Communication Services. Both paths served the same notification features, but delivery went through a dispatcher that selected a provider at runtime.

The previous EmailDispatcher read the configuration, located the matching implementation, and forwarded the message:

public class EmailDispatcher(
    IOptions<EmailServiceOptions> options,
    IEnumerable<IEmailProviderSender> senders) : IEmailDispatcher
{
    public async Task SendAsync(CommonMailMessage message)
    {
        var sender = options.Value.NormalizedProvider;
        var providerSender = senders.FirstOrDefault(s => string.Equals(s.Provider, sender, StringComparison.OrdinalIgnoreCase));

        if (providerSender == null)
        {
            throw new InvalidOperationException($"Email provider '{sender}' is not supported.");
        }

        await providerSender.SendAsync(message);
    }
}

This design had a purpose. When an application integrates directly with several email APIs, a dispatcher can keep their differences out of the business logic. However, the abstraction still left the project responsible for maintaining:

  • The Azure.Communication.Email SDK dependency.
  • AzureCommunicationSender, plus a wrapper and interface for the SDK client.
  • Configuration fields such as Provider, AcsConnectionString, and AcsSenderAddress.
  • Provider name normalization, separate validation branches, and provider-specific admin presentation.
  • Translation of Azure SDK exceptions into the application's retry decisions.

For example, the Azure implementation constructed the SDK's EmailMessage and initiated sending with WaitUntil.Started. The notification itself was simple, but the integration had to understand the vendor's message types and operation model.

Resend supports SMTP, and Moonglade already had SMTP support. That made this a good opportunity to reconsider whether both delivery paths were still worth maintaining.

Moving Provider Differences into Configuration

The new boundary is straightforward: Moonglade generates notifications, manages pending messages, and submits email through SMTP. The email service handles delivery after submission.

The application still needs a server address and credentials, and it still depends on an external service. However, switching to another SMTP service that meets the implementation's authentication, sender, and TLS requirements can usually be handled through configuration.

Replacing the Azure SDK with a Resend SDK would have introduced another vendor API into the application. Choosing SMTP instead lets the application depend on a common protocol, while the service provider's identity remains in deployment configuration.

There is a tradeoff. The current implementation does not integrate provider-specific delivery callbacks, analytics APIs, or idempotency headers. For the blog's existing notification requirements, that keeps the amount of code manageable. Additional integrations can be evaluated when there is a concrete need for them.

Separating Authentication from the Sender Address

One practical detail mattered immediately: an SMTP login username is not necessarily an email address.

Resend uses resend as its SMTP username and an API key as the password. The sender address must belong to a verified domain. A configuration that treats the SMTP username as the sender address cannot represent this setup correctly. These requirements are documented in Resend's SMTP documentation.

To support this, Moonglade gained a general-purpose SmtpSenderAddress setting:

Setting Purpose Resend example
SmtpUserName SMTP authentication username resend
SmtpPassword SMTP authentication credential API key supplied through deployment secrets
SmtpSenderAddress Email sender address notifications@example.com

The implementation does not detect Resend from the username or add a provider-specific branch. Authentication and sender identity are separate concepts, and the configuration now expresses that distinction.

To preserve existing configurations where the username is an email address, an omitted sender address falls back to the username:

public string EffectiveSmtpSenderAddress =>
    string.IsNullOrWhiteSpace(SmtpSenderAddress) ? SmtpUserName : SmtpSenderAddress;

The sender constructs the message with MimeKit and applies the effective address to both From and Sender:

var senderAddress = options.Value.EffectiveSmtpSenderAddress;
var mimeMessage = new MimeMessage
{
    Sender = new MailboxAddress(smtpSettings.SenderName, senderAddress),
    Subject = message.Subject,
    Body = new TextPart(message.BodyIsHtml ? TextFormat.Html : TextFormat.Plain) { Text = message.Body }
};
mimeMessage.From.Add(new MailboxAddress(smtpSettings.EmailDisplayName, senderAddress));

Authentication continues to use SmtpUserName and SmtpPassword. The account can therefore authenticate as resend, while the message headers and SMTP envelope sender use the actual email address.

Validation changed alongside the configuration. An explicitly supplied SmtpSenderAddress must be a valid bare email address. Display names are configured separately, so a value such as Blog <notifications@example.com> does not belong in this field.

The relevant code is in EmailServiceOptions.cs and SmtpEmailSender.cs.

Removing the Provider Selection Layer

With the sender address issue resolved, the next step was to remove the Azure delivery path and the code whose only purpose was choosing between providers.

Previously, the delivery registrations included the Azure client wrapper, two provider implementations, and the dispatcher:

services.AddSingleton<IAzureCommunicationEmailClient, AzureCommunicationEmailClient>();
services.AddSingleton<IEmailProviderSender, SmtpEmailSender>();
services.AddSingleton<IEmailProviderSender, AzureCommunicationSender>();
services.AddSingleton<IEmailDispatcher, EmailDispatcher>();

After the migration, that part became one line:

services.AddSingleton<IEmailDispatcher, SmtpEmailSender>();

SmtpEmailSender now implements the existing IEmailDispatcher directly. The background processor still calls the same interface and does not need to know which SMTP service is configured.

Keeping IEmailDispatcher is useful because it remains the boundary between background processing and network delivery, and it allows the sender to be replaced in tests. The deleted IEmailProviderSender and EmailDispatcher mainly existed to select a provider. With one delivery implementation, that extra layer no longer served a purpose.

The cleanup extended beyond those classes:

  • Removed the Azure.Communication.Email package reference.
  • Deleted the Azure client wrapper and its interface.
  • Removed provider selection, name normalization, and ACS configuration fields.
  • Changed configuration validation to check SMTP settings directly.
  • Updated the admin notification page to show SMTP and the effective sender address.
  • Removed Azure RequestFailedException classification while retaining SMTP error handling.
  • Updated configuration examples, documentation, and tests.

This removed an entire branch running through configuration, registration, execution, and failure handling. There is less code to understand when diagnosing a delivery problem.

The commit removing Azure email support changed 26 files, adding 39 lines and deleting 541: a net reduction of 502 lines. That includes tests and documentation, so it should not be presented as 502 lines of production code removed. It does show that this migration was primarily about consolidating and deleting code.

Keeping the Existing Outbox

Consolidating delivery into SMTP did not move network calls back into comment requests. Moonglade already had a database-backed outbox before this migration, and that pipeline remains in place:

flowchart TD
    A[Comment, reply, and Webmention events] --> B[Notification handlers]
    B --> C[Database outbox]
    C --> D[EmailOutboxWorker]
    D --> E[EmailOutboxMessageProcessor]
    E --> F[IEmailDispatcher / SmtpEmailSender]
    F --> G[SMTP service, currently Resend]

Notification handlers enqueue messages. The background worker creates a dependency injection scope and processes pending work. The processor validates each message, builds the email, invokes the sender, and records the result.

Template generation, retries, claim leases, and dead-letter handling stay inside the application. They do not have to be implemented separately for each email service. The SMTP sender handles connecting, authenticating, submitting the message, and disconnecting.

The reliability guarantees also remain explicit. When every recipient fails and at least one failure is transient, the processor schedules a retry. Permanent failures or exhausted attempts lead to dead-letter handling. If only some recipients fail, the processor logs a warning and completes the message, avoiding a retry of the whole notification for recipients whose messages were already submitted successfully.

The outbox uses at-least-once processing. If the SMTP service accepts an email but the application stops before recording success, a later retry can submit it again. SMTP does not automatically provide exactly-once delivery, and successful submission does not prove that a message reached the recipient's inbox.

There is another boundary worth being precise about: notification enqueueing uses its own SaveChangesAsync. Having an outbox alone does not mean that saving a comment and enqueueing its notification happen in the same database transaction.

Handling SMTP Transport Details

A later change fixed TLS behavior on port 465. Although the configuration property is called EnableSsl, SMTP supports different ways of establishing an encrypted connection. Port 465 typically starts TLS immediately when connecting, while port 587 typically upgrades an existing connection through STARTTLS.

The current implementation keeps that decision in a small method:

internal static SecureSocketOptions GetSocketOptions(int port, bool enableTls) =>
    port == 465 ? SecureSocketOptions.SslOnConnect :
    enableTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;

The connection and authentication code uses that result:

await smtp.ConnectAsync(
    settings.SmtpServer,
    settings.SmtpServerPort,
    GetSocketOptions(settings.SmtpServerPort, settings.EnableTls));
if (!string.IsNullOrEmpty(settings.SmtpUserName))
{
    await smtp.AuthenticateAsync(settings.SmtpUserName, settings.SmtpPassword);
}

var response = await smtp.SendAsync(mimeMessage);
await smtp.DisconnectAsync(true);

In this implementation, port 465 always uses implicit TLS. On other ports, EnableSsl=true requires STARTTLS. Setting it to false selects Auto, which uses STARTTLS when the server supports it but otherwise permits an unencrypted connection. The Resend example below uses port 587 with TLS explicitly enabled.

Standardizing on a protocol reduces vendor-specific behavior, but the protocol still needs to be implemented and tested correctly.

Configuring Resend

The current version can send through Resend with the following configuration:

{
  "Email": {
    "Enabled": true,
    "SmtpServer": "smtp.resend.com",
    "SmtpUserName": "resend",
    "SmtpSenderAddress": "notifications@example.com",
    "SmtpPort": 587,
    "EnableSsl": true
  }
}

Replace notifications@example.com with a permitted sender address on your verified domain. Supply the API key through the Email__SmtpPassword environment variable or your deployment's secret configuration, rather than committing it to appsettings.json. The host, authentication requirements, and supported ports are covered in Resend's SMTP documentation.

For an existing deployment, the migration steps are:

  1. Remove Email:Provider, Email:AcsConnectionString, and Email:AcsSenderAddress. The current code no longer reads them.
  2. Configure the SMTP host, port, credentials, and sender address.
  3. If the deployment uses the old Email:OutboxWorker:Enabled switch, replace it with Email:Enabled. The old key is no longer read; worker tuning settings remain under Email:OutboxWorker.
  4. Restart the application, enable email sending at /admin/settings/notification, and use Send Test Email to check actual delivery.

Email remains an optional capability. Missing configuration leaves email inactive while the blog starts normally. Invalid configuration produces an error log and validation details in the admin portal. While email is unavailable, the worker does not claim messages and built-in notification handlers do not enqueue new ones. Existing pending messages remain in the database and can be processed after valid configuration is supplied and the application is restarted.

The application-level Email:Enabled setting and the admin notification switch retain separate responsibilities. The former controls whether the email capability is enabled; the latter controls whether the blog produces notifications. Enabling notifications in the admin portal does not replace SMTP configuration.

What the Migration Achieved

The email module now has one fewer vendor SDK, no Azure client wrapper, and no provider selection layer. Their associated configuration and error-handling branches are gone. Notification features, templates, and the outbox continue to use the existing application pipeline.

Reducing vendor lock-in does not eliminate dependence on an external service. Domain verification, quotas, sending reputation, and operational data still belong to the provider's side of the boundary. The improvement is lower coupling between application code and provider APIs, with less application work required for a future switch.

Resend serves Moonglade's current email needs, and SMTP keeps that choice easier to change. If the next service meets the existing implementation's requirements, migration can focus on configuration, domain verification, and delivery testing, without adding another sender implementation to the application.