Email testing with Go
Standard library only: net/http and encoding/json. One small client type covers every endpoint you need in a test.
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.
- Go 1.21+ (standard library only)That is the whole toolchain requirement.
The client
There is no Go 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: net/http and encoding/json are enough.
// Save this as mailflat_test.go (or internal/mailflat/client.go).
package mailflat
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
const api = "https://mailflat.net/api/v1"
func call(method, path string, body any) (map[string]any, error) {
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequest(method, api+path, rdr)
if err != nil {
return nil, err
}
req.Header.Set("X-API-Key", os.Getenv("MAILFLAT_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var out map[string]any
return out, json.NewDecoder(res.Body).Decode(&out)
}
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
created, err := call("POST", "/inboxes", map[string]any{
"prefix": "signup",
"retention_hours": 2,
})
if err != nil {
t.Fatal(err)
}
address := created["address"].(string) // signup@a7f2c.mailflat.net
// 2. your app sends the code to that address
app.Register(address)
// 3. read it back, no mocking anywhere
otp, err := waitForOtp(address, 60*time.Second)
if err != nil {
t.Fatal(err)
}
// 4. done with it
call("DELETE", "/inboxes/"+address, nil)
Line by linewhat each step of this Go 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.
func waitForOtp(address string, timeout time.Duration) (string, error) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
res, err := call("GET", "/inboxes/"+address+"/latest", nil)
if err != nil {
return "", err
}
if enc, _ := res["encrypted"].(bool); enc {
return "", fmt.Errorf("%v", res["note"]) // E2E inbox: server cannot read it
}
if email, ok := res["email"].(map[string]any); ok {
if otp, ok := email["otp_code"].(string); ok && otp != "" {
return otp, nil
}
}
time.Sleep(3 * time.Second)
}
return "", fmt.Errorf("no OTP arrived for %s within %s", address, timeout)
}
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
res, err := call("GET", "/inboxes/"+address+"/messages", nil)
if err != nil {
t.Fatal(err)
}
emails := res["emails"].([]any) // newest first
for _, e := range emails {
msg := e.(map[string]any)
fmt.Println(msg["subject"], msg["sender"], msg["received_at"])
fmt.Println(msg["body_text"]) // plain text body
fmt.Println(msg["body_html"]) // HTML body
fmt.Println(msg["otp_code"]) // extracted code, or nil
}
| 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
call("POST", "/inboxes/"+address+"/send", map[string]any{
"to": "someone@example.com",
"subject": "Welcome",
"body": "Plain text body",
"html": "<p>Optional HTML body</p>",
})
Cleaning up
call("DELETE", "/inboxes/"+address, nil)
// or leave it: retention_hours on create means it cleans itself up
call("POST", "/inboxes", map[string]any{"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 go test, Testify, Ginkgo and anything else that gives you a setup and teardown hook.
// signup_test.go — a fresh inbox per test, torn down even on failure
func newInbox(t *testing.T) string {
t.Helper()
created, err := call("POST", "/inboxes", map[string]any{
"prefix": fmt.Sprintf("ci-%d", time.Now().UnixNano()),
"retention_hours": 2,
})
if err != nil {
t.Fatal(err)
}
address := created["address"].(string)
t.Cleanup(func() { call("DELETE", "/inboxes/"+address, nil) })
return address
}
func TestUserCanSignUpWithARealCode(t *testing.T) {
address := newInbox(t)
app.Register(address)
otp, err := waitForOtp(address, 60*time.Second)
if err != nil {
t.Fatalf("no verification code: %v", err)
}
if len(otp) != 6 {
t.Fatalf("unexpected code %q", otp)
}
}
Line by linewhat each step of this Go 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.