Send email from C# and .NET
Send email from any .NET app with Groundmail: over SMTP with MailKit, or over the REST API with HttpClient. There's no NuGet package to install for either. The examples target .NET 8 and later, and work the same in ASP.NET Core, worker services, Azure Functions and console apps.
| SMTP (MailKit) | REST API (HttpClient) |
|
|---|---|---|
| Best for | Existing code that already sends over SMTP | New code, retries, delivery status |
| Credential | SMTP credential (gmsmtp_…) |
API key (gm_…), or an Azure managed identity |
| Response | 250 from the server |
Message ID (msg_…) and status, structured errors |
| Idempotency | X-Groundmail-Idempotency-Key header |
Idempotency-Key header |
Both use the same verified domains, suppressions, limits and webhooks.
Before you start
- Add and verify your sending domain. To test before DNS is set up, send from
noreply@mail.groundmail.devto your own account email. - Create the credential you need at app.groundmail.dev: an SMTP credential under SMTP, or an API key under API keys. Use a Send only key for app code.
- Keep it out of source control:
dotnet user-secretsin development, environment variables or Azure Key Vault in production.
Send with SMTP and MailKit
MailKit is the SMTP library Microsoft's own SmtpClient docs point to for new code.
dotnet add package MailKit| Setting | Value |
|---|---|
| Host | smtp.groundmail.dev |
| Port | 587 |
| Security | STARTTLS (SecureSocketOptions.StartTls) |
| Username | groundmail |
| Password | Your SMTP credential (gmsmtp_…) |
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;
var message = new MimeMessage();
message.From.Add(new MailboxAddress("Acme", "billing@yourdomain.com"));
message.To.Add(MailboxAddress.Parse("user@example.com"));
message.Subject = "Your receipt";
// Optional: Groundmail features over SMTP.
message.Headers.Add("X-Groundmail-Idempotency-Key", "receipt-order-1042");
message.Headers.Add("X-Groundmail-Tag", "type=receipt");
var body = new BodyBuilder
{
TextBody = "Thanks for your order.",
HtmlBody = "<p>Thanks for your order.</p>",
};
body.Attachments.Add("invoice-1042.pdf");
message.Body = body.ToMessageBody();
using var smtp = new SmtpClient();
await smtp.ConnectAsync("smtp.groundmail.dev", 587, SecureSocketOptions.StartTls);
await smtp.AuthenticateAsync("groundmail", Environment.GetEnvironmentVariable("GROUNDMAIL_SMTP_PASSWORD")!);
await smtp.SendAsync(message);
await smtp.DisconnectAsync(true);SendAsync returns the server's reply (OK: message queued). The X-Groundmail-* headers map to API features: tags, idempotency (24 hours) and X-Groundmail-Scheduled-At for scheduled sends. See SMTP relay.
To send many messages, keep one connected SmtpClient and call SendAsync for each, rather than reconnecting per message. SmtpClient isn't thread-safe, so use one per worker.
MailKit errors
| Exception | Meaning | What to do |
|---|---|---|
AuthenticationException |
Wrong username or password, or a revoked credential | Username must be groundmail; check the gmsmtp_… value |
SmtpCommandException with StatusCode 451 |
Temporary: for example a new account's daily ramp-up limit | Retry later. The message says when (retry after <time>) |
SmtpCommandException with StatusCode 554 |
Permanent: for example From isn't on a verified domain, every recipient is suppressed, or the Free plan's daily limit |
Don't retry. Message has the reason |
SslHandshakeException: "incomplete certificate revocation check" |
The OS couldn't finish the certificate revocation check (seen on macOS) | Set smtp.CheckCertificateRevocation = false before ConnectAsync. The certificate itself is still validated |
The server checks the message after it's sent (ErrorCode is MessageNotAccepted), so the reason is in the exception's Message.
System.Net.Mail.SmtpClient
The built-in SmtpClient still works with Groundmail. Set EnableSsl = true, which uses STARTTLS on port 587:
using System.Net;
using System.Net.Mail;
using var client = new SmtpClient("smtp.groundmail.dev", 587)
{
EnableSsl = true,
Credentials = new NetworkCredential("groundmail", Environment.GetEnvironmentVariable("GROUNDMAIL_SMTP_PASSWORD")),
};
using var message = new MailMessage("billing@yourdomain.com", "user@example.com", "Your receipt", "<p>Thanks for your order.</p>")
{
IsBodyHtml = true,
};
await client.SendMailAsync(message);Microsoft marks SmtpClient as not recommended for new development: it doesn't support many modern protocols and isn't being improved. Keep it for existing code, and use MailKit or the REST API for anything new.
Send with the REST API and HttpClient
One POST https://api.groundmail.dev/emails per email, with the API key as a Bearer token:
using System.Net.Http.Headers;
using System.Net.Http.Json;
using var http = new HttpClient { BaseAddress = new Uri("https://api.groundmail.dev/") };
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("GROUNDMAIL_API_KEY"));
using var request = new HttpRequestMessage(HttpMethod.Post, "emails")
{
Content = JsonContent.Create(new
{
from = "Acme <billing@yourdomain.com>",
to = new[] { "user@example.com" },
subject = "Your receipt",
html = "<p>Thanks for your order.</p>",
text = "Thanks for your order.",
}),
};
request.Headers.Add("Idempotency-Key", "receipt-order-1042");
using var response = await http.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
// 200 {"id":"msg_…","status":"sent"}Request fields are snake_case: from, to, subject, html, text, reply_to, cc, bcc, tags, attachments, scheduled_at, template. The full list is in the Emails API and the OpenAPI spec.
Use an Idempotency-Key built from what the email is for (an order ID, a reset token ID). A retry with the same key and body returns the original response instead of sending twice, for 24 hours.
Attachments are base64:
attachments = new[]
{
new
{
name = "invoice-1042.pdf",
content_type = "application/pdf",
data = Convert.ToBase64String(await File.ReadAllBytesAsync("invoice-1042.pdf")),
},
},ASP.NET Core: a typed client with options
In an app, register a typed HttpClient once and inject it. This client sends with typed records, turns error responses into an exception you can branch on, and retries safely.
// Email/GroundmailClient.cs
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MyApp.Email;
public sealed class GroundmailOptions
{
public const string Section = "Groundmail";
public string ApiKey { get; set; } = "";
public Uri BaseAddress { get; set; } = new("https://api.groundmail.dev/");
}
public sealed record SendEmailRequest
{
public required string From { get; init; }
public required IReadOnlyList<string> To { get; init; }
public required string Subject { get; init; }
public string? Html { get; init; }
public string? Text { get; init; }
public string? ReplyTo { get; init; }
public IReadOnlyDictionary<string, string>? Tags { get; init; }
}
public sealed record SendEmailResponse(string Id, string Status, bool? Test);
public sealed class GroundmailException(
HttpStatusCode status, string? code, string message, string? hint, TimeSpan? retryAfter)
: Exception(message)
{
public HttpStatusCode Status { get; } = status;
public string? Code { get; } = code;
public string? Hint { get; } = hint;
public TimeSpan? RetryAfter { get; } = retryAfter;
/// 429 (except the daily ramp-up limit), 500, 502 and 503 are safe to retry
/// with the same Idempotency-Key. 504 means the email may already be on its way.
public bool IsRetryable => Status switch
{
HttpStatusCode.TooManyRequests => Code != "daily_ramp_limit",
HttpStatusCode.InternalServerError or HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable => true,
_ => false,
};
public static async Task<GroundmailException> FromResponseAsync(HttpResponseMessage response, CancellationToken ct)
{
string? code = null, hint = null;
var message = $"Groundmail returned {(int)response.StatusCode}";
try
{
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct));
var root = doc.RootElement;
// Errors look like {"error": {"code", "message", "hint", "docs_url"}}.
var error = root.TryGetProperty("error", out var e) && e.ValueKind == JsonValueKind.Object ? e : root;
code = error.TryGetProperty("code", out var c) ? c.GetString() : null;
hint = error.TryGetProperty("hint", out var h) ? h.GetString() : null;
if (error.TryGetProperty("message", out var m)) message = m.GetString() ?? message;
}
catch (JsonException)
{
// Not JSON (for example a proxy error page): keep the status line.
}
return new GroundmailException(response.StatusCode, code, message, hint, response.Headers.RetryAfter?.Delta);
}
}
public sealed class GroundmailClient(HttpClient http)
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, // ReplyTo -> reply_to
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public async Task<SendEmailResponse> SendAsync(
SendEmailRequest email, string idempotencyKey, CancellationToken ct = default)
{
using var request = new HttpRequestMessage(HttpMethod.Post, "emails")
{
Content = JsonContent.Create(email, options: Json),
};
request.Headers.Add("Idempotency-Key", idempotencyKey);
using var response = await http.SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
throw await GroundmailException.FromResponseAsync(response, ct);
return (await response.Content.ReadFromJsonAsync<SendEmailResponse>(Json, ct))!;
}
/// Retries rate limits, 5xx and network errors with the same idempotency key,
/// so a retry can never send the email twice.
public async Task<SendEmailResponse> SendWithRetryAsync(
SendEmailRequest email, string idempotencyKey, CancellationToken ct = default)
{
const int maxAttempts = 5;
for (var attempt = 1; ; attempt++)
{
try
{
return await SendAsync(email, idempotencyKey, ct);
}
catch (GroundmailException e) when (e.IsRetryable && attempt < maxAttempts)
{
await Task.Delay(e.RetryAfter ?? Backoff(attempt), ct);
}
catch (HttpRequestException) when (attempt < maxAttempts)
{
await Task.Delay(Backoff(attempt), ct);
}
catch (TaskCanceledException) when (!ct.IsCancellationRequested && attempt < maxAttempts)
{
// HttpClient.Timeout elapsed. The email may or may not have been accepted;
// retrying with the same key is safe either way.
await Task.Delay(Backoff(attempt), ct);
}
}
}
private static TimeSpan Backoff(int attempt) => TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)); // 1s, 2s, 4s, 8s
}Register it in Program.cs:
using System.Net.Http.Headers;
using Microsoft.Extensions.Options;
using MyApp.Email;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<GroundmailOptions>(builder.Configuration.GetSection(GroundmailOptions.Section));
builder.Services.AddHttpClient<GroundmailClient>((sp, http) =>
{
var options = sp.GetRequiredService<IOptions<GroundmailOptions>>().Value;
http.BaseAddress = options.BaseAddress;
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", options.ApiKey);
http.Timeout = TimeSpan.FromSeconds(30);
});Set the key with dotnet user-secrets set "Groundmail:ApiKey" "gm_…" in development and the Groundmail__ApiKey environment variable (or a Key Vault reference) in production.
Then inject GroundmailClient wherever you send:
using System.Net;
using MyApp.Email;
app.MapPost("/orders/{orderId}/receipt", async (string orderId, string email, GroundmailClient groundmail, CancellationToken ct) =>
{
try
{
var result = await groundmail.SendWithRetryAsync(new SendEmailRequest
{
From = "Acme <billing@yourdomain.com>",
To = [email],
Subject = $"Receipt for order {orderId}",
Html = $"<p>Thanks for order {orderId}.</p>",
Text = $"Thanks for order {orderId}.",
Tags = new Dictionary<string, string> { ["type"] = "receipt" },
}, idempotencyKey: $"receipt-{orderId}", ct);
return Results.Ok(new { result.Id, result.Status });
}
catch (GroundmailException e) when (e.Status == HttpStatusCode.UnprocessableEntity)
{
// Every recipient is suppressed or opted out. Don't retry; flag the address instead.
return Results.Problem(e.Message, statusCode: StatusCodes.Status422UnprocessableEntity);
}
catch (GroundmailException e) when (e.Status is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
// Wrong or revoked key, unverified From domain, missing scope: fix the config, don't retry.
app.Logger.LogError("Groundmail refused the send: {Code} {Message} {Hint}", e.Code, e.Message, e.Hint);
return Results.Problem("Email is misconfigured", statusCode: StatusCodes.Status500InternalServerError);
}
});In a real app, send from a background job (a hosted service or a queue consumer) rather than inside the request, so a slow retry doesn't hold up the page.
If you'd rather use Microsoft.Extensions.Http.Resilience (AddStandardResilienceHandler()) for retries, that works too: it resends the same request, Idempotency-Key header included. Don't stack it on top of SendWithRetryAsync.
Handle errors
Every error is JSON with a stable code, a message and a hint (Errors). Branch on the status and code:
| Status | Code | Meaning | What to do |
|---|---|---|---|
401 |
unauthorized |
Missing, wrong or revoked API key | Fix the key. Don't retry |
403 |
forbidden, insufficient_scope, domain_not_allowed |
From domain not verified, sandbox or plan restriction, or the key can't do this |
Read hint. Don't retry |
400 |
validation_error |
A field is missing or malformed | Fix the request. Don't retry |
422 |
validation_error |
Every recipient is suppressed (details.suppressed) or opted out |
Don't retry to the same recipients |
429 |
rate_limited |
Too many requests | Wait Retry-After seconds, retry with the same Idempotency-Key |
429 |
daily_ramp_limit |
A new account's daily ramp-up limit | Wait until details.resets_at (next 00:00 UTC) |
500, 502, 503 |
Not sent | Retry with backoff and the same Idempotency-Key |
|
504 |
upstream_error |
The email may still go out | Check GET /emails before resending. A retry with the same key returns the same 504, never a duplicate |
SendWithRetryAsync above implements the retry rows. Network errors and timeouts are retried too: with the same idempotency key, a retry of a request that actually went through returns the original response instead of sending again.
Test without sending
Test keys. Create a key with Test key ticked on the API keys page. It starts with gm_test_. A send with it runs every check a live send does (verified domain, suppressions, validation, idempotency) and fires the same webhooks, but delivers nothing and counts toward nothing. The response has "test": true. Put the test key in your development and CI configuration and the live key in production; the code doesn't change.
Simulator addresses give a specific outcome, with real webhooks, from a test key or a live key:
| Send to | Outcome |
|---|---|
delivered@simulator.groundmail.dev |
Delivered |
bounce@simulator.groundmail.dev |
Hard bounce |
complaint@simulator.groundmail.dev |
Delivered, then a spam complaint |
suppressed@simulator.groundmail.dev |
422, as if the recipient were suppressed |
Add a tag to tell tests apart: bounce+signup-42@simulator.groundmail.dev. SMTP credentials are always live, but simulator addresses work over SMTP too, so you can point a MailKit test at delivered@simulator.groundmail.dev.
An integration test that checks the 422 path:
var ex = await Assert.ThrowsAsync<GroundmailException>(() => groundmail.SendAsync(new SendEmailRequest
{
From = "billing@yourdomain.com",
To = ["suppressed@simulator.groundmail.dev"],
Subject = "Suppression test",
Text = "Simulated.",
}, $"test-{Guid.NewGuid()}"));
Assert.Equal(HttpStatusCode.UnprocessableEntity, ex.Status);More in Testing.
Azure: keyless auth with managed identity
On Azure (App Service, Container Apps, Functions, AKS, VMs) you can skip the API key: the app's managed identity gets a short-lived Microsoft Entra token for Groundmail, and sends it as the Bearer credential. Nothing to store in Key Vault or rotate.
- Add the Groundmail app to your Entra tenant once, and register the workload's managed identity under API keys → Azure managed identity. Steps: Authenticate from Azure with managed identity.
- Swap the API key header for a handler that adds the token:
dotnet add package Azure.Identityusing System.Net.Http.Headers;
using Azure.Core;
namespace MyApp.Email;
public sealed class EntraTokenHandler(TokenCredential credential) : DelegatingHandler
{
private static readonly TokenRequestContext Scope = new(["https://api.groundmail.dev/.default"]);
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
// DefaultAzureCredential caches the token and refreshes it before it expires.
AccessToken token = await credential.GetTokenAsync(Scope, cancellationToken);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
return await base.SendAsync(request, cancellationToken);
}
}using Azure.Core;
using Azure.Identity;
using MyApp.Email;
builder.Services.AddSingleton<TokenCredential>(new DefaultAzureCredential());
builder.Services.AddTransient<EntraTokenHandler>();
builder.Services.AddHttpClient<GroundmailClient>(http =>
{
http.BaseAddress = new Uri("https://api.groundmail.dev/");
http.Timeout = TimeSpan.FromSeconds(30);
})
.AddHttpMessageHandler<EntraTokenHandler>();GroundmailClient and everything that uses it stay the same. For a user-assigned identity, pass its client ID: new DefaultAzureCredential(new DefaultAzureCredentialOptions { ManagedIdentityClientId = "<client-id>" }). Locally, DefaultAzureCredential falls back to your Azure CLI or Visual Studio sign-in, or use a gm_test_ key in development.
Managed identity covers the REST API. The SMTP relay still needs an SMTP credential, because SMTP can't carry an Entra token.
Moving off Azure Communication Services Email? The ACS migration guide maps EmailClient calls, fields, domains and SMTP settings to Groundmail, with before-and-after C#.
Related
- SMTP relay: settings and custom headers
- Emails API: every send field
- Errors: codes and recovery
- Testing: test keys and simulator addresses
- Java and Spring Boot