Skip to main content

Command Palette

Search for a command to run...

Building a Transactional Email Service in ASP.NET Core: A Real-World Walkthrough

Updated
โ€ข13 min read

Transactional email โ€” the kind sent after registration, password reset, order confirmation, or account alert โ€” looks simple from the outside. Developers wire up an SMTP client, call a send method, and move on. In production, that naive approach breaks fast: emails timeout during request processing, credentials leak into config files, retry logic is missing, and the first time the SMTP server is unavailable the entire user flow falls apart silently. Building a transactional email service that actually holds up in enterprise-grade ASP.NET Core applications means thinking about abstraction, delivery reliability, background processing, and observability from the start โ€” not as afterthoughts. If you want to see these design decisions wired together in a complete, running codebase, the full implementation with templating, retry handling, and a working test suite lives on Patreon โ€” including everything the article intentionally leaves open.

For teams building a full production API where email is one piece of a much larger system, understanding where email fits into your service architecture is just as important as the sending logic itself. Chapter 12 of the Zero to Production course walks through background task patterns โ€” including System.Threading.Channels for in-process queuing โ€” inside a complete ASP.NET Core codebase, so the design decisions covered here translate directly into a real working system.

ASP.NET Core Web API: Zero to Production

The Business Problem This Solves

Every SaaS application sends transactional emails. The challenge is not sending one email โ€” it is building an email subsystem that:

  • Does not block the HTTP request thread while attempting SMTP delivery
  • Survives temporary provider outages with retry logic
  • Supports multiple email providers without changing application code
  • Keeps credentials out of application code and configuration files
  • Provides enough observability to diagnose delivery failures in production
  • Scales without becoming a bottleneck as send volume grows

Most tutorials stop at "install MailKit and call SendAsync." That pattern works fine in development and falls apart quietly in production when the SMTP server is unreachable, when the request timeout fires before delivery completes, or when a misconfigured credential causes bulk failures with no log trail.

What Does a Production-Ready Email Service Look Like?

A production-grade transactional email service in ASP.NET Core has four distinct responsibilities:

1. Abstraction โ€” Application code should express intent ("send a welcome email to this user"), not implementation details ("connect to smtp.sendgrid.net on port 587 with TLS"). An IEmailService interface decouples the domain from the delivery mechanism.

2. Configuration โ€” SMTP credentials, API keys, sender address, and provider settings all belong in the Options pattern, not hardcoded in service classes. Using IOptions<EmailSettings> keeps configuration centralised and testable.

3. Decoupled Delivery โ€” Sending email synchronously inside a controller action ties HTTP response time to SMTP round-trip time โ€” a dependency that will eventually hurt users. The right architecture enqueues the email and returns the HTTP response immediately, letting a BackgroundService handle actual delivery.

4. Resilience โ€” A single SMTP failure should not silently discard an email. The delivery loop needs retry logic, failure logging, and a dead-letter mechanism for permanently undeliverable messages.

Design Decisions Before Writing a Single Line

Before choosing a library, there are three choices that shape the entire email subsystem.

Should You Send Synchronously or Asynchronously?

Synchronous delivery (send during the HTTP request) is simpler to reason about but adds provider latency โ€” typically 100ms to 500ms โ€” to every request that triggers an email. More critically, if the SMTP connection pool is exhausted or the provider is degraded, requests start timing out. For high-volume or latency-sensitive endpoints, this is unacceptable.

Asynchronous delivery via a background queue solves the latency problem but introduces a new one: a confirmed HTTP response no longer guarantees delivery. The application must communicate this to users clearly ("you'll receive an email shortly") and ensure the queue is durable enough to survive process restarts.

For most enterprise applications, asynchronous delivery via a System.Threading.Channels queue backed by a BackgroundService is the right default. It is lightweight, requires no external infrastructure, and handles typical send volumes well. For very high volume or multi-instance deployments, an external queue (Azure Service Bus, RabbitMQ) becomes appropriate โ€” but that is a later scaling decision, not a day-one requirement.

MailKit or Provider SDK?

MailKit is the right default for SMTP-based sending. It is the library Microsoft recommends over System.Net.Mail, it supports all modern authentication modes including OAuth 2.0, and it handles TLS negotiation correctly across all major email providers. The MailKit.Net.Smtp.SmtpClient implements IDisposable and is designed to be instantiated per-send rather than shared across threads.

Provider SDKs (SendGrid, Postmark, Resend, AWS SES) make sense when your organisation has committed to a specific email delivery platform and needs provider-specific features like event webhooks, suppression lists, or ISP reputation management. The trade-off is tighter coupling to a vendor. Abstract the provider behind IEmailService and the coupling is manageable.

FluentEmail sits above both โ€” it provides a fluent builder API and supports multiple backends (SMTP via MailKit, SendGrid, Mailgun, etc.) through pluggable senders. It is a good choice when you expect to switch providers or support multiple sending strategies in the same application.

How Should You Model the Email Message?

