.NET's built-in System.Net.Mail.SmtpClient is officially obsolete for new code, so this guide uses MailKit — the library Microsoft's own documentation recommends instead. It sets up a reusable email service backed by Postwing, then covers HTML mail, attachments and background sending.
| Setting | Value |
|---|---|
| SMTP host | smtp.postwing.app |
| Port | 587 |
| Encryption | STARTTLS (the connection is upgraded to TLS before login) |
| Username | The login of an SMTP token for your domain |
| Password | The password of that token — shown once, when the token is created |
dotnet add package MailKit{
"EmailSettings": {
"Host": "smtp.postwing.app",
"Port": 587,
"UseStartTls": true,
"SenderEmail": "noreply@your-domain.com",
"SenderName": "Acme"
}
}Keep the token itself out of the file that ships with your build — in development use user secrets, and in production the environment or your key vault:
dotnet user-secrets set "EmailSettings:Username" "token-login@your-domain.com"
dotnet user-secrets set "EmailSettings:Password" "your-token-password"Bind the section to a settings class:
public class EmailSettings
{
public string Host { get; set; } = "";
public int Port { get; set; } = 587;
public bool UseStartTls { get; set; } = true;
public string Username { get; set; } = "";
public string Password { get; set; } = "";
public string SenderEmail { get; set; } = "";
public string SenderName { get; set; } = "";
}using MailKit.Net.Smtp;
using MailKit.Security;
using Microsoft.Extensions.Options;
using MimeKit;
public class EmailService
{
private readonly EmailSettings _settings;
private readonly ILogger<EmailService> _logger;
public EmailService(IOptions<EmailSettings> settings, ILogger<EmailService> logger)
{
_settings = settings.Value;
_logger = logger;
}
public async Task SendAsync(
string to,
string subject,
string html,
string text,
CancellationToken ct = default)
{
var message = new MimeMessage();
message.From.Add(new MailboxAddress(_settings.SenderName, _settings.SenderEmail));
message.To.Add(MailboxAddress.Parse(to));
message.Subject = subject;
// Both parts, so the message is multipart/alternative rather than HTML-only.
message.Body = new BodyBuilder { HtmlBody = html, TextBody = text }.ToMessageBody();
using var client = new SmtpClient { Timeout = 10_000 };
var option = _settings.UseStartTls
? SecureSocketOptions.StartTls // port 587
: SecureSocketOptions.SslOnConnect; // port 465
await client.ConnectAsync(_settings.Host, _settings.Port, option, ct);
await client.AuthenticateAsync(_settings.Username, _settings.Password, ct);
await client.SendAsync(message, ct);
await client.DisconnectAsync(true, ct);
}
}ServerCertificateValidationCallback to return true is a common copy-paste fix that accepts any certificate at all, including an attacker's. If validation fails, the cause is normally a missing CA bundle in the container image — fix that instead. builder.Services.Configure<EmailSettings>(
builder.Configuration.GetSection("EmailSettings"));
builder.Services.AddTransient<EmailService>();public class OrdersController : ControllerBase
{
private readonly EmailService _email;
public OrdersController(EmailService email) => _email = email;
[HttpPost]
public async Task<IActionResult> Create(OrderRequest request, CancellationToken ct)
{
var order = await _orders.CreateAsync(request, ct);
await _email.SendAsync(
to: order.CustomerEmail,
subject: $"Your order #{order.Id} is confirmed",
html: $"<h1>Order confirmed</h1><p>Your order ships tomorrow.</p>",
text: "Your order ships tomorrow.",
ct);
return Ok(order);
}
}BodyBuilder handles attachments and inline images. A linked resource with a ContentId is what the HTML references as cid:logo:
var builder = new BodyBuilder
{
HtmlBody = "<p>The invoice for March is attached.</p>",
TextBody = "The invoice for March is attached.",
};
// From disk
await builder.Attachments.AddAsync("/srv/invoices/2026-03.pdf", ct);
// From bytes generated in memory
builder.Attachments.Add("invoice.pdf", pdfBytes, new ContentType("application", "pdf"));
// Inline, referenced from the HTML as <img src="cid:logo">
var logo = builder.LinkedResources.Add("logo.png", logoBytes);
logo.ContentId = "logo";
message.Body = builder.ToMessageBody();Awaiting SMTP inside a controller action ties your response time to the mail server. Hand the message to a hosted background service instead:
// Queue the send so the HTTP response does not wait for SMTP.
public class EmailQueue
{
private readonly Channel<EmailJob> _channel =
Channel.CreateUnbounded<EmailJob>();
public ValueTask EnqueueAsync(EmailJob job) => _channel.Writer.WriteAsync(job);
public IAsyncEnumerable<EmailJob> ReadAllAsync(CancellationToken ct) =>
_channel.Reader.ReadAllAsync(ct);
}
public class EmailBackgroundService : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
await foreach (var job in _queue.ReadAllAsync(ct))
{
try { await _email.SendAsync(job.To, job.Subject, job.Html, job.Text, ct); }
catch (Exception ex) { _logger.LogError(ex, "Email to {To} failed", job.To); }
}
}
}| Error | Cause and fix |
|---|---|
AuthenticationException (535) | Wrong token login or password, or the secret was never loaded into configuration. |
SocketException / timeout on connect | Outbound port blocked. Use 8587 (STARTTLS) or 8465 (implicit TLS). |
SslHandshakeException | SslOnConnect on port 587, or StartTls on 465. Match the option to the port. |
The remote certificate is invalid | Missing CA certificates in the container. Install ca-certificates — do not bypass validation. |
| Random failures under load | A shared SmtpClient instance. It is not thread-safe — create one per send. |
SmtpCommandException (550) | The sender domain is not verified, or the token does not cover it. |
No. Microsoft marks System.Net.Mail.SmtpClient as obsolete for new development — it does not support modern TLS negotiation properly and has no async story worth using. MailKit is the recommended replacement and is what the .NET documentation itself points to.
SecureSocketOptions.StartTls on port 587, and SecureSocketOptions.SslOnConnect on port 465. SecureSocketOptions.Auto works too but negotiates on every connection; being explicit makes a misconfiguration fail loudly instead of silently downgrading.
No. Returning true accepts any certificate, including one presented by an attacker intercepting the connection, and it masks the actual cause — normally an outdated CA bundle in the container image. Leave validation on and fix the trust store instead.
Most cloud hosts block outbound ports 25, 465 and 587. Change the port to 8587 for STARTTLS or 8465 for implicit TLS. Setting SmtpClient.Timeout means the failure surfaces quickly instead of holding the request thread.
Not safely — MailKit's SmtpClient is not thread-safe, so a shared instance corrupts concurrent sends. Create one per send as shown above, or pool them behind a service that hands out one client at a time.
Push the message onto a Channel and drain it from a BackgroundService, or use a durable queue such as Hangfire if the send must survive a restart. Awaiting SMTP inside a controller action ties the response time to your mail server's latency.