.NET & C# Development · Lesson 131 of 229
Sending Email in .NET with MailKit
Email powers receipts, password resets, alerts, and workflow notifications. In .NET, MailKit is the de facto SMTP library — modern, maintained, and works everywhere ASP.NET Core runs.
Related: Configuration & secrets · Background services · Course lesson
Architecture: don't send from the request thread
Bad: await smtp.SendAsync(...) directly inside a controller during checkout — slow, fragile, and couples HTTP latency to SendGrid.
Better:
HTTP request → queue job / BackgroundService → MailKit SMTP sendUsers get a fast response; retries happen offline.
1. Configuration
// appsettings.json — no passwords here
{
"Email": {
"Host": "smtp.sendgrid.net",
"Port": 587,
"UseStartTls": true,
"FromAddress": "noreply@orderflow.io",
"FromName": "OrderFlow"
}
}Secrets (password, API key):
dotnet user-secrets set "Email:Password" "your-smtp-password"Strongly typed options:
public sealed class EmailOptions
{
public const string SectionName = "Email";
public string Host { get; set; } = "";
public int Port { get; set; } = 587;
public bool UseStartTls { get; set; } = true;
public string FromAddress { get; set; } = "";
public string FromName { get; set; } = "";
public string? Username { get; set; }
public string? Password { get; set; }
}builder.Services.Configure<EmailOptions>(
builder.Configuration.GetSection(EmailOptions.SectionName));2. Install MailKit
dotnet add package MailKit
dotnet add package MimeKit3. IEmailSender abstraction
public interface IEmailSender
{
Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default);
}
public sealed record EmailMessage(
string To,
string Subject,
string HtmlBody,
string? PlainTextBody = null);Implementation:
using MailKit.Net.Smtp;
using MailKit.Security;
using Microsoft.Extensions.Options;
using MimeKit;
public sealed class MailKitEmailSender : IEmailSender
{
private readonly EmailOptions _options;
public MailKitEmailSender(IOptions<EmailOptions> options) =>
_options = options.Value;
public async Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default)
{
var mime = new MimeMessage();
mime.From.Add(new MailboxAddress(_options.FromName, _options.FromAddress));
mime.To.Add(MailboxAddress.Parse(message.To));
mime.Subject = message.Subject;
var builder = new BodyBuilder
{
HtmlBody = message.HtmlBody,
TextBody = message.PlainTextBody ?? StripHtml(message.HtmlBody)
};
mime.Body = builder.ToMessageBody();
using var client = new SmtpClient();
await client.ConnectAsync(_options.Host, _options.Port,
_options.UseStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto,
cancellationToken);
if (!string.IsNullOrEmpty(_options.Username))
await client.AuthenticateAsync(_options.Username, _options.Password, cancellationToken);
await client.SendAsync(mime, cancellationToken);
await client.DisconnectAsync(true, cancellationToken);
}
private static string StripHtml(string html) =>
System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", " ");
}Register as scoped or singleton (MailKit client is created per send here — simple and safe):
builder.Services.AddSingleton<IEmailSender, MailKitEmailSender>();4. Use from a minimal API endpoint
app.MapPost("/orders/{id:guid}/confirmation-email", async (
Guid id,
IEmailSender emailSender,
OrderDbContext db,
CancellationToken ct) =>
{
var order = await db.Orders.FindAsync([id], ct);
if (order is null) return Results.NotFound();
await emailSender.SendAsync(new EmailMessage(
To: order.CustomerEmail,
Subject: $"Order {order.Id} confirmed",
HtmlBody: $"<p>Thanks! Your order total is {order.Total:C}.</p>"
), ct);
return Results.Accepted();
});For production, enqueue instead of awaiting SMTP in the request.
5. Providers
| Provider | Typical use | |----------|-------------| | SendGrid / Mailgun / Amazon SES | Transactional at scale | | Office 365 SMTP | Internal corporate mail | | Local Papercut / MailHog | Dev inbox capture |
Dev tip: run MailHog in Docker and point SMTP to localhost:1025 with no TLS.
Security checklist
- Never log full email bodies with PII in production logs
- Rate-limit "forgot password" endpoints
- Validate
Toaddresses; prevent header injection - Use TLS; prefer API keys over plain passwords in config stores
- SPF/DKIM/DMARC on your sending domain — deliverability is ops work
Testing
- Unit tests: mock
IEmailSender, assert it was called with expected subject - Integration tests: use MailHog or a test SMTP sink; don't hit real inboxes in CI
Next: Background jobs · Webhooks