Back to Blog

How to Send Emails in C# / .NET (ASP.NET Core, 2026 Guide)

18 min read

Most "how to send email in C#" tutorials use SmtpClient. Microsoft themselves marked it as obsolete. It doesn't support modern authentication, pooling, or cancellation. Don't use it.

This guide covers how to send emails from ASP.NET Core properly: using API-based providers with HttpClient, dependency injection, Razor email templates, background sending with channels and hosted services, retry policies with Polly, and Stripe webhook handling. All examples use .NET 8+ and C# 12.

Pick an Email Provider

You have three solid options. Each code example below lets you switch between them.

  • Sequenzy is built for SaaS. Transactional emails, marketing campaigns, automated sequences, subscriber management, all from one API. If you're building a SaaS product, this is the simplest path because you won't need to glue together three different tools later. It also has built-in retries and native Stripe integration.
  • Resend is a developer-friendly transactional email API. Clean DX, good docs, solid deliverability. They have one-off broadcast campaigns but no automations or sequences.
  • SendGrid is the enterprise standard. Feature-rich, has an official .NET SDK. Good if you need high volume and don't mind a bigger API surface.

Why Not SmtpClient?

// SmtpClient — obsolete, don't use
using var client = new SmtpClient("smtp.gmail.com", 587);
client.Credentials = new NetworkCredential("you@gmail.com", "password");
await client.SendMailAsync(message);
// [Obsolete("SmtpClient doesn't support many modern protocols")]
 
// API provider — one HTTP call, proper error handling
await httpClient.PostAsJsonAsync("https://api.sequenzy.com/v1/transactional/send",
    new { to, subject, body });
// Clean, reliable, handles deliverability for you

SmtpClient is synchronous under the hood (even SendMailAsync wraps sync calls), doesn't support connection pooling, and can't handle OAuth2 authentication. Use an API provider instead.

Install Dependencies

Terminal
# No NuGet package needed — uses built-in HttpClient
# Optionally add Polly for retries:
dotnet add package Microsoft.Extensions.Http.Polly
Terminal
dotnet add package Resend
Terminal
dotnet add package SendGrid

Configure your API key. Use User Secrets for development and environment variables or Azure Key Vault for production:

# Development — User Secrets (never committed to source control)
dotnet user-secrets init
dotnet user-secrets set "Email:ApiKey" "sq_your_api_key_here"
appsettings.json
{
"Email": {
  "Provider": "sequenzy",
  "ApiKey": "",
  "FromAddress": "noreply@yourdomain.com",
  "FromName": "Your App"
}
}
appsettings.json
{
"Email": {
  "Provider": "resend",
  "ApiKey": "",
  "FromAddress": "noreply@yourdomain.com",
  "FromName": "Your App"
}
}
appsettings.json
{
"Email": {
  "Provider": "sendgrid",
  "ApiKey": "",
  "FromAddress": "noreply@yourdomain.com",
  "FromName": "Your App"
}
}

Create the Email Service

Use .NET's dependency injection with an IEmailService interface. This lets you swap providers and mock for testing.

Configuration

// Models/EmailOptions.cs
public class EmailOptions
{
    public const string SectionName = "Email";
 
    public required string Provider { get; init; }
    public required string ApiKey { get; init; }
    public required string FromAddress { get; init; }
    public required string FromName { get; init; }
}

Interface

// Services/IEmailService.cs
public interface IEmailService
{
    Task<EmailResult> SendAsync(string to, string subject, string body, CancellationToken ct = default);
}
 
public record EmailResult(bool Success, string? Id = null, string? Error = null);

Implementations

Services/SequenzyEmailService.cs
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

public class SequenzyEmailService : IEmailService
{
  private readonly HttpClient _http;
  private readonly ILogger<SequenzyEmailService> _logger;

  public SequenzyEmailService(
      HttpClient http,
      IOptions<EmailOptions> options,
      ILogger<SequenzyEmailService> logger)
  {
      _http = http;
      _logger = logger;

      _http.BaseAddress = new Uri("https://api.sequenzy.com/v1/");
      _http.DefaultRequestHeaders.Authorization =
          new AuthenticationHeaderValue("Bearer", options.Value.ApiKey);
      _http.Timeout = TimeSpan.FromSeconds(30);
  }

