Email testing from the command line
Every endpoint in one terminal. Useful for smoke checks, CI shell steps, and for seeing exactly what your language client sends.
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.
- curl + jqThat is the whole toolchain requirement.
The client
There is no curl 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. You need curl and (for reading JSON) jq.
export MAILFLAT_API_KEY="mf_live_..."
export MAILFLAT_API="https://mailflat.net/api/v1"
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
ADDRESS=$(curl -s -X POST "$MAILFLAT_API/inboxes" \
-H "X-API-Key: $MAILFLAT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"prefix":"signup","retention_hours":2}' | jq -r .address)
echo "$ADDRESS" # signup@a7f2c.mailflat.net
# 2. your app sends the code to that address
# 3. read it back
curl -s "$MAILFLAT_API/inboxes/$ADDRESS/latest" \
-H "X-API-Key: $MAILFLAT_API_KEY" | jq -r .email.otp_code
# → 482913
# 4. done with it
curl -s -X DELETE "$MAILFLAT_API/inboxes/$ADDRESS" \
-H "X-API-Key: $MAILFLAT_API_KEY"
Line by linewhat each step of this curl 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 a code shows up, with a hard timeout so a missing mail fails loudly.
wait_for_otp() {
local address="$1" timeout="${2:-60}" waited=0
while [ "$waited" -lt "$timeout" ]; do
otp=$(curl -s "$MAILFLAT_API/inboxes/$address/latest" \
-H "X-API-Key: $MAILFLAT_API_KEY" | jq -r '.email.otp_code // empty')
if [ -n "$otp" ]; then
echo "$otp"
return 0
fi
sleep 3
waited=$((waited + 3))
done
echo "no OTP arrived for $address within ${timeout}s" >&2
return 1
}
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
# every message, newest first
curl -s "$MAILFLAT_API/inboxes/$ADDRESS/messages" \
-H "X-API-Key: $MAILFLAT_API_KEY" \
| jq '.emails[] | {subject, sender, otp_code, received_at}'
# just the latest one
curl -s "$MAILFLAT_API/inboxes/$ADDRESS/latest" \
-H "X-API-Key: $MAILFLAT_API_KEY" | jq .email.body_text
# drop one message, keep the inbox
curl -s -X DELETE "$MAILFLAT_API/inboxes/$ADDRESS/messages/12" \
-H "X-API-Key: $MAILFLAT_API_KEY"
| 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
curl -s -X POST "$MAILFLAT_API/inboxes/$ADDRESS/send" \
-H "X-API-Key: $MAILFLAT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "someone@example.com",
"subject": "Welcome",
"body": "Plain text body",
"html": "<p>Optional HTML body</p>"
}'
Cleaning up
curl -s -X DELETE "$MAILFLAT_API/inboxes/$ADDRESS" \
-H "X-API-Key: $MAILFLAT_API_KEY"
# or leave it: retention_hours on create means it cleans itself up
curl -s -X POST "$MAILFLAT_API/inboxes" \
-H "X-API-Key: $MAILFLAT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"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 GitHub Actions, GitLab CI, Makefile, shell and anything else that gives you a setup and teardown hook.
# .github/workflows/e2e.yml — a real inbox for the whole job
name: e2e
on: [push]
jobs:
test:
runs-on: ubuntu-latest
env:
MAILFLAT_API_KEY: ${{ secrets.MAILFLAT_API_KEY }}
MAILFLAT_API: https://mailflat.net/api/v1
steps:
- uses: actions/checkout@v4
- name: Open an inbox for this run
run: |
ADDRESS=$(curl -s -X POST "$MAILFLAT_API/inboxes" \
-H "X-API-Key: $MAILFLAT_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"prefix\":\"ci-${GITHUB_RUN_ID}\",\"retention_hours\":2}" | jq -r .address)
echo "TEST_EMAIL=$ADDRESS" >> "$GITHUB_ENV"
- run: npm ci && npm test
- name: Tear down
if: always()
run: |
curl -s -X DELETE "$MAILFLAT_API/inboxes/$TEST_EMAIL" \
-H "X-API-Key: $MAILFLAT_API_KEY" || true
Line by linewhat each step of this curl 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.