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. Tick Read messages and Create and delete inboxes (inbox:read + inbox:manage): a new key can only read, so opening an inbox fails with 403 until you add the second one. 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"
| JSON 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 UTC (ends with Z) | 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.
On the Free plan you can send to your own addresses (your account email or any of your inboxes). To send anywhere else, connect and verify a domain. Free includes one; Pro plans include more.
# 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, needs inbox:manage | { ok, address, name, 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.