  public async Task<EmailResult> SendAsync(
      string to, string subject, string body, CancellationToken ct = default)
  {
      try
      {
          var response = await _http.PostAsJsonAsync("transactional/send",
              new { to, subject, body }, ct);

          if (response.IsSuccessStatusCode)
          {
              var result = await response.Content.ReadFromJsonAsync<SendResponse>(ct);
              _logger.LogInformation("Email sent to {To}, subject: {Subject}", to, subject);
              return new EmailResult(true, Id: result?.JobId);
          }

          var error = await response.Content.ReadAsStringAsync(ct);
          _logger.LogError("Email send failed ({Status}): {Error}", response.StatusCode, error);
          return new EmailResult(false, Error: error);
      }
      catch (Exception ex)
      {
          _logger.LogError(ex, "Email send failed for {To}", to);
          return new EmailResult(false, Error: ex.Message);
      }
  }

  private record SendResponse(string JobId);
}
Services/ResendEmailService.cs
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Resend;

public class ResendEmailService : IEmailService
{
  private readonly IResend _resend;
  private readonly EmailOptions _options;
  private readonly ILogger<ResendEmailService> _logger;

  public ResendEmailService(
      IResend resend,
      IOptions<EmailOptions> options,
      ILogger<ResendEmailService> logger)
  {
      _resend = resend;
      _options = options.Value;
      _logger = logger;
  }

  public async Task<EmailResult> SendAsync(
      string to, string subject, string body, CancellationToken ct = default)
  {
      try
      {
          var message = new EmailMessage
          {
              From = $"{_options.FromName} <{_options.FromAddress}>",
              To = to,
              Subject = subject,
              HtmlBody = body,
          };

          var result = await _resend.EmailSendAsync(message, ct);

          _logger.LogInformation("Email sent to {To}, id: {Id}", to, result?.Id);
          return new EmailResult(true, Id: result?.Id);
      }
      catch (Exception ex)
      {
          _logger.LogError(ex, "Email send failed for {To}", to);
          return new EmailResult(false, Error: ex.Message);
      }
  }
}
Services/SendGridEmailService.cs
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SendGrid;
using SendGrid.Helpers.Mail;

public class SendGridEmailService : IEmailService
{
  private readonly ISendGridClient _client;
  private readonly EmailOptions _options;
  private readonly ILogger<SendGridEmailService> _logger;

  public SendGridEmailService(
      ISendGridClient client,
      IOptions<EmailOptions> options,
      ILogger<SendGridEmailService> logger)
  {
      _client = client;
      _options = options.Value;
      _logger = logger;
  }

  public async Task<EmailResult> SendAsync(
      string to, string subject, string body, CancellationToken ct = default)
  {
      try
      {
          var from = new EmailAddress(_options.FromAddress, _options.FromName);
          var toAddress = new EmailAddress(to);
          var msg = MailHelper.CreateSingleEmail(from, toAddress, subject, null, body);

          var response = await _client.SendEmailAsync(msg, ct);

          if (response.IsSuccessStatusCode)
          {
              _logger.LogInformation("Email sent to {To}", to);
              return new EmailResult(true);
          }

          var error = await response.Body.ReadAsStringAsync(ct);
          _logger.LogError("SendGrid error ({Status}): {Error}", response.StatusCode, error);
          return new EmailResult(false, Error: error);
      }
      catch (Exception ex)
      {
          _logger.LogError(ex, "Email send failed for {To}", to);
          return new EmailResult(false, Error: ex.Message);
      }
  }
}

Register in DI

Program.cs
builder.Services.Configure<EmailOptions>(
  builder.Configuration.GetSection(EmailOptions.SectionName));

builder.Services.AddHttpClient<IEmailService, SequenzyEmailService>()
  .AddTransientHttpErrorPolicy(p =>
      p.WaitAndRetryAsync(3, attempt =>
          TimeSpan.FromSeconds(Math.Pow(2, attempt))));
Program.cs
builder.Services.Configure<EmailOptions>(
  builder.Configuration.GetSection(EmailOptions.SectionName));

builder.Services.AddOptions();
builder.Services.AddHttpClient<ResendClient>();
builder.Services.Configure<ResendClientOptions>(o =>
  o.ApiToken = builder.Configuration["Email:ApiKey"]!);
builder.Services.AddTransient<IResend, ResendClient>();
builder.Services.AddTransient<IEmailService, ResendEmailService>();
Program.cs
builder.Services.Configure<EmailOptions>(
  builder.Configuration.GetSection(EmailOptions.SectionName));

builder.Services.AddSingleton<ISendGridClient>(
  new SendGridClient(builder.Configuration["Email:ApiKey"]!));
builder.Services.AddTransient<IEmailService, SendGridEmailService>();

Send Your First Email

Minimal API

// Program.cs
app.MapPost("/api/send", async (SendRequest request, IEmailService email) =>
{
    var result = await email.SendAsync(
        request.Email,
        "Hello from ASP.NET Core",
        "<p>Your app is sending emails. Nice.</p>"
    );
 
    return result.Success
        ? Results.Ok(new { result.Id })
        : Results.StatusCode(500);
});
 
