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.
You have three solid options. Each code example below lets you switch between them.
| .NET email provider path | Best for | Implementation style | Tradeoff |
|---|---|---|---|
Sequenzy over HttpClient | SaaS transactional and lifecycle email | Typed service with REST calls | No NuGet package needed, but you own DTOs |
| Resend SDK | Developer-friendly transactional sending | Inject IResend client | Marketing automation still needs another layer |
| SendGrid SDK | Enterprise sending and mature SDK support | Official NuGet client | Larger API surface and provider-specific models |
| SMTP | Legacy systems only | Avoid for new work | SmtpClient is obsolete and brittle |
// 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 youSmtpClient 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.
| Approach | Modern .NET fit | Why | Use instead |
|---|---|---|---|
SmtpClient | Poor | Obsolete, weak auth story, no modern resilience | API provider through HttpClient or SDK |
Raw HttpClient per send | Poor | Socket exhaustion and inconsistent config | IHttpClientFactory |
| Typed email service | Strong | Testable and provider-swappable | IEmailService abstraction |
| Background channel | Strong | Keeps requests fast and centralizes retries | BackgroundService worker |
| Polly retry policy | Strong | Handles transient provider or network failures | Retry with backoff and logging |
# No NuGet package needed - uses built-in HttpClient
# Optionally add Polly for retries:
dotnet add package Microsoft.Extensions.Http.Pollydotnet add package Resenddotnet add package SendGridConfigure 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"{
"Email": {
"Provider": "sequenzy",
"ApiKey": "",
"FromAddress": "noreply@yourdomain.com",
"FromName": "Your App"
}
}{
"Email": {
"Provider": "resend",
"ApiKey": "",
"FromAddress": "noreply@yourdomain.com",
"FromName": "Your App"
}
}{
"Email": {
"Provider": "sendgrid",
"ApiKey": "",
"FromAddress": "noreply@yourdomain.com",
"FromName": "Your App"
}
}Use .NET's dependency injection with an IEmailService interface. This lets you swap providers and mock for testing.
// 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; }
}// 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);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);
}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);
}
}
}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);
}
}
}| DI registration | Purpose | Production note |
|---|---|---|
IOptions<EmailOptions> | Binds provider, key, and sender config | Validate options on startup |
Typed HttpClient | Reuses connections and centralizes base URL | Set timeout and auth headers once |
IEmailService | Keeps controllers provider-agnostic | Mock this interface in tests |
| Provider implementation | Encapsulates API payload differences | Log provider response IDs |
| Background queue | Decouples email from HTTP request lifecycle | Add bounded capacity and failure monitoring |
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))));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>();builder.Services.Configure<EmailOptions>(
builder.Configuration.GetSection(EmailOptions.SectionName));
builder.Services.AddSingleton<ISendGridClient>(
new SendGridClient(builder.Configuration["Email:ApiKey"]!));
builder.Services.AddTransient<IEmailService, SendGridEmailService>();// 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);// 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
Test it:
curl -X POST https://localhost:5001/api/email/send-welcome \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "name": "Jane"}'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>© {DateTime.UtcNow.Year} Your App. All rights reserved.</p>
</div>
</body>
</html>
""";
public static string Welcome
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.
<!-- 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>
</
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);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));
}
}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));
}
}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));
}
}Email sends should not block your HTTP requests. Use System.Threading.Channels with a BackgroundService for non-blocking email delivery:
| Email source | Queue through channel? | Reason | Extra safeguard |
|---|---|---|---|
| Signup welcome | Yes | User creation should not wait on provider latency | Retry transient failures |
| Password reset | Usually yes | Keeps auth endpoint responsive | Surface failure if reset email cannot be queued |
| Stripe webhook | Yes | Webhook must return quickly | Verify signature before enqueueing |
| Bulk campaign | Yes | Needs rate control and backpressure | Use bounded channel or external queue |
| Admin test send | Optional | Immediate feedback can be useful | Keep separate from production fanout |
// 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>
// 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>();
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 });
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}",
// 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<
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
Add SPF, DKIM, and DMARC DNS records through your provider's dashboard. Without this, emails go straight to spam.
Send from mail.yourapp.com instead of your root domain. Protects your main domain's reputation.
# 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());- [ ] 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
At some point, you'll want more than one-off transactional emails. You'll want to:
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(),
});
}
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.
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.
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.
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.
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));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.
Use Papercut SMTP or MailHog - they capture emails without sending them. Or register a FakeEmailService that stores sent emails in memory during development.
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));
}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.
Here's what we covered:
HttpClientIEmailService for testabilityThe code in this guide is production-ready. Copy the patterns that fit your app, swap in your provider of choice, and start shipping emails.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.