Avoid coupling your application code to library-specific message objects. Defining your own EmailMessage value type โ€” with To, Subject, Body, IsHtml, and optional Attachments โ€” means your command handlers and domain events express intent in your own vocabulary, and the mapping to MailKit's MimeMessage or a provider SDK's request object stays contained inside the infrastructure layer.

The Architecture in Practice

The cleanest production architecture for transactional email in ASP.NET Core looks like this:

Application Layer:

  • IEmailService โ€” the interface that application code calls
  • EmailMessage โ€” an immutable message record

Infrastructure Layer:

  • ChannelEmailQueue โ€” wraps System.Threading.Channels.Channel<EmailMessage> and implements the write side (IEmailQueue)
  • EmailDeliveryService โ€” a BackgroundService that reads from the channel and invokes the SMTP sender
  • SmtpEmailSender (or SendGridEmailSender) โ€” the concrete delivery implementation
  • EmailSettings โ€” strongly typed options model bound to appsettings.json

Composition Root (Program.cs):

  • All above registered via AddTransientEmaailService() extension method or individual builder.Services.AddX() calls

This layering means you can swap the delivery implementation without changing any application code. You can test EmailDeliveryService by injecting a mock sender. You can run the channel-backed queue in development and replace it with a Service Bus-backed queue in production without touching the interface contract.

What Production Observability Looks Like

Email delivery failures are silent by default. Without explicit instrumentation, the first sign of a problem is users reporting missing emails โ€” hours or days after the failure started occurring. Any production email subsystem needs:

Structured logging at key points:

  • Message enqueued (log the To address, subject, and a correlation ID โ€” never the full body in case it contains PII)
  • Delivery attempted (provider, attempt number, elapsed time)
  • Delivery succeeded (provider, latency)
  • Delivery failed (provider, error category, whether retry is scheduled)
  • Message dead-lettered (permanent failure, requires attention)

Log levels matter. A single failed attempt that will be retried is a Warning. A message exceeding the maximum retry count and entering dead-letter is an Error. Application code enqueuing a message successfully is Debug โ€” it should not appear in production log streams by default.

Metrics worth tracking:

  • email.enqueued (counter)
  • email.delivered (counter, with provider label)
  • email.failed (counter, with failure reason label)
  • email.delivery_duration_ms (histogram)
  • email.queue_depth (gauge โ€” alert if this grows)

These map directly to OpenTelemetry metrics, which you can export to any compatible backend (Azure Monitor, Prometheus, Jaeger) without changing instrumentation code. This is consistent with the observability approach covered in this blog's ASP.NET Core OpenTelemetry guide.

Anti-Patterns Teams Hit in Production

Injecting SmtpClient as a singleton. SMTP connections are not thread-safe and are not reusable across requests the way HTTP connections are. Instantiate a new MailKit.Net.Smtp.SmtpClient per-send and dispose it properly.

Sending attachments by reading files inside the delivery loop. If a 5 MB PDF is read from disk inside the background delivery loop and the disk is temporarily unavailable, the delivery fails โ€” not because of the email provider, but because of a separate I/O problem. Load attachments before enqueuing the message and store the bytes in the message object itself, or use a pre-signed URL that the delivery service fetches lazily.

No retry budget. A common mistake is retrying indefinitely on any SMTP error. Transient errors (connection timeout, temporary authentication failure) are worth retrying with exponential backoff. Permanent errors (invalid address, rejected by recipient domain) should not be retried โ€” they will never succeed and burn through your provider's sending reputation.

Logging the full email body on each send attempt. If the email body contains PII (user names, addresses, token values), every failed delivery attempt leaks sensitive data into the log store. Log the correlation ID and envelope metadata only; log the body only in a controlled debug mode with explicit opt-in.

Using SmtpClient from System.Net.Mail. Microsoft has marked this as obsolete for modern .NET. It lacks support for modern authentication flows, TLS negotiation is less reliable, and it does not support async properly. MailKit is the correct replacement.

How to Relate Availability to Your SLA

One design decision teams frequently skip: what is your delivery SLA? The answer determines several architectural choices:

SLA Architecture
Best effort (no guarantee) In-process channel queue, no persistence
At-least-once delivery Queue with persistence (Hangfire, external broker)
Exactly-once delivery Idempotency keys + outbox pattern
Real-time (< 2 seconds to inbox) Synchronous delivery (accept the latency trade-off)

For most applications โ€” welcome emails, password resets, order confirmations โ€” at-least-once delivery is the right target. A durable queue backed by Hangfire or the Outbox pattern gives you this at acceptable complexity cost. If the same email is occasionally delivered twice due to a retry after a delivery acknowledgement was lost, the user receives two welcome emails โ€” annoying, but not a business-critical failure.

Exactly-once delivery is significantly more complex and usually not worth the engineering cost for transactional email unless duplicate delivery causes real business harm (e.g., duplicate billing notifications).

Configuration Patterns That Keep Credentials Safe

Store email credentials in the secrets hierarchy ASP.NET Core provides:

  1. Development: dotnet user-secrets set "Email:SmtpPassword" "dev-password" โ€” stays out of source control
  2. Staging/Production: Environment variables or Key Vault references via the built-in configuration providers
  3. Never: appsettings.json committed to source control, or credentials injected directly into service constructors