record SendRequest(string Email);

Controller

// Controllers/EmailController.cs
[ApiController]
[Route("api/[controller]")]
public class EmailController(IEmailService email) : ControllerBase
{
    [HttpPost("send-welcome")]
    public async Task<IActionResult> SendWelcome([FromBody] WelcomeRequest request)
    {
        var html = EmailTemplates.Welcome(request.Name, "/dashboard");
        var result = await email.SendAsync(request.Email, $"Welcome, {request.Name}", html);
 
        return result.Success ? Ok(new { result.Id }) : StatusCode(500, new { result.Error });
    }
}
 
record WelcomeRequest(string Email, string Name);

Test it:

curl -X POST https://localhost:5001/api/email/send-welcome \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com", "name": "Jane"}'

Build Email Templates

Option 1: Raw String Literals (Simple, No Dependencies)

C# 11+ raw string literals are great for email templates:

// Templates/EmailTemplates.cs
public static class EmailTemplates
{
    public static string Layout(string content) => $"""
        <!DOCTYPE html>
        <html>
        <head>
          <meta charset="utf-8" />
          <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        </head>
        <body style="margin:0;padding:0;background-color:#f6f9fc;font-family:sans-serif;">
          <div style="max-width:480px;margin:40px auto;background:#fff;border-radius:8px;padding:40px;">
            {content}
          </div>
          <div style="text-align:center;padding:20px;color:#9ca3af;font-size:12px;">
            <p>&copy; {DateTime.UtcNow.Year} Your App. All rights reserved.</p>
          </div>
        </body>
        </html>
        """;
 
    public static string Welcome(string name, string dashboardUrl) => Layout($"""
        <h1 style="font-size:24px;margin-bottom:16px;">Welcome, {Encode(name)}</h1>
        <p style="font-size:16px;line-height:1.6;color:#374151;">
          Your account is ready. Click below to get started.
        </p>
        <a href="{Encode(dashboardUrl)}"
           style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;
                  border-radius:6px;text-decoration:none;font-size:14px;font-weight:600;
                  margin-top:16px;">
          Go to Dashboard
        </a>
        """);
 
    public static string PasswordReset(string resetUrl) => Layout($"""
        <h2 style="font-size:20px;">Password Reset</h2>
        <p style="font-size:16px;line-height:1.6;color:#374151;">
          Click the link below to reset your password. This link expires in 1 hour.
        </p>
        <a href="{Encode(resetUrl)}"
           style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;
                  border-radius:6px;text-decoration:none;font-size:14px;font-weight:600;
                  margin-top:16px;">
          Reset Password
        </a>
        <p style="color:#6b7280;font-size:14px;margin-top:24px;">
          If you didn't request this, ignore this email.
        </p>
        """);
 
    public static string PaymentReceipt(string amount, string plan, string invoiceUrl) => Layout($"""
        <h2 style="font-size:20px;">Payment Received</h2>
        <p style="font-size:16px;color:#374151;">Thanks for your payment.</p>
        <table style="width:100%;border-collapse:collapse;margin:16px 0;">
          <tr>
            <td style="padding:8px;border-bottom:1px solid #e5e7eb;">Plan</td>
            <td style="padding:8px;border-bottom:1px solid #e5e7eb;text-align:right;">{Encode(plan)}</td>
          </tr>
          <tr>
            <td style="padding:8px;font-weight:600;">Total</td>
            <td style="padding:8px;text-align:right;font-weight:600;">{Encode(amount)}</td>
          </tr>
        </table>
        <a href="{Encode(invoiceUrl)}" style="color:#f97316;">View full invoice</a>
        """);
 
    private static string Encode(string value) =>
        System.Net.WebUtility.HtmlEncode(value);
}

Option 2: Razor Templates (Type-Safe, Full Razor Features)

For complex templates, render Razor views to strings:

dotnet add package RazorLight
// Services/RazorEmailRenderer.cs
using RazorLight;
 
public class RazorEmailRenderer
{
    private readonly RazorLightEngine _engine;
 
    public RazorEmailRenderer()
    {
        _engine = new RazorLightEngineBuilder()
            .UseFileSystemProject(Path.Combine(Directory.GetCurrentDirectory(), "EmailTemplates"))
            .UseMemoryCachingProvider()
            .Build();
    }
 
    public async Task<string> RenderAsync<TModel>(string templateName, TModel model)
    {
        return await _engine.CompileRenderAsync($"{templateName}.cshtml", model);
    }
}
<!-- EmailTemplates/Welcome.cshtml -->
@model WelcomeEmailModel
 
