Recipes
Complete, copy-paste patterns for the things people actually build with MailFlat.
A fresh inbox per test (pytest)
A fixture that opens an isolated inbox, hands the address to the test, and deletes it afterwards, even when the test fails.
# conftest.py, one throwaway inbox per test
import os, time, uuid, pytest, requests
API = "https://mailflat.net/api/v1"
H = {"X-API-Key": os.environ["MAILFLAT_API_KEY"]}
@pytest.fixture
def inbox():
prefix = f"ci-{uuid.uuid4().hex[:8]}"
r = requests.post(f"{API}/inboxes", headers=H,
json={"prefix": prefix, "retention_hours": 2})
address = r.json()["address"]
yield address
requests.delete(f"{API}/inboxes/{address}", headers=H)
def wait_for_otp(address, timeout=60):
deadline = time.time() + timeout
while time.time() < deadline:
email = requests.get(f"{API}/inboxes/{address}/latest", headers=H).json().get("email")
if email and email.get("otp_code"):
return email["otp_code"]
time.sleep(3)
raise AssertionError("no OTP arrived")
def test_signup_flow(inbox):
my_app.register(email=inbox) # your app sends the code
assert wait_for_otp(inbox) is not None
Line by linewhat each step of this pytest example does
- uuid4().hex[:8]
- A unique prefix per test, so parallel workers never read each other's mail.
- yield address
- Everything after the yield runs as teardown, including on failure.
- retention_hours: 2
- Safety net: even if teardown is skipped, the messages expire on their own.
- raise AssertionError
- Fail loudly on timeout. A silent None turns into a confusing error three lines later.
End-to-end signup (Playwright)
// e2e/signup.spec.ts, real address, real inbox, no mocking
import { test, expect } from "@playwright/test";
async function waitForOtp(address, timeoutMs = 60_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const r = await fetch(`${API}/inboxes/${address}/latest`, { headers: H });
const otp = (await r.json())?.email?.otp_code;
if (otp) return otp;
await new Promise((r) => setTimeout(r, 3000));
}
throw new Error("OTP never arrived");
}
test("user can sign up with a real code", async ({ page }) => {
const address = await createInbox();
await page.goto("/signup");
await page.fill("#email", address);
await page.click("#submit");
await page.fill("#code", await waitForOtp(address));
await expect(page).toHaveURL(/dashboard/);
});
Line by linewhat each step of this TypeScript example does
- Date.now().toString(36)
- Cheap unique prefix, no extra dependency needed.
- ?.email?.otp_code
- latest returns { email: null } until the first message lands.
- await page.fill("#code", …)
- The real code from a real email, with no test-mode backdoor in your app.
- DELETE at the end
- Keeps the agent inbox list clean between runs.
Wire it into CI (GitHub Actions)
# .github/workflows/e2e.yml
name: e2e
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx playwright test
env:
MAILFLAT_API_KEY: ${{ secrets.MAILFLAT_API_KEY }}
# wipe anything the run left behind
- if: always()
run: |
curl -X DELETE "https://mailflat.net/api/agent/inboxes?group=ci" \
-H "Authorization: Bearer ${{ secrets.MAILFLAT_TOKEN }}" || true
Line by linewhat each step of this workflow.yml example does
- secrets.MAILFLAT_API_KEY
- Repository secret. An account key in a public log grants access to every inbox you own.
- if: always()
- Runs the sweep even when the tests failed. That is exactly when inboxes get left behind.
- DELETE ?group=ci
- Tears down the whole ci batch in one call. Guarded server-side: it only touches API-created inboxes you own.
Two different credentials
The test run uses an account key (X-API-Key) for /api/v1. The group teardown route lives on the session API, so it wants a session token. Store whichever you use as a CI secret, never in the repo.
Give an AI agent its own inbox
# an AI agent that can sign itself up somewhere
pip install "mailflat[langchain]"
from mailflat.langchain import MailFlatToolkit
tools = MailFlatToolkit(api_key=os.environ["MAILFLAT_API_KEY"]).get_tools()
agent = create_react_agent(llm, tools, prompt)
agent.invoke({"input":
"Open an inbox, register on staging.example.com with it, "
"wait for the verification code and complete the signup."})
Line by linewhat each step of this LangChain example does
- get_tools()
- Returns six LangChain tools: create, list, read, wait_for_otp, send, delete_inbox. delete_message is MCP-only.
- "Open an inbox…"
- The model decides when to call each tool; you do not orchestrate the polling loop yourself.
Inside Claude Desktop or Cursor, use the MCP server instead, with the same tools and zero code. See Agent API & MCP.