The EmailSettings options class (bound via builder.Services.Configure<EmailSettings>(builder.Configuration.GetSection("Email"))) gives you a single strongly typed view of all email configuration, with validation on startup via ValidateDataAnnotations() to catch misconfigured credentials before the first request is served โ€” not when the first email fails at 2 AM.

What to Do Next

The architecture described here gives you a production-ready foundation. The natural next steps from here are:

  1. Add templating. Razor-based email templates (using RazorEngine or the Razor engine inside your existing web project) let designers maintain email layouts without touching C#. Scriban templates are a lighter alternative with no dependency on the web host.

  2. Add an email preview endpoint. An internal /devtools/email-preview/{templateName} endpoint that renders the email HTML in the browser is invaluable during development and design review.

  3. Add delivery event webhooks. SendGrid, Postmark, and Resend all support inbound webhooks for delivery, open, and bounce events. Storing these in your database lets you surface delivery status in the application UI and trigger retry flows for bounces the provider considers permanent.

  4. Consider outgoing email for IHostedService and BackgroundService patterns. Using System.Threading.Channels as the in-process transport keeps complexity low โ€” but understanding the trade-offs between in-process and external queues is critical before choosing your delivery SLA.


FAQ

What is the best library for sending email in ASP.NET Core in 2026?

MailKit is the recommended library for SMTP-based sending in modern .NET. It replaces the legacy System.Net.Mail.SmtpClient, which Microsoft has deprecated, and supports modern authentication, TLS, and OAuth 2.0. For applications already committed to a provider like SendGrid or Postmark, use their official .NET SDK directly โ€” but abstract it behind IEmailService so switching providers does not require application changes.

Should I send email synchronously or asynchronously in ASP.NET Core?

For most production applications, asynchronous delivery via a background queue is the correct default. Sending synchronously blocks the HTTP request thread for the full duration of the SMTP round-trip (typically 100โ€“500ms), and a provider outage will cause HTTP requests to timeout. Enqueue the message, return the HTTP response immediately, and let a BackgroundService handle delivery. Reserve synchronous delivery only for scenarios where the user experience genuinely requires immediate delivery confirmation.

How do I prevent email sending from blocking my ASP.NET Core requests?

Use System.Threading.Channels to create an in-process email queue. Inject an IEmailQueue that accepts EmailMessage objects into your controllers or application handlers. A separate BackgroundService reads from the channel and performs the actual SMTP delivery. Your controller action completes in microseconds regardless of SMTP latency.

How do I store email credentials securely in ASP.NET Core?

Use the Options pattern (IOptions<EmailSettings>) with the ASP.NET Core configuration hierarchy: dotnet user-secrets for local development, environment variables for containers, and Azure Key Vault (or similar) for production. Never commit credentials to appsettings.json. Add ValidateDataAnnotations() and ValidateOnStart() to your options registration so missing or malformed credentials cause a startup failure rather than a silent runtime failure.

What retry strategy should I use for failed email deliveries?

Distinguish between transient and permanent failures. Transient failures โ€” SMTP connection timeouts, TLS failures, rate limiting โ€” are worth retrying with exponential backoff and jitter (3โ€“5 attempts over a few minutes). Permanent failures โ€” invalid recipient address, domain rejection โ€” should not be retried because they will never succeed and erode your sending reputation. After exhausting retries, move the message to a dead-letter queue and alert on it.

How do I test email sending in ASP.NET Core without hitting real SMTP servers?

Use a fake or in-memory IEmailSender implementation during testing. For integration tests with WebApplicationFactory, register a TestEmailSender : IEmailSender that stores outgoing messages in a ConcurrentQueue<EmailMessage> โ€” then assert against that queue in your integration test. For development environments, services like Mailpit (local SMTP trap) or Mailtrap.io capture outgoing emails without actually delivering them.

What is the difference between transactional and marketing email, and why does it matter architecturally?

Transactional emails are triggered by user actions (registration, password reset, order confirmation). Marketing emails are bulk sends to opted-in lists (newsletters, campaigns). They should be sent through different infrastructure: mixing them uses the same IP reputation for both, and if a marketing campaign triggers spam complaints, it can damage deliverability for your critical transactional sends. Use separate sending domains, IP pools, and often separate providers for each category.

Should I use FluentEmail or MailKit directly in ASP.NET Core?

FluentEmail wraps MailKit (and other backends) with a fluent builder API and template engine integrations. Use FluentEmail if you want a higher-level abstraction, need to support multiple sending backends, or want built-in template support with Razor or Liquid. Use MailKit directly if you need fine-grained control over SMTP negotiation, connection management, or want to minimise dependencies. Both are production-ready choices โ€” the decision is about abstraction level versus control, not reliability.

More from this blog

C

Coding Droplets

254 posts

Coding Droplets is your go-to resource for .NET and ASP.NET Core development. Whether you're just starting out or building production systems, you'll find practical guides, real-world patterns, and clear explanations that actually make sense.

From beginner-friendly tutorials to advanced architecture decisions. We publish fresh .NET content every day to help you grow at every stage of your career.