<!DOCTYPE html>
<html>
<body style="margin:0;padding:0;background-color:#f6f9fc;font-family:sans-serif;">
  <div style="max-width:480px;margin:40px auto;background:#fff;border-radius:8px;padding:40px;">
    <h1 style="font-size:24px;margin-bottom:16px;">Welcome, @Model.Name</h1>
    <p style="font-size:16px;line-height:1.6;color:#374151;">
      Your account is ready.
    </p>
    <a href="@Model.DashboardUrl"
       style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;
              border-radius:6px;text-decoration:none;font-weight:600;margin-top:16px;">
      Go to Dashboard
    </a>
  </div>
</body>
</html>
public record WelcomeEmailModel(string Name, string DashboardUrl);
 
// Usage
var renderer = new RazorEmailRenderer();
var html = await renderer.RenderAsync("Welcome", new WelcomeEmailModel("Jane", "/dashboard"));
await emailService.SendAsync("jane@example.com", "Welcome, Jane", html);

Common Email Patterns for SaaS

Stripe Webhook Handler

Controllers/StripeWebhookController.cs
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("webhooks/stripe")]
public class StripeWebhookController(
  IEmailService email,
  IConfiguration config,
  ILogger<StripeWebhookController> logger) : ControllerBase
{
  [HttpPost]
  public async Task<IActionResult> Handle()
  {
      var payload = await new StreamReader(Request.Body).ReadToEndAsync();
      var signature = Request.Headers["Stripe-Signature"].ToString();
      var secret = config["Stripe:WebhookSecret"]!;

      if (!VerifyStripeSignature(payload, signature, secret))
      {
          return BadRequest("Invalid signature");
      }

      var json = System.Text.Json.JsonDocument.Parse(payload);
      var eventType = json.RootElement.GetProperty("type").GetString();
      var data = json.RootElement.GetProperty("data").GetProperty("object");

      switch (eventType)
      {
          case "checkout.session.completed":
              await HandleCheckout(data);
              break;
          case "invoice.payment_failed":
              await HandlePaymentFailed(data);
              break;
      }

      return Ok(new { received = true });
  }

  private async Task HandleCheckout(System.Text.Json.JsonElement session)
  {
      var customerEmail = session.GetProperty("customer_email").GetString()!;
      var amount = session.GetProperty("amount_total").GetInt32();
      var formatted = (amount / 100.0).ToString("C");

      await email.SendAsync(
          customerEmail,
          $"Payment receipt - {formatted}",
          EmailTemplates.PaymentReceipt(formatted, "Pro", "#")
      );

      logger.LogInformation("Checkout completed for {Email}", customerEmail);
  }

  private async Task HandlePaymentFailed(System.Text.Json.JsonElement invoice)
  {
      var customerEmail = invoice.GetProperty("customer_email").GetString()!;

      await email.SendAsync(
          customerEmail,
          "Payment failed",
          "<h1>Payment issue</h1><p>We couldn't process your latest payment. Please update your card.</p>"
      );

      logger.LogWarning("Payment failed for {Email}", customerEmail);
  }

  private static bool VerifyStripeSignature(string payload, string sigHeader, string secret)
  {
      var parts = sigHeader.Split(',')
          .Select(p => p.Split('='))
          .ToDictionary(p => p[0], p => p[1]);

      if (!parts.TryGetValue("t", out var timestamp) || !parts.TryGetValue("v1", out var sig))
          return false;

      var signedPayload = $"{timestamp}.{payload}";
      using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
      var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(signedPayload));
      var expected = Convert.ToHexString(hash).ToLowerInvariant();

      return CryptographicOperations.FixedTimeEquals(
          Encoding.UTF8.GetBytes(expected),
          Encoding.UTF8.GetBytes(sig));
  }
}
Controllers/StripeWebhookController.cs
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("webhooks/stripe")]
public class StripeWebhookController(
  IEmailService email,
  IConfiguration config,
  ILogger<StripeWebhookController> logger) : ControllerBase
{
  [HttpPost]
  public async Task<IActionResult> Handle()
  {
      var payload = await new StreamReader(Request.Body).ReadToEndAsync();
      var signature = Request.Headers["Stripe-Signature"].ToString();
      var secret = config["Stripe:WebhookSecret"]!;

      if (!VerifyStripeSignature(payload, signature, secret))
          return BadRequest("Invalid signature");

      var json = System.Text.Json.JsonDocument.Parse(payload);
      var eventType = json.RootElement.GetProperty("type").GetString();
      var data = json.RootElement.GetProperty("data").GetProperty("object");

      switch (eventType)
      {
          case "checkout.session.completed":
              var customerEmail = data.GetProperty("customer_email").GetString()!;
              var amount = (data.GetProperty("amount_total").GetInt32() / 100.0).ToString("C");
              await email.SendAsync(customerEmail, $"Payment receipt - {amount}",
                  EmailTemplates.PaymentReceipt(amount, "Pro", "#"));
              break;

          case "invoice.payment_failed":
              var failedEmail = data.GetProperty("customer_email").GetString()!;
              await email.SendAsync(failedEmail, "Payment failed",
                  "<h1>Payment issue</h1><p>Please update your payment method.</p>");
              break;
      }

      return Ok(new { received = true });
  }

  private static bool VerifyStripeSignature(string payload, string sigHeader, string secret)
  {
      var parts = sigHeader.Split(',')
          .Select(p => p.Split('='))
          .ToDictionary(p => p[0], p => p[1]);

      if (!parts.TryGetValue("t", out var timestamp) || !parts.TryGetValue("v1", out var sig))
          return false;

      var signedPayload = $"{timestamp}.{payload}";
      using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
      var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(signedPayload));
      var expected = Convert.ToHexString(hash).ToLowerInvariant();

      return CryptographicOperations.FixedTimeEquals(
          Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(sig));
  }
}
Controllers/StripeWebhookController.cs
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("webhooks/stripe")]
public class StripeWebhookController(
  IEmailService email,
  IConfiguration config,
  ILogger<StripeWebhookController> logger) : ControllerBase
{
  [HttpPost]
  public async Task<IActionResult> Handle()
  {
      var payload = await new StreamReader(Request.Body).ReadToEndAsync();
      var signature = Request.Headers["Stripe-Signature"].ToString();
      var secret = config["Stripe:WebhookSecret"]!;

      if (!VerifyStripeSignature(payload, signature, secret))
          return BadRequest("Invalid signature");

      var json = System.Text.Json.JsonDocument.Parse(payload);
      var eventType = json.RootElement.GetProperty("type").GetString();
      var data = json.RootElement.GetProperty("data").GetProperty("object");

      switch (eventType)
      {
          case "checkout.session.completed":
              var customerEmail = data.GetProperty("customer_email").GetString()!;
              var amount = (data.GetProperty("amount_total").GetInt32() / 100.0).ToString("C");
              await email.SendAsync(customerEmail, $"Payment receipt - {amount}",
                  EmailTemplates.PaymentReceipt(amount, "Pro", "#"));
              break;

          case "invoice.payment_failed":
              var failedEmail = data.GetProperty("customer_email").GetString()!;
              await email.SendAsync(failedEmail, "Payment failed",
                  "<h1>Payment issue</h1><p>Please update your payment method.</p>");
              break;
      }

      return Ok(new { received = true });
  }

  private static bool VerifyStripeSignature(string payload, string sigHeader, string secret)
  {
      var parts = sigHeader.Split(',')
          .Select(p => p.Split('='))
          .ToDictionary(p => p[0], p => p[1]);

      if (!parts.TryGetValue("t", out var timestamp) || !parts.TryGetValue("v1", out var sig))
          return false;

      var signedPayload = $"{timestamp}.{payload}";
      using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
      var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(signedPayload));
      var expected = Convert.ToHexString(hash).ToLowerInvariant();

      return CryptographicOperations.FixedTimeEquals(
          Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(sig));
  }
}

