Send Transactional Email with .NET 8 + MailAnvil — Minimal API Tutorial
.NET shops don't need an SMTP library to send transactional email. MailAnvil is a plain HTTPS API, which means you can send from any .NET app with the HttpClient already in the base class library — no MailKit, no SmtpClient, no NuGet package at all.
This tutorial builds a .NET 8 minimal API endpoint that sends a welcome email when a user signs up.
What you'll build
A POST /signup endpoint that:
- Accepts an email address
- Calls MailAnvil's
POST /v1/sendin the background - Returns immediately — the user never waits on email delivery
Prerequisites
- .NET 8 SDK (
dotnet --version→8.x) - A MailAnvil API key (starts with
re_) and a verified sending domain
Step 1 — Scaffold the project
dotnet new web -n AcmeApi
cd AcmeApi
This gives you a minimal API project with Program.cs — no controllers, no boilerplate.
Step 2 — A typed MailAnvil client
Create MailAnvilClient.cs. It wraps one endpoint in a strongly-typed method so the rest of your app never touches raw JSON:
using System.Text;
using System.Text.Json;
public sealed class MailAnvilClient
{
private readonly HttpClient _http;
private readonly string _apiKey;
public MailAnvilClient(HttpClient http, IConfiguration config)
{
_http = http;
_apiKey = config["MailAnvil:ApiKey"]
?? throw new InvalidOperationException("MailAnvil:ApiKey not set");
}
public async Task SendAsync(
string to,
string subject,
string text,
string html,
string idempotencyKey,
CancellationToken ct = default)
{
var payload = new
{
from = "[email protected]",
to = new[] { to },
subject,
text,
html
};
using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.mailanvil.com/v1/send");
req.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _apiKey);
req.Headers.TryAddWithoutValidation("Idempotency-Key", idempotencyKey);
req.Content = new StringContent(
JsonSerializer.Serialize(payload),
Encoding.UTF8,
"application/json");
using var resp = await _http.SendAsync(req, ct);
if (!resp.IsSuccessStatusCode)
{
var body = await resp.Content.ReadAsStringAsync(ct);
throw new HttpRequestException(
$"MailAnvil returned {(int)resp.StatusCode}: {body}");
}
}
}
Three details worth noticing:
Idempotency-Key— a unique value per send. If the network drops after MailAnvil accepts the request but before you read the response, your retry reuses the same key and MailAnvil deduplicates instead of double-sending.ThrowIfNotSuccessdone manually — we read the response body so a failed send surfaces the actual MailAnvil error (domain_not_verified,invalid_api_key, etc.), not a generic 400.frommust be a verified domain — MailAnvil refuses to send from unverified domains to protect your sender reputation.
Step 3 — Register it and add the endpoint
Replace Program.cs:
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient<MailAnvilClient>(c =>
{
c.Timeout = TimeSpan.FromSeconds(15);
});
var app = builder.Build();
app.MapPost("/signup", async (SignupRequest body, MailAnvilClient mail) =>
{
// Fire-and-forget: don't block the signup on email latency.
_ = Task.Run(async () =>
{
try
{
await mail.SendAsync(
to: body.Email,
subject: "Welcome to Acme!",
text: "Thanks for signing up.",
html: "<h1>Welcome to Acme</h1><p>Thanks for signing up.</p>",
idempotencyKey: $"signup-{body.SignupId}");
}
catch (Exception ex)
{
// Log to your sink of choice. The user already got their 202.
Console.Error.WriteLine($"Email send failed: {ex.Message}");
}
});
return Results.Accepted();
});
app.Run();
public record SignupRequest(string Email, string SignupId);
The key decision here: 202 Accepted, not a blocking send. Signup latency should be measured in milliseconds, and email delivery is asynchronous by nature. If the send fails, you log it and retry — the user isn't left staring at a spinner.
Step 4 — Configure the key
Add to appsettings.Development.json (never commit a real key):
{
"Logging": { "LogLevel": { "Default": "Information" } },
"MailAnvil": {
"ApiKey": "re_key_xxxxxxxx"
}
}
In production, inject it through the environment instead:
export MailAnvil__ApiKey="re_key_xxxxxxxx"
.NET's configuration system maps the MailAnvil__ApiKey environment variable to config["MailAnvil:ApiKey"] automatically.
Test it
dotnet run
In another terminal:
curl -X POST http://localhost:5000/signup \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","signupId":"sig_12345"}'
You should get a 202 immediately, and the welcome email lands within seconds.
Why this matters for Indonesian .NET teams
Enterprise and government stacks in Indonesia run heavily on .NET, and those teams are often told they need an SMTP relay with its own client library. That's a legacy reflex. A modern transactional email API over HTTPS means:
- No SMTP ports to open or keep alive — works from serverless and containers without outbound-port headaches
- Billing in IDR with QRIS/GoPay, no USD card requirement
- Idempotency built in — a header, not a hand-rolled dedup table
Swap the base URL and API key shape and the exact same HttpClient pattern works against any provider. But the idempotency and the IDR billing are the parts that make MailAnvil a natural fit for local .NET shops.
Full picture
| Piece | What it does |
|---|---|
MailAnvilClient |
One typed method over POST /v1/send |
Idempotency-Key |
Safe retries without duplicate sends |
AddHttpClient |
Connection pooling + timeout, free from the framework |
202 Accepted |
Signup never blocks on email |