Developer Tutorial

How to Send Email with C# and .NET: SmtpClient, MailKit, and the API Approach

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.

9 min read·Updated March 2026

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.

1. SmtpClient vs MailKit vs REST API

Three options with very different tradeoffs — and one you should stop using today.

System.Net.Mail.SmtpClient (deprecated)

Ships with .NET but marked obsolete since .NET 5. It doesn't support modern TLS protocols correctly and lacks async-first design.

+ Zero dependencies — already in the framework
– Officially deprecated by Microsoft
– Poor SSL/TLS support — broken on many SMTP hosts
– Not designed for async — blocks threads or requires wrappers

MailKit (modern SMTP)

The community-recommended replacement. Full async support, correct TLS handling, MIME builder, OAuth2. If you need SMTP, use MailKit.

+ Fully async (async/await native)
+ Correct TLS — works with all SMTP providers
+ MIME builder for HTML, attachments, inline images
– Still SMTP — no delivery events or webhooks
– Adds a NuGet dependency

REST API via HttpClient (recommended for apps)

POST JSON to an email API using .NET's HttpClient. Clean, async, no SMTP credentials to manage, and you get delivery webhooks.

+ Webhooks for bounces, complaints, opens, clicks
+ HttpClient is the .NET standard — no extra dependency
+ Deliverability managed by provider
+ Works perfectly with IHttpClientFactory and DI

2. System.Net.Mail.SmtpClient — what it looks like and why to move on

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.

3. MailKit — the modern SMTP approach

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")).

4. Sending email via REST API with HttpClient

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);

5. HTML templates with Razor

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.

6. Error handling and retries with Polly

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.

7. Deliverability checklist before you launch

SPF, DKIM, and DMARC are configured
Without these DNS records, Gmail and Outlook will reject or spam-folder your email. See the complete DKIM/SPF/DMARC guide.
API key and SMTP password are in environment variables or Azure Key Vault
Never hardcode credentials in appsettings.json that gets committed. Use dotnet user-secrets for local development.
Always include a plain-text body alongside HTML
MailKit's BodyBuilder handles both. HTML-only email scores worse in spam filters and can't be read in text-only clients.
Don't await email sends in web request handlers directly
Use a background service (IHostedService), a message queue (Azure Service Bus, RabbitMQ), or at minimum Task.Run so the HTTP response isn't held up by email delivery.
Bounce webhook handler is live before you send at volume
Hard bounces must be suppressed immediately. A bounce rate above 2% will get your sending account suspended by most providers.

MailKit vs REST API for .NET apps

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.

Send your first email from C# in 2 minutes

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 →