Background Sending with Channels

Email sends should not block your HTTP requests. Use System.Threading.Channels with a BackgroundService for non-blocking email delivery:

// Services/EmailChannel.cs
using System.Threading.Channels;
 
public record EmailJob(string To, string Subject, string Body);
 
public class EmailChannel
{
    private readonly Channel<EmailJob> _channel = Channel.CreateBounded<EmailJob>(
        new BoundedChannelOptions(100)
        {
            FullMode = BoundedChannelFullMode.Wait,
            SingleReader = false,
            SingleWriter = false,
        });
 
    public ChannelWriter<EmailJob> Writer => _channel.Writer;
    public ChannelReader<EmailJob> Reader => _channel.Reader;
}
// Services/EmailWorker.cs
public class EmailWorker(
    EmailChannel channel,
    IServiceScopeFactory scopeFactory,
    ILogger<EmailWorker> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        logger.LogInformation("Email worker started");
 
        await foreach (var job in channel.Reader.ReadAllAsync(ct))
        {
            try
            {
                using var scope = scopeFactory.CreateScope();
                var emailService = scope.ServiceProvider.GetRequiredService<IEmailService>();
 
                var result = await emailService.SendAsync(job.To, job.Subject, job.Body, ct);
 
                if (!result.Success)
                {
                    logger.LogWarning("Email to {To} failed: {Error}", job.To, result.Error);
                }
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Email worker error for {To}", job.To);
            }
        }
    }
}

