Developer Tutorial
Covers .NET's SmtpClient (and why to stop using it), MailKit as the modern replacement, and a clean HttpClient REST API call for production apps that need delivery webhooks.
Sending email in C# is deceptively straightforward to start — System.Net.Mail.SmtpClient ships with .NET and works for basic cases. The problem is that Microsoft deprecated it in .NET 5+ due to SSL handling issues. This guide covers what to use instead: MailKit as the modern SMTP option, and a direct REST API call via HttpClient for production apps where you need delivery tracking.
Three options with very different tradeoffs — and one you should stop using today.
Ships with .NET but marked obsolete since .NET 5. It doesn't support modern TLS protocols correctly and lacks async-first design.
The community-recommended replacement. Full async support, correct TLS handling, MIME builder, OAuth2. If you need SMTP, use MailKit.
POST JSON to an email API using .NET's HttpClient. Clean, async, no SMTP credentials to manage, and you get delivery webhooks.
You'll encounter this pattern in older .NET codebases. It works, but Microsoft recommends migrating to MailKit:
// ⚠️ Deprecated — shown for migration context only
using System.Net;
using System.Net.Mail;
var client = new SmtpClient(Environment.GetEnvironmentVariable("SMTP_HOST"))
{
Port = 587,
Credentials = new NetworkCredential(
Environment.GetEnvironmentVariable("SMTP_USER"),
Environment.GetEnvironmentVariable("SMTP_PASSWORD")
),
EnableSsl = true
};
var message = new MailMessage
{
From = new MailAddress(Environment.GetEnvironmentVariable("EMAIL_FROM")!),
Subject = "Your order shipped",
Body = "<p>Your order has shipped.</p>",
IsBodyHtml = true
};
message.To.Add("[email protected]");
await client.SendMailAsync(message); // don't reuse this client — it's not thread-safe The main issue isn't the API shape — it's the underlying TLS implementation. SmtpClient uses a custom TLS stack instead of the system TLS, which causes authentication failures on some SMTP hosts and doesn't support modern cipher suites. MailKit uses MimeKit's TLS implementation which handles STARTTLS and SSL/TLS correctly on all .NET platforms.
Install via NuGet, then send HTML email with full async support:
dotnet add package MailKit
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;
public class EmailService
{
private readonly string _host = Environment.GetEnvironmentVariable("SMTP_HOST")!;
private readonly int _port = 587;
private readonly string _user = Environment.GetEnvironmentVariable("SMTP_USER")!;
private readonly string _pass = Environment.GetEnvironmentVariable("SMTP_PASSWORD")!;
private readonly string _from = Environment.GetEnvironmentVariable("EMAIL_FROM")!;
public async Task SendAsync(string to, string subject, string htmlBody, string textBody)
{
var message = new MimeMessage();
message.From.Add(MailboxAddress.Parse(_from));
message.To.Add(MailboxAddress.Parse(to));
message.Subject = subject;
var builder = new BodyBuilder
{
TextBody = textBody,
HtmlBody = htmlBody
};
message.Body = builder.ToMessageBody();
using var client = new SmtpClient();
await client.ConnectAsync(_host, _port, SecureSocketOptions.StartTls);
await client.AuthenticateAsync(_user, _pass);
await client.SendAsync(message);
await client.DisconnectAsync(true);
}
}SecureSocketOptions.StartTls upgrades the connection after connecting — correct for port 587. Use SecureSocketOptions.SslOnConnect for port 465. Adding attachments: builder.Attachments.Add("invoice.pdf", fileBytes, ContentType.Parse("application/pdf")).
Register a typed HTTP client in Program.cs — this is the .NET idiomatic way to manage HttpClient lifetime:
// Program.cs
builder.Services.AddHttpClient<IEmailService, EmailService>(client =>
{
client.BaseAddress = new Uri("https://api.tinysend.co/v1/");
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("TINYSEND_API_KEY"));
client.Timeout = TimeSpan.FromSeconds(10);
});using System.Net.Http.Json;
public interface IEmailService
{
Task<string> SendAsync(SendEmailRequest request);
}
public record SendEmailRequest(
string From,
string To,
string Subject,
string Html,
string Text
);
public class EmailService(HttpClient httpClient) : IEmailService
{
private readonly string _from = Environment.GetEnvironmentVariable("EMAIL_FROM")!;
public async Task<string> SendAsync(SendEmailRequest request)
{
var payload = request with { From = _from };
var response = await httpClient.PostAsJsonAsync("emails", payload);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
return result.GetProperty("id").GetString()!;
}
}
// Usage in a controller:
// var id = await _emailService.SendAsync(new SendEmailRequest(
// From: "", // set by service
// To: user.Email,
// Subject: $"Welcome, {user.FirstName}!",
// Html: $"<p>Hi {user.FirstName}, <a href='https://app.example.com'>sign in</a></p>",
// Text: $"Hi {user.FirstName}, sign in at https://app.example.com"
// ));For idempotent emails (password resets, payment receipts), pass an idempotency key:
var request = new HttpRequestMessage(HttpMethod.Post, "emails")
{
Content = JsonContent.Create(payload)
};
request.Headers.Add("Idempotency-Key", $"password-reset-{userId}-{token}");
var response = await httpClient.SendAsync(request); In ASP.NET Core, render Razor views to strings using IRazorViewEngine:
<!-- Views/Emails/Welcome.cshtml --> @model WelcomeEmailModel <!DOCTYPE html> <html> <body> <h1>Welcome, @Model.FirstName!</h1> <p><a href="@Model.LoginUrl">Sign in to your account</a></p> </body> </html>
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
public class RazorEmailRenderer(
ICompositeViewEngine viewEngine,
ITempDataProvider tempDataProvider,
IServiceProvider serviceProvider)
{
public async Task<string> RenderAsync<TModel>(string viewName, TModel model)
{
var httpContext = new DefaultHttpContext { RequestServices = serviceProvider };
var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor());
using var sw = new StringWriter();
var viewResult = viewEngine.FindView(actionContext, viewName, false);
if (!viewResult.Success)
throw new InvalidOperationException($"Email view '{viewName}' not found");
var viewDictionary = new ViewDataDictionary<TModel>(
new EmptyModelMetadataProvider(), new ModelStateDictionary())
{
Model = model
};
var viewContext = new ViewContext(
actionContext, viewResult.View!, viewDictionary,
new TempDataDictionary(actionContext.HttpContext, tempDataProvider),
sw, new HtmlHelperOptions()
);
await viewResult.View!.RenderAsync(viewContext);
return sw.ToString();
}
} Wire it up in your email service: var html = await _renderer.RenderAsync("Emails/Welcome", new WelcomeEmailModel { FirstName = user.FirstName, LoginUrl = "..." }). For simpler use cases without the full Razor pipeline overhead, a plain interpolated string with inline styles works fine for transactional email.
Add Polly resilience policies when registering your HTTP client — this gives you automatic retries with exponential backoff for transient failures:
dotnet add package Microsoft.Extensions.Http.Polly
using Polly;
using Polly.Extensions.Http;
// Program.cs
builder.Services.AddHttpClient<IEmailService, EmailService>(client =>
{
client.BaseAddress = new Uri("https://api.tinysend.co/v1/");
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("TINYSEND_API_KEY"));
})
.AddPolicyHandler(GetRetryPolicy())
.AddPolicyHandler(GetCircuitBreakerPolicy());
static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy() =>
HttpPolicyExtensions
.HandleTransientHttpError() // 5xx and network errors
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
onRetry: (outcome, timespan, retryAttempt, context) =>
{
// Log retry attempt here
});
static IAsyncPolicy<HttpResponseMessage> GetCircuitBreakerPolicy() =>
HttpPolicyExtensions
.HandleTransientHttpError()
.CircuitBreakerAsync(5, TimeSpan.FromSeconds(30));HandleTransientHttpError() handles 408 (timeout), 5xx errors, and network exceptions — but not 4xx validation errors, which you shouldn't retry. The circuit breaker opens after 5 consecutive failures and stops sending for 30 seconds, preventing a slow email API from cascading into a full app outage.
appsettings.json that gets committed. Use dotnet user-secrets for local development.BodyBuilder handles both. HTML-only email scores worse in spam filters and can't be read in text-only clients.IHostedService), a message queue (Azure Service Bus, RabbitMQ), or at minimum Task.Run so the HTTP response isn't held up by email delivery.MailKit is the right choice if you genuinely need SMTP — for example, sending from an on-premise mail server, or building a mail client. For transactional email in a web app (welcome emails, password resets, receipts), a REST API is simpler to operate: no SMTP credentials to rotate, and you get delivery webhooks natively. See the SMTP vs email API comparison for a full breakdown.
tinysend gives you a clean REST API, templates with variables, bounce handling, and BYOK — so you can use your existing SES or Postmark account as the backend. Works with ASP.NET Core, Azure Functions, console apps, and Worker Services.
Get your API key →