Learnixo

.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 send

Users get a fast response; retries happen offline.


1. Configuration

JSON
// appsettings.json — no passwords here
{
  "Email": {
    "Host": "smtp.sendgrid.net",
    "Port": 587,
    "UseStartTls": true,
    "FromAddress": "noreply@orderflow.io",
    "FromName": "OrderFlow"
  }
}

Secrets (password, API key):

Bash
dotnet user-secrets set "Email:Password" "your-smtp-password"

Strongly typed options:

C#
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; }
}
C#
builder.Services.Configure<EmailOptions>(
    builder.Configuration.GetSection(EmailOptions.SectionName));

2. Install MailKit

Bash
dotnet add package MailKit
dotnet add package MimeKit

3. IEmailSender abstraction

C#
public interface IEmailSender
{
    Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default);
}

public sealed record EmailMessage(
    string To,
    string Subject,
    string HtmlBody,
    string? PlainTextBody = null);

Implementation:

C#
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):

C#
builder.Services.AddSingleton<IEmailSender, MailKitEmailSender>();

4. Use from a minimal API endpoint

C#
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 To addresses; 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