Register and use:

// Program.cs
builder.Services.AddSingleton<EmailChannel>();
builder.Services.AddHostedService<EmailWorker>();
 
// In your endpoint — non-blocking
app.MapPost("/api/signup", async (SignupRequest req, EmailChannel channel) =>
{
    var user = await CreateUser(req);
 
    await channel.Writer.WriteAsync(new EmailJob(
        user.Email,
        $"Welcome, {user.Name}",
        EmailTemplates.Welcome(user.Name, "/dashboard")
    ));
 
    return Results.Ok(new { user.Id });
});

Error Handling with Polly

Add retry policies with Polly for resilient HTTP calls:

dotnet add package Microsoft.Extensions.Http.Polly
// Program.cs
builder.Services.AddHttpClient<IEmailService, SequenzyEmailService>()
    .AddTransientHttpErrorPolicy(policy =>
        policy.WaitAndRetryAsync(
            retryCount: 3,
            sleepDurationProvider: attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)),
            onRetry: (outcome, delay, attempt, _) =>
            {
                var logger = builder.Services.BuildServiceProvider()
                    .GetRequiredService<ILogger<SequenzyEmailService>>();
                logger.LogWarning(
                    "Email retry {Attempt}/3 after {Delay}s: {Status}",
                    attempt, delay.TotalSeconds, outcome.Result?.StatusCode);
            }))
    .AddTransientHttpErrorPolicy(policy =>
        policy.CircuitBreakerAsync(
            handledEventsAllowedBeforeBreaking: 5,
            durationOfBreak: TimeSpan.FromMinutes(1)));

Testing

Unit Test the Email Service

// Tests/EmailServiceTests.cs
using System.Net;
using System.Net.Http;
using Moq;
using Moq.Protected;
 
public class SequenzyEmailServiceTests
{
    [Fact]
    public async Task SendAsync_ReturnsSuccess_OnOkResponse()
    {
        var handler = new Mock<HttpMessageHandler>();
        handler.Protected()
            .Setup<Task<HttpResponseMessage>>("SendAsync",
                ItExpr.IsAny<HttpRequestMessage>(),
                ItExpr.IsAny<CancellationToken>())
            .ReturnsAsync(new HttpResponseMessage
            {
                StatusCode = HttpStatusCode.OK,
                Content = JsonContent.Create(new { jobId = "job-123" }),
            });
 
        var client = new HttpClient(handler.Object);
        var options = Options.Create(new EmailOptions
        {
            Provider = "sequenzy",
            ApiKey = "test-key",
            FromAddress = "test@test.com",
            FromName = "Test",
        });
        var logger = Mock.Of<ILogger<SequenzyEmailService>>();
 
        var service = new SequenzyEmailService(client, options, logger);
        var result = await service.SendAsync("user@test.com", "Test", "<p>Hello</p>");
 
        Assert.True(result.Success);
        Assert.Equal("job-123", result.Id);
    }
 
    [Fact]
    public async Task SendAsync_ReturnsError_On429()
    {
        var handler = new Mock<HttpMessageHandler>();
        handler.Protected()
            .Setup<Task<HttpResponseMessage>>("SendAsync",
                ItExpr.IsAny<HttpRequestMessage>(),
                ItExpr.IsAny<CancellationToken>())
            .ReturnsAsync(new HttpResponseMessage
            {
                StatusCode = HttpStatusCode.TooManyRequests,
                Content = new StringContent("Rate limited"),
            });
 
        var client = new HttpClient(handler.Object);
        var options = Options.Create(new EmailOptions
        {
            Provider = "sequenzy", ApiKey = "test-key",
            FromAddress = "test@test.com", FromName = "Test",
        });
 
        var service = new SequenzyEmailService(client, options,
            Mock.Of<ILogger<SequenzyEmailService>>());
 
        var result = await service.SendAsync("user@test.com", "Test", "<p>Hello</p>");
 
        Assert.False(result.Success);
    }
}

Integration Test with WebApplicationFactory

public class EmailEndpointTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;
 
    public EmailEndpointTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                // Replace real email service with a mock
                services.AddSingleton<IEmailService, FakeEmailService>();
            });
        }).CreateClient();
    }
 
    [Fact]
    public async Task SendWelcome_ReturnsOk()
    {
        var response = await _client.PostAsJsonAsync("/api/email/send-welcome",
            new { Email = "test@test.com", Name = "Jane" });
 
        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
    }
}
 
public class FakeEmailService : IEmailService
{
    public List<(string To, string Subject, string Body)> SentEmails { get; } = [];
 
