How to Send Email in C# and .NET: HttpClient, SMTP, Retries and Idempotency
Sending email from C# and .NET comes down to two viable paths: an HTTP API call with HttpClient (the right default for transactional mail) or an SMTP client like MailKit when you already have a relay. This guide walks through both, then adds the parts most tutorials skip: retry policies, idempotency keys, and a test that proves your retries actually work.
Prerequisites
Before writing code, have these in place:
- .NET 8 SDK (or .NET 6/7 — the samples here target .NET 8 and use only APIs available since .NET 6).
- An API key from a transactional email provider. The examples use Mailerflow, but the pattern works with any REST email API.
- A verified sending domain. Most providers require you to verify your sending domain before you can send from it.
- A test recipient address you control.
Packages:
System.Net.HttpandSystem.Text.Json— built into the framework, no install needed.Microsoft.Extensions.Http— forIHttpClientFactory.Polly.Extensions.Http— for retry and timeout policies.MailKit— only if you go the SMTP route.
One thing to get out of the way early: Microsoft's documentation marks System.Net.Mail.SmtpClient as not recommended for new development. It still works, and it does expose async methods such as SendMailAsync, but its async support is limited and it lacks modern TLS defaults. Use MailKit or an HTTP API instead.
Two ways to send email from .NET
HTTP API. You POST JSON to an endpoint. No outbound SMTP ports to open, no relay to maintain. You get structured error responses, so you can distinguish a bad request from a rate limit. Idempotency keys are a header away. This is the better fit for transactional mail — password resets, receipts, notifications — where you care about delivery tracking and retry semantics.
SMTP. Useful when you already run an SMTP relay, need to talk to an internal mail server, or are maintaining a legacy code path. The trade-off is that SMTP gives you no built-in idempotency and no structured error codes — you get a status code and a text response, and you build the rest yourself.
Mailerflow's transactional email API runs on a high-priority lane with templates and variables, and exposes a REST API with scoped keys, idempotency keys, and an OpenAPI spec. That makes it a reasonable example for the API path, and the developer docs contain the current endpoint reference.
Sending email with HttpClient
Setup and authentication
Never put an API key in source. Read it from configuration or an environment variable:
export MAILERFLOW_API_KEY="mf_live_..."
Register a typed client so the underlying HttpMessageHandler is pooled. Creating a new HttpClient per send is a common cause of socket exhaustion under load — Microsoft's guidance on HttpClient lifetime covers this in detail.
// Program.cs
using System.Net.Http.Headers;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHttpClient<EmailService>(client =>
{
client.BaseAddress = new Uri("https://api.mailerflow.ai/");
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer",
Environment.GetEnvironmentVariable("MAILERFLOW_API_KEY"));
});
var host = builder.Build();
var emailService = host.Services.GetRequiredService<EmailService>();
var messageId = await emailService.SendAsync(
to: "customer@example.com",
templateId: "order-confirmation",
variables: new Dictionary<string, object>
{
["first_name"] = "Alex",
["order_id"] = "12345",
["total"] = "$84.00"
});
Console.WriteLine($"Sent: {messageId}");
The full request
// EmailService.cs
using System.Net.Http.Json;
using System.Text.Json.Serialization;
public sealed class EmailService(HttpClient http)
{
public async Task<string> SendAsync(
string to,
string templateId,
Dictionary<string, object> variables,
string? idempotencyKey = null,
CancellationToken ct = default)
{
var payload = new SendRequest
{
To = to,
TemplateId = templateId,
Variables = variables
};
using var request = new HttpRequestMessage(HttpMethod.Post, "v1/transactional/send")
{
Content = JsonContent.Create(payload)
};
if (idempotencyKey is not null)
{
request.Headers.Add("Idempotency-Key", idempotencyKey);
}
using var response = await http.SendAsync(request, ct);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<SendResponse>(ct);
return result?.MessageId ?? throw new InvalidOperationException("No message id returned");
}
}
public sealed class SendRequest
{
[JsonPropertyName("to")] public required string To { get; init; }
[JsonPropertyName("template_id")] public required string TemplateId { get; init; }
[JsonPropertyName("variables")] public required Dictionary<string, object> Variables { get; init; }
}
public sealed class SendResponse
{
[JsonPropertyName("message_id")] public string? MessageId { get; init; }
}
The endpoint path v1/transactional/send and the field names above are placeholders for illustration — the exact path and field names must come from the current Mailerflow OpenAPI spec, since API surfaces change. The request shape (typed client, JSON body, bearer token, idempotency header) is what carries over between providers.
If you are templating heavily, the same request shape applies whether you send one email or a batch; sending templated emails with variables via API covers the payload design in more depth.
Sending email over SMTP
If you need SMTP, MailKit is the maintained choice:
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;
public static async Task SendViaSmtpAsync(CancellationToken ct = default)
{
var message = new MimeMessage();
message.From.Add(MailboxAddress.Parse("orders@example.com"));
message.To.Add(MailboxAddress.Parse("customer@example.com"));
message.Subject = "Your order 12345";
message.Body = new BodyBuilder
{
TextBody = "Your order 12345 has shipped.",
HtmlBody = "<p>Your order <strong>12345</strong> has shipped.</p>"
}.ToMessageBody();
using var client = new SmtpClient();
await client.ConnectAsync("smtp.example.com", 587, SecureSocketOptions.StartTls, ct);
await client.AuthenticateAsync("username", "password", ct);
await client.SendAsync(message, ct);
await client.DisconnectAsync(true, ct);
}
SMTP has no idempotency. If a send times out and you retry, you may deliver twice. Either add your own deduplication header and track it in a database, or move transactional mail to an HTTP API where idempotency keys are supported natively.
Handling errors, retries and idempotency
Which status codes to retry
| Retry | Do not retry |
|---|---|
| 408 Request Timeout | 400 Bad Request |
| 429 Too Many Requests | 401 Unauthorized |
| 500 Internal Server Error | 403 Forbidden |
| 502 Bad Gateway | 404 Not Found |
| 503 Service Unavailable | 422 Unprocessable Entity |
| 504 Gateway Timeout |
The right column is almost always a bug or a configuration problem. Retrying a 401 because your key was rotated wastes time and can trip rate limits.
A Polly retry policy
using Polly;
using Polly.Extensions.Http;
builder.Services
.AddHttpClient<EmailService>(client =>
{
client.BaseAddress = new Uri("https://api.mailerflow.ai/");
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("MAILERFLOW_API_KEY"));
})
.AddPolicyHandler(HttpPolicyExtensions
.HandleTransientHttpError()
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: attempt =>
TimeSpan.FromSeconds(Math.Pow(2, attempt))
+ TimeSpan.FromMilliseconds(Random.Shared.Next(0, 250))))
.AddPolicyHandler(Policy.TimeoutAsync<HttpResponseMessage>(TimeSpan.FromSeconds(10)));
Exponential backoff with jitter helps avoid the thundering-herd problem where every client retries at the same instant after an outage.
Idempotency keys
A retry should not create a second email. Derive a stable key from the business event, not from a timestamp or GUID:
using System.Security.Cryptography;
using System.Text;
public static string BuildIdempotencyKey(string eventType, string businessId)
{
var raw = $"{eventType}:{businessId}";
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw));
return Convert.ToHexString(hash).ToLowerInvariant();
}
// Same order, same key, every retry:
var key = BuildIdempotencyKey("order-confirmation", "12345");
The provider stores the key and returns the original result if you send the same key again. Mailerflow's REST API supports idempotency keys, which means a retried request after a timeout resolves to the same message rather than a duplicate.
A 202 response is a success status — it means the provider accepted the message, not that the recipient received it. EnsureSuccessStatusCode() and IsSuccessStatusCode both treat 202 as success, which is correct for the send call, but it does not confirm delivery. To confirm final delivery, subscribe to webhooks. Mailerflow's webhooks are signed, retried, and backed by delivery logs, so you can reconcile bounces and complaints against your own records.
Testing and going to production
Unit-testing the request without sending
Stub the handler so tests never touch the network:
using System.Net;
using System.Net.Http;
using System.Text;
using Xunit;
public sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> responder)
: HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken ct)
=> Task.FromResult(responder(request));
}
public class EmailServiceTests
{
[Fact]
public async Task Sends_idempotency_header_and_template()
{
HttpRequestMessage? captured = null;
var handler = new StubHandler(req =>
{
captured = req;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"message_id\":\"msg_1\"}",
Encoding.UTF8, "application/json")
};
});
var service = new EmailService(new HttpClient(handler)
{
BaseAddress = new Uri("https://api.mailerflow.ai/")
});
var id = await service.SendAsync(
"customer@example.com", "order-confirmation",
new() { ["order_id"] = "12345" },
idempotencyKey: "abc123");
Assert.Equal("msg_1", id);
Assert.Equal("abc123", captured!.Headers.GetValues("Idempotency-Key").Single());
}
}
Proving the retry works
Return a 503 on the first call and a 200 on the second, then assert the service succeeded and the handler was hit twice. If you wire the Polly policy through IHttpClientFactory in the test, this is a genuine integration check of your retry configuration, not just a unit test of the service.
Production checklist
- Authenticate your domain. Set up DKIM, SPF, and DMARC. Mailerflow provides guided setup with automatic verification, which removes most of the guesswork.
- Keep the API key in a secret store (Azure Key Vault, AWS Secrets Manager, or environment variables injected at deploy time).
- Log the provider message ID alongside your own correlation ID so support can trace a specific send.
- Subscribe to webhooks for bounces and complaints. Mailerflow automatically suppresses bounces and complaints and monitors reputation, so you do not have to build suppression logic yourself.
- Use scoped keys. A key that can only send transactional mail is a smaller blast radius if it leaks.
Complete example: order confirmation email
using System.Net.Http.Json;
using System.Text.Json.Serialization;
public sealed record Order(string Id, string CustomerEmail, string FirstName, decimal Total);
public sealed record SendResult(bool Success, string? MessageId, string? Error);
public sealed class OrderEmailService(HttpClient http)
{
// Adapt: template id, endpoint path, and API key source.
private const string TemplateId = "order-confirmation";
public async Task<SendResult> SendOrderConfirmationAsync(
Order order, CancellationToken ct = default)
{
var key = BuildIdempotencyKey("order-confirmation", order.Id);
var payload = new SendRequest
{
To = order.CustomerEmail,
TemplateId = TemplateId,
Variables = new Dictionary<string, object>
{
["first_name"] = order.FirstName,
["order_id"] = order.Id,
["total"] = order.Total.ToString("C")
}
};
using var request = new HttpRequestMessage(HttpMethod.Post, "v1/transactional/send")
{
Content = JsonContent.Create(payload)
};
request.Headers.Add("Idempotency-Key", key);
try
{
using var response = await http.SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(ct);
return new SendResult(false, null, $"{(int)response.StatusCode}: {body}");
}
var result = await response.Content.ReadFromJsonAsync<SendResponse>(ct);
return new SendResult(true, result?.MessageId, null);
}
catch (HttpRequestException ex)
{
return new SendResult(false, null, ex.Message);
}
}
private static string BuildIdempotencyKey(string eventType, string businessId)
{
var raw = $"{eventType}:{businessId}";
var hash = System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(raw));
return Convert.ToHexString(hash).ToLowerInvariant();
}
}
Register it with the same retry policy from earlier, using the same AddHttpClient<OrderEmailService> pattern:
builder.Services
.AddHttpClient<OrderEmailService>(client =>
{
client.BaseAddress = new Uri("https://api.mailerflow.ai/");
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("MAILERFLOW_API_KEY"));
})
.AddPolicyHandler(HttpPolicyExtensions
.HandleTransientHttpError()
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: attempt =>
TimeSpan.FromSeconds(Math.Pow(2, attempt))
+ TimeSpan.FromMilliseconds(Random.Shared.Next(0, 250))))
.AddPolicyHandler(Policy.TimeoutAsync<HttpResponseMessage>(TimeSpan.FromSeconds(10)));
Inject OrderEmailService wherever you process orders, and you have a transactional send path that survives timeouts without duplicating mail.
Troubleshooting
401 after rotating a key. The old key is still in configuration, or the new key lacks the scope your request needs. Check the key's permissions, not just its validity.
403 on send. The sending domain is not verified. Complete DKIM/SPF/DMARC setup and wait for verification to propagate.
422 on template variables. A variable name in your payload does not match the template. Compare the dictionary keys against the template definition — a typo like firstName vs first_name is the usual culprit.
Mail lands in spam. Check SPF, DKIM, and DMARC alignment for your sending domain, and review link reputation in the message body. Authentication alignment is the first thing to verify.
Duplicate sends after a timeout. No idempotency key, or a key derived from something that changes between attempts (a GUID or timestamp). Derive it from the business event.
SMTP times out on port 25. Many hosting providers block outbound port 25. Use 587 with STARTTLS or 465 with implicit TLS.
Socket exhaustion under load. You are creating a new HttpClient per send. Use IHttpClientFactory and a typed client.
FAQ
Can I send email in C# without SMTP? Yes — an HTTP API call with HttpClient is the common approach for transactional mail. You avoid outbound SMTP ports entirely and get structured responses you can act on.
Is System.Net.Mail.SmtpClient deprecated? Microsoft marks it as not recommended for new development. It still functions, but MailKit or an HTTP API is the better choice for anything new.
How do I send an HTML email in C#? With MailKit, set BodyBuilder.HtmlBody. With an HTTP API, put the HTML in the template or in an html field in the payload.
How do I attach a file? With MailKit, use BodyBuilder.Attachments.Add(path). With an HTTP API, check the provider's spec for attachment support and size limits — many prefer a hosted URL over inline base64.
How do I avoid sending the same email twice on retry? Send an idempotency key derived from the business event (order ID, user ID, event type) and reuse the same key on every retry. The provider deduplicates.
How do I send email from an ASP.NET Core background service? Inject your email service into the BackgroundService and pass the stopping token through to SendAsync. Register the typed client with IHttpClientFactory so the handler is pooled across the service's lifetime.
Related reading
- How to Send Email From Next.js (Route Handlers and Server Actions) — the same request shape and idempotency approach, applied to a Node backend.
- Sending Templated Emails With Variables via API — payload design for template-driven sends.
Ready to wire this up? Send your first transactional email with the Mailerflow REST API — scoped keys, idempotency and an OpenAPI spec. Start free.


