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

  1. Add and verify your sending domain. To test before DNS is set up, send from noreply@mail.groundmail.dev to your own account email.
  2. 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.
  3. Keep it out of source control: dotnet user-secrets in 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.

  1. 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.
  2. Swap the API key header for a handler that adds the token:
dotnet add package Azure.Identity
using 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#.