Email testing with C# and .NET
HttpClient plus System.Net.Http.Json — no package to install. Fits xUnit, NUnit and SpecFlow suites driving Selenium or Playwright.
Before you begin
- A MailFlat accountFree to start, no card. Every plan can open inboxes from the API.
- An account API keyAgents → API keys in the dashboard. It looks like mf_live_… and goes in the X-API-Key header. Keep it in an environment variable, never in the repo. See API keys and authentication.
- .NET 8+That is the whole toolchain requirement.
The client
There is no C# / .NET package yet, and you do not need one: the API is six endpoints. Paste this helper into your test support directory and every example below works.
// Nothing to install: HttpClient and System.Net.Http.Json ship with .NET.
// Save this as Tests/MailFlat.cs.
using System.Net.Http.Json;
using System.Text.Json;
public static class MailFlat
{
private static readonly HttpClient Http = CreateClient();
private static HttpClient CreateClient()
{
var http = new HttpClient { BaseAddress = new Uri("https://mailflat.net/api/v1/") };
http.DefaultRequestHeaders.Add(
"X-API-Key", Environment.GetEnvironmentVariable("MAILFLAT_API_KEY"));
return http;
}
public static async Task<JsonElement> CallAsync(
HttpMethod method, string path, object? body = null)
{
using var req = new HttpRequestMessage(method, path);
if (body is not null) req.Content = JsonContent.Create(body);
using var res = await Http.SendAsync(req);
return await res.Content.ReadFromJsonAsync<JsonElement>();
}
}
Your first inbox and one-time code
The whole loop: open an address, let your app mail it, read the code back, clean up.
// 1. a real, deliverable address
var created = await MailFlat.CallAsync(HttpMethod.Post, "inboxes",
new { prefix = "signup", retention_hours = 2 });
string address = created.GetProperty("address").GetString()!; // signup@a7f2c.mailflat.net
// 2. your app sends the code to that address
await app.RegisterAsync(address);
// 3. read it back, no mocking anywhere
string otp = await MailFlat.WaitForOtpAsync(address);
// 4. done with it
await MailFlat.CallAsync(HttpMethod.Delete, $"inboxes/{address}");
Line by linewhat each step of this C# / .NET example does
- create(...)
- Returns immediately with a real, deliverable address. Nothing is queued or simulated.
- retention_hours
- Optional. Messages purge themselves after it, so a skipped teardown never leaks.
- wait_for_otp
- Polls for you and fails loudly on timeout, instead of returning nothing three lines before the real error.
- delete()
- Optional but tidy. Retention would clean up anyway.
The address is real
Mail actually travels: SMTP, DKIM, the lot. Nothing is stubbed, so a broken template or a misconfigured sender fails here exactly like it would in production.
Waiting for the code
We extract the one-time code server-side and hand it to you as a field, so you never write a regex against an email body.
// Poll /latest until the server has extracted a code, then fail loudly.
public static async Task<string> WaitForOtpAsync(string address, int timeoutSeconds = 60)
{
var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
while (DateTime.UtcNow < deadline)
{
var res = await CallAsync(HttpMethod.Get, $"inboxes/{address}/latest");
if (res.TryGetProperty("encrypted", out var enc) && enc.GetBoolean())
throw new InvalidOperationException(res.GetProperty("note").GetString());
if (res.TryGetProperty("email", out var email)
&& email.ValueKind == JsonValueKind.Object
&& email.TryGetProperty("otp_code", out var otp)
&& otp.ValueKind == JsonValueKind.String)
{
return otp.GetString()!;
}
await Task.Delay(3000);
}
throw new TimeoutException($"no OTP arrived for {address} within {timeoutSeconds}s");
}
Always set a timeout
A poll loop without a deadline turns a missing email into a hung job. Fail loudly instead: the error message should name the address you were waiting on.
Reading every message
var res = await MailFlat.CallAsync(HttpMethod.Get, $"inboxes/{address}/messages");
foreach (var msg in res.GetProperty("emails").EnumerateArray()) // newest first
{
Console.WriteLine(msg.GetProperty("subject").GetString());
Console.WriteLine(msg.GetProperty("sender").GetString());
Console.WriteLine(msg.GetProperty("body_text").GetString()); // plain text body
Console.WriteLine(msg.GetProperty("body_html").GetString()); // HTML body
}
| Field | Type | What it is |
|---|---|---|
| subject | string | Subject line |
| sender | string | From address |
| body_text | string | Plain text body |
| body_html | string | HTML body |
| otp_code | string | null | One-time code, extracted by us |
| to_address | string | The exact address it was sent to, tag included |
| tag | string | null | Plus-addressing tag, if the sender used one |
| received_at | ISO 8601 | When it landed |
| is_encrypted | boolean | True on end-to-end encrypted inboxes, where body and code are unavailable |
Sending mail from the inbox
Useful in reverse: point your own inbound pipeline at a MailFlat address and check that it handles what arrives.
// mail leaves from the inbox address, DKIM-signed by our own MTA
await MailFlat.CallAsync(HttpMethod.Post, $"inboxes/{address}/send", new
{
to = "someone@example.com",
subject = "Welcome",
body = "Plain text body",
html = "<p>Optional HTML body</p>",
});
Cleaning up
await MailFlat.CallAsync(HttpMethod.Delete, $"inboxes/{address}");
// or leave it: retention_hours on create means it cleans itself up
await MailFlat.CallAsync(HttpMethod.Post, "inboxes",
new { prefix = "ci", retention_hours = 2 });
Two safety nets, use both
Delete in teardown so the list stays readable, and set retention_hours so a crashed run still cleans itself up.
In a test suite
Works with xUnit, NUnit, SpecFlow, Playwright .NET and anything else that gives you a setup and teardown hook.
// SignupTests.cs — a fresh inbox per test, torn down even on failure
public class SignupTests : IAsyncLifetime
{
private string _address = "";
public async Task InitializeAsync()
{
var created = await MailFlat.CallAsync(HttpMethod.Post, "inboxes", new
{
prefix = $"ci-{Guid.NewGuid():N}"[..11],
retention_hours = 2,
});
_address = created.GetProperty("address").GetString()!;
}
public async Task DisposeAsync() =>
await MailFlat.CallAsync(HttpMethod.Delete, $"inboxes/{_address}");
[Fact]
public async Task UserCanSignUpWithARealCode()
{
await app.RegisterAsync(_address);
string otp = await MailFlat.WaitForOtpAsync(_address);
Assert.Matches(@"^\d{6}$", otp);
}
}
Line by linewhat each step of this C# / .NET example does
- unique prefix
- One inbox per test, so parallel workers never read each other's mail.
- teardown hook
- Runs even when the test fails — that is exactly when inboxes get left behind.
- real code, real email
- No test-mode backdoor in your app: the path under test is the one your users take.
API reference
| Endpoint | What it does | Returns |
|---|---|---|
| POST /api/v1/inboxes | Open an inbox | { ok, address, api_key, retention_hours } |
| GET /api/v1/inboxes | Every inbox this key opened | { ok, inboxes: [...] } |
| GET /api/v1/inboxes/{address}/latest | Newest message, the polling call | { ok, email: {...} | null } |
| GET /api/v1/inboxes/{address}/messages | Every message, newest first | { ok, emails: [...] } |
| POST /api/v1/inboxes/{address}/send | Send from this address | { ok } |
| DELETE /api/v1/inboxes/{address} | Drop the inbox and its mail | { ok } |
| DELETE /api/v1/inboxes/{address}/messages/{id} | Drop one message | { ok } |
Full endpoint reference, including error shapes and rate limits: Agent API and MCP.