    public Task<EmailResult> SendAsync(string to, string subject, string body, CancellationToken ct = default)
    {
        SentEmails.Add((to, subject, body));
        return Task.FromResult(new EmailResult(true, Id: "fake-id"));
    }
}

Going to Production

1. Verify Your Domain

Add SPF, DKIM, and DMARC DNS records through your provider's dashboard. Without this, emails go straight to spam.

2. Use a Dedicated Sending Domain

Send from mail.yourapp.com instead of your root domain. Protects your main domain's reputation.

3. Secure Your Secrets

# Development: User Secrets
dotnet user-secrets set "Email:ApiKey" "sq_your_key"
 
# Production: Environment variables
export Email__ApiKey=sq_your_production_key
 
# Or Azure Key Vault
builder.Configuration.AddAzureKeyVault(new Uri("https://your-vault.vault.azure.net/"),
    new DefaultAzureCredential());

Production Checklist

- [ ] Domain verified (SPF, DKIM, DMARC)
- [ ] Dedicated sending domain (mail.yourapp.com)
- [ ] API keys in User Secrets (dev) / env vars or Key Vault (prod)
- [ ] HttpClient registered via DI (not new HttpClient())
- [ ] Polly retry + circuit breaker policies
- [ ] Background worker for non-blocking sends
- [ ] Structured logging on all send attempts
- [ ] IEmailService interface for testability
- [ ] Unit tests with mocked HttpMessageHandler
- [ ] Integration tests with FakeEmailService
- [ ] HTML encoding on all user data in templates
- [ ] Stripe webhook signature verification
- [ ] CancellationToken passed through async chain

Beyond Transactional: Marketing Emails and Sequences

At some point, you'll want more than one-off transactional emails. You'll want to:

  • Send onboarding sequences that guide new users through your product over several days
  • Run marketing campaigns to announce features or share updates
  • Automate lifecycle emails based on what users do (or don't do) in your app
  • Track engagement to see which emails get opened and clicked

Most teams stitch together a transactional provider (Resend, SendGrid) with a separate marketing tool (Mailchimp, ConvertKit). That means two dashboards, two billing systems, and keeping subscriber lists in sync.

Sequenzy handles both from one platform. Same API, same dashboard. You get transactional sends, marketing campaigns, automated sequences, subscriber segments, and native Stripe integration for SaaS-specific automations like trial conversion and churn prevention.

// Services/SequenzySubscriberService.cs
public class SequenzySubscriberService(HttpClient http)
{
    public async Task AddSubscriberAsync(string email, string? firstName = null,
        string[]? tags = null, Dictionary<string, object>? attributes = null)
    {
        await http.PostAsJsonAsync("/subscribers", new
        {
            email,
            firstName,
            tags = tags ?? [],
            customAttributes = attributes ?? new(),
        });
    }
 
    public async Task AddTagAsync(string email, string tag)
    {
        await http.PostAsJsonAsync("/subscribers/tags", new { email, tag });
    }
 
    public async Task TrackEventAsync(string email, string eventName,
        Dictionary<string, object>? properties = null)
    {
        await http.PostAsJsonAsync("/subscribers/events", new
        {
            email,
            @event = eventName,
            properties = properties ?? new(),
        });
    }
}
 
// Usage
await subscribers.AddSubscriberAsync("jane@example.com",
    firstName: "Jane",
    tags: ["signed-up"],
    attributes: new() { ["plan"] = "free", ["source"] = "organic" });
 
await subscribers.AddTagAsync("jane@example.com", "customer");
 
await subscribers.TrackEventAsync("jane@example.com", "onboarding.completed",
    new() { ["completedSteps"] = 5 });

FAQ

Why not use SmtpClient?

Microsoft marked SmtpClient as obsolete. It doesn't support modern auth (OAuth2), connection pooling, or proper async. Even SendMailAsync is a wrapper around synchronous code. Use HttpClient with an API provider instead.

Should I use the SendGrid SDK or HttpClient?

The SendGrid NuGet package provides type-safe request/response models and handles serialization. If you're using SendGrid, use their SDK. For Sequenzy, HttpClient is all you need since the API is simple JSON.

How do I send emails from a background job?

Use System.Threading.Channels with a BackgroundService (shown above). For more complex scenarios, use Hangfire or Azure Queue Storage. The key is to never block an HTTP request with email sending.

How do I prevent XSS in email templates?

Always use System.Net.WebUtility.HtmlEncode() when inserting user data into HTML templates. Razor templates auto-encode by default, which is another reason to prefer them for complex emails.

How do I handle rate limiting?

With Polly, add a retry policy that respects Retry-After headers:

.AddTransientHttpErrorPolicy(p => p.WaitAndRetryAsync(
    retryCount: 3,
    sleepDurationProvider: (attempt, result, _) =>
    {
        if (result.Result?.Headers.RetryAfter?.Delta is { } retryAfter)
            return retryAfter;
        return TimeSpan.FromSeconds(Math.Pow(2, attempt));
    },
    onRetryAsync: (_, _, _, _) => Task.CompletedTask));

Can I use Razor Pages for email templates?

Yes, but it requires more setup. You need to render Razor views outside the HTTP pipeline using IRazorViewEngine. Libraries like RazorLight or Razor.Templating.Core simplify this. For simple emails, raw string literals are easier.

How do I test emails in development?

Use Papercut SMTP or MailHog — they capture emails without sending them. Or register a FakeEmailService that stores sent emails in memory during development.

How do I send to multiple recipients?

Modify your IEmailService to accept a list, or call SendAsync in a loop. For bulk sending, use the background channel to avoid blocking:

foreach (var recipient in recipients)
{
    await channel.Writer.WriteAsync(new EmailJob(recipient.Email, subject, body));
}

What about MailKit?

MailKit is the recommended replacement for SmtpClient if you need SMTP. It's fully async, supports modern auth, and handles MIME properly. But for most apps, an API provider is simpler and more reliable than managing SMTP connections.

Wrapping Up

Here's what we covered:

  1. Ditch SmtpClient and use API providers with HttpClient
  2. Dependency injection with IEmailService for testability
  3. Raw string literals or Razor for type-safe email templates
  4. Background services with channels for non-blocking sends
  5. Polly for retry policies and circuit breakers
  6. Stripe webhooks with HMAC signature verification
  7. Production setup: User Secrets, Key Vault, structured logging

The code in this guide is production-ready. Copy the patterns that fit your app, swap in your provider of choice, and start shipping emails.

Frequently Asked Questions

Should I use SmtpClient or an email provider SDK in .NET?

Use an email provider's SDK or HTTP client. SmtpClient is marked as obsolete in .NET and requires you to manage SMTP connections, TLS, and retries manually. Modern email APIs via HttpClient are simpler, more reliable, and provide delivery tracking.

How do I register email services in ASP.NET Core's DI container?

Register your email service as a singleton or scoped service in Program.cs using builder.Services.AddSingleton<IEmailService, EmailService>(). Inject IEmailService into controllers or minimal API handlers. Use IOptions<EmailSettings> pattern for configuration.

How do I store email API keys securely in .NET?

Use User Secrets for local development (dotnet user-secrets set "Email:ApiKey" "your-key") and Azure Key Vault or environment variables for production. Never store keys in appsettings.json since it gets committed to source control.

How do I send emails in the background in ASP.NET Core?

Use IHostedService or BackgroundService for simple background processing. For more complex scenarios, use Hangfire or a message queue (RabbitMQ, Azure Service Bus). Avoid Task.Run() in controllers since the task can be lost if the process recycles.

Can I use Razor views for email templates in .NET?

Yes. Use RazorViewToStringRenderer to render Razor .cshtml files to HTML strings. This gives you full Razor syntax (layouts, partials, tag helpers) for email templates. Libraries like RazorLight simplify this setup for non-MVC projects.

How do I handle email sending failures in ASP.NET Core?

Use Polly for retry policies with exponential backoff. Wrap your email sending in a Policy.Handle<HttpRequestException>().WaitAndRetryAsync() pipeline. Log failures with ILogger and return appropriate status codes from your API endpoints.

How do I test email sending in .NET?

Create an IEmailService interface and mock it in unit tests using Moq or NSubstitute. For integration tests, create a FakeEmailService that captures sent messages in a list. This verifies your code calls the email service correctly without making HTTP requests.

How do I send templated emails with dynamic data in C#?

Use string interpolation for simple templates, Razor rendering for complex HTML, or Scriban/Fluid template engines for user-editable templates. Pass a strongly-typed model to your template engine and render it to an HTML string before sending.

Can I send emails from a .NET minimal API?

Yes. Define an endpoint with app.MapPost("/api/send-email", async (EmailRequest req, IEmailService email) => { ... }). Minimal APIs support dependency injection, so your email service is injected automatically. Keep the handler thin—delegate logic to your service layer.

How do I handle rate limiting for email endpoints in ASP.NET Core?

Use ASP.NET Core's built-in rate limiting middleware (AddRateLimiter() in .NET 7+). Configure a fixed or sliding window policy on your email endpoints. For per-user limits, use a partitioned rate limiter keyed by user ID or IP address.