MailFlatDocs
Documentation/Languages/C# / .NET

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

  1. A MailFlat account
    Free to start, no card. Every plan can open inboxes from the API.
  2. An account API key
    Agents → 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.
  3. .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.
C# / .NET
// 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.
C# / .NET
// 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.
C# / .NET
// 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

C# / .NET
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
}
FieldTypeWhat it is
subjectstringSubject line
senderstringFrom address
body_textstringPlain text body
body_htmlstringHTML body
otp_codestring | nullOne-time code, extracted by us
to_addressstringThe exact address it was sent to, tag included
tagstring | nullPlus-addressing tag, if the sender used one
received_atISO 8601When it landed
is_encryptedbooleanTrue 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.
C# / .NET
// 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

C# / .NET
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.
C# / .NET
// 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

EndpointWhat it doesReturns
POST /api/v1/inboxesOpen an inbox{ ok, address, api_key, retention_hours }
GET /api/v1/inboxesEvery inbox this key opened{ ok, inboxes: [...] }
GET /api/v1/inboxes/{address}/latestNewest message, the polling call{ ok, email: {...} | null }
GET /api/v1/inboxes/{address}/messagesEvery message, newest first{ ok, emails: [...] }
POST /api/v1/inboxes/{address}/sendSend 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.