Most signup flows end with "check your inbox", and most test suites end there too. The account gets created, the verification email is assumed, and the one step that decides whether a real user can ever sign in goes untested until someone notices the link is broken.
mailQA's SDK is JavaScript, but the thing underneath it is a small REST API, and a test in
any language can drive it with an HTTP client. This post walks through an email-verification
test end to end with nothing but curl and jq, then writes the same test in Python with
pytest. Either one runs in CI as it is.
The shape of the test
Every email test against mailQA has the same five steps:
- Create an inbox. A fresh one per test, so the only mail in it is the mail this test caused.
- Trigger the email. Sign up in the app under test with the inbox's address.
- Wait for it. Poll the inbox until the message arrives — never a fixed sleep.
- Read it. Fetch the full message; the links and codes in it are already extracted.
- Act and assert, then delete the inbox.
All of it is five endpoints, authenticated with an API key from Settings → API keys in the dashboard:
export MAILQA_API_KEY=mqa_live_…
API=https://api.mailqa.ioEvery request carries it as a bearer token: Authorization: Bearer $MAILQA_API_KEY. The
responses below are trimmed to the fields this test uses; the
API reference has them in full.
Getting the app's email into mailQA
The inbox you create has a real address, like smoke-1727261437@acme.mailqa.io. If the
environment under test sends through a real provider, signing up with that address is all it
takes — the mail arrives over the public internet like anyone else's.
If you would rather the app sent nothing out at all, point its SMTP at the sandbox instead:
smtp.mailqa.io, port 587 with STARTTLS, and your organization's SMTP credentials (in the
dashboard, or GET /v1/smtp/credentials). Mail sent to an inbox's address still lands in that
inbox, and mail to anyone else is captured in an inbox of its own. The
sandbox page has the configuration for each common framework.
Step by step with curl
1. Create an inbox
curl -s -X POST "$API/v1/inboxes" \
-H "Authorization: Bearer $MAILQA_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"name": "verification smoke test", "address": "smoke-1727261437"}'{
"id": "clx7f0a1b0000qw3h2v9k1m4t",
"name": "verification smoke test",
"address": "smoke-1727261437@acme.mailqa.io",
"kind": "MX",
"messageCount": 0,
"created": true,
"smtp": { "host": "smtp.mailqa.io", "ports": [587, 2525], "username": "acme", "password": "…" }
}address is the local part you asked for, exactly — no suffix is added — so make it unique
per run. Asking for one that already exists is not an error: you get that inbox back, with
created: false. Leave address out and it is derived from name.
2. Trigger the email
This part is your app's, not mailQA's. Against an API:
curl -s -X POST https://staging.acme.dev/api/signup \
-H 'Content-Type: application/json' \
-d '{"email": "smoke-1727261437@acme.mailqa.io", "password": "correct horse battery staple"}'— or drive the signup form with a browser automation tool; the rest of the test is the same.
3. Wait for the message
List the inbox's messages with a filter, newest first, and ask again every second until one comes back:
curl -s -G "$API/v1/inboxes/$INBOX_ID/messages" \
-H "Authorization: Bearer $MAILQA_API_KEY" \
--data-urlencode 'subject_contains=Verify' \
-d limit=1{
"data": [
{
"id": "clx7f0c9e0003qw3hq1x8b2de",
"from": { "address": "noreply@acme.dev", "name": "Acme" },
"to": ["smoke-1727261437@acme.mailqa.io"],
"subject": "Verify your email address",
"receivedAt": "2026-09-25T10:17:21.482Z"
}
],
"nextCursor": null,
"hasMore": false
}An empty data means it has not arrived yet. The other filters worth knowing are from,
to, tag (the part after a + in the address) and since, an ISO timestamp for when an
inbox is deliberately reused. The rate limits are set with exactly this polling in mind, so
once a second is fine.
4. Read the link out of it
The list returns summaries. The full message — bodies, headers, attachments, and the links and codes mailQA extracted while parsing it — is one more request:
curl -s "$API/v1/messages/$MESSAGE_ID" -H "Authorization: Bearer $MAILQA_API_KEY"{
"id": "clx7f0c9e0003qw3hq1x8b2de",
"subject": "Verify your email address",
"text": "Confirm your address to finish signing up: https://staging.acme.dev/verify?token=9f2c…",
"links": [
{ "url": "https://staging.acme.dev/", "text": "Acme" },
{ "url": "https://staging.acme.dev/verify?token=9f2c…", "text": "Confirm email" }
],
"codes": [],
"parseStatus": "PARSED"
}Pick the link by what it points at, not by position: the first link in most templates is the
logo. For a one-time-code flow, codes holds the codes found next to a keyword like "code",
"OTP" or "verification" — so an order number elsewhere in the email is not mistaken for one.
5. Follow it, assert, clean up
Visit the link, check the account is now verified in whatever way your app exposes, and delete the inbox:
curl -s -X DELETE "$API/v1/inboxes/$INBOX_ID" -H "Authorization: Bearer $MAILQA_API_KEY"{ "deletedMessages": 1 }The whole test as one script
Put together, with the waiting done properly and the inbox deleted however the script exits:
#!/usr/bin/env bash
# verify-email.sh — needs curl, jq, MAILQA_API_KEY and APP_URL
set -euo pipefail
API=${MAILQA_URL:-https://api.mailqa.io}
AUTH="Authorization: Bearer $MAILQA_API_KEY"
# 1. A fresh inbox, deleted on the way out whatever happens.
inbox=$(curl -sf -X POST "$API/v1/inboxes" -H "$AUTH" -H 'Content-Type: application/json' \
-d "{\"name\": \"verification smoke test\", \"address\": \"smoke-$(date +%s)-$RANDOM\"}")
inbox_id=$(jq -r .id <<<"$inbox")
address=$(jq -r .address <<<"$inbox")
trap 'curl -sf -X DELETE "$API/v1/inboxes/$inbox_id" -H "$AUTH" >/dev/null' EXIT
# 2. Sign up with it.
curl -sf -X POST "$APP_URL/api/signup" -H 'Content-Type: application/json' \
-d "{\"email\": \"$address\", \"password\": \"correct horse battery staple\"}" >/dev/null
# 3. Wait up to 30 seconds for the verification email.
message_id=
for _ in $(seq 30); do
message_id=$(curl -sf -G "$API/v1/inboxes/$inbox_id/messages" -H "$AUTH" \
--data-urlencode 'subject_contains=Verify' -d limit=1 | jq -r '.data[0].id // empty')
[ -n "$message_id" ] && break
sleep 1
done
[ -n "$message_id" ] || { echo "FAIL: no verification email within 30s" >&2; exit 1; }
# 4. The verification link, chosen by its path.
link=$(curl -sf "$API/v1/messages/$message_id" -H "$AUTH" |
jq -r '[.links[].url | select(test("/verify\\?token="))][0] // empty')
[ -n "$link" ] || { echo "FAIL: the email has no /verify link" >&2; exit 1; }
# 5. Following it must succeed, and afterwards the account must be able to sign in.
curl -sf -o /dev/null -L "$link"
curl -sf -o /dev/null -X POST "$APP_URL/api/login" -H 'Content-Type: application/json' \
-d "{\"email\": \"$address\", \"password\": \"correct horse battery staple\"}"
echo "PASS: email verification works"The signup and login calls are stand-ins for your own app's; everything that talks to mailQA is exactly as written. It makes a good post-deploy smoke test on its own: run it against staging after every release and a broken verification email stops the pipeline.
The same test in Python
In a real suite you want a fixture that creates and deletes the inbox, and a wait helper
you write once. With pytest and requests:
# test_email_verification.py
import os
import time
import uuid
import pytest
import requests
API = os.environ.get("MAILQA_URL", "https://api.mailqa.io")
APP = os.environ["APP_URL"]
mailqa = requests.Session()
mailqa.headers["Authorization"] = f"Bearer {os.environ['MAILQA_API_KEY']}"
@pytest.fixture
def inbox(request):
"""A fresh inbox per test, deleted afterwards even when the test fails."""
res = mailqa.post(
f"{API}/v1/inboxes",
json={"name": request.node.name[:80], "address": f"py-{uuid.uuid4().hex[:8]}"},
)
res.raise_for_status()
inbox = res.json()
yield inbox
mailqa.delete(f"{API}/v1/inboxes/{inbox['id']}")
def wait_for_message(inbox_id, timeout=30, **filters):
"""Poll until a message matches, then return it in full."""
deadline = time.monotonic() + timeout
while True:
res = mailqa.get(
f"{API}/v1/inboxes/{inbox_id}/messages", params={**filters, "limit": 1}
)
res.raise_for_status()
page = res.json()["data"]
if page:
return mailqa.get(f"{API}/v1/messages/{page[0]['id']}").json()
if time.monotonic() + 1 >= deadline:
raise TimeoutError(f"no message matching {filters} within {timeout}s")
time.sleep(1)
def test_signup_sends_a_working_verification_link(inbox):
password = "correct horse battery staple"
requests.post(
f"{APP}/api/signup", json={"email": inbox["address"], "password": password}
).raise_for_status()
message = wait_for_message(inbox["id"], subject_contains="Verify")
assert message["from"]["address"] == "noreply@acme.dev"
link = next((l["url"] for l in message["links"] if "/verify?token=" in l["url"]), None)
assert link, f"no verification link in {[l['url'] for l in message['links']]}"
assert requests.get(link).ok
login = requests.post(f"{APP}/api/login", json={"email": inbox["address"], "password": password})
assert login.ok, "the account should be able to sign in once verified"
def test_login_code_is_emailed(inbox):
requests.post(f"{APP}/api/login/code", json={"email": inbox["address"]}).raise_for_status()
message = wait_for_message(inbox["id"], subject_contains="code")
assert message["codes"], f"no code found in: {message['text']}"
res = requests.post(
f"{APP}/api/login/verify", json={"email": inbox["address"], "code": message["codes"][0]}
)
assert res.okThe wait helper is the part worth copying exactly. It polls instead of sleeping for a guessed
duration, it gives up with a message that says which filter matched nothing, and it asks for
limit=1, which returns the newest match — the email the test just triggered.
Reusing an inbox, and the clock
A fresh inbox per test makes waiting simple: whatever arrives is yours. When a test
deliberately sends several emails to one inbox — a verification, then a password reset — pass
since so the wait ignores what came before the step it is checking, and keep a subject
filter on it too, since a wait returns the newest match and a late welcome email is newer:
from datetime import datetime, timedelta, timezone
# Two seconds of margin for a CI machine whose clock runs a little fast.
since = (datetime.now(timezone.utc) - timedelta(seconds=2)).isoformat()
requests.post(f"{APP}/api/password-reset", json={"email": inbox["address"]})
reset = wait_for_message(inbox["id"], subject_contains="reset", since=since)since is compared against the time mailQA received the message, by mailQA's clock, while
the timestamp you send comes from yours. A CI runner a few seconds fast would put since
after the email and the wait would never see it, which is what the margin is for. Nothing
waits with a time filter by default, for the same reason.
When something fails
Every error has the same shape, with a code that is stable and safe to branch on:
{ "error": { "code": "not_found", "message": "Inbox not found" } }code |
Status | Usually means |
|---|---|---|
unauthorized |
401 | The API key is missing, mistyped, expired or revoked |
not_found |
404 | The id is wrong — or belongs to another organization, which is reported the same way |
validation_error |
422 | A parameter is malformed; details names it |
quota_exceeded |
402 | The plan's inbox limit is reached — delete stale inboxes |
rate_limited |
429 | Too many requests; back off and retry |
When a wait times out, check in order: did the app send at all (its logs), did it send here
(the address, or the SMTP credentials), and is the filter right — subject_contains is a
case-insensitive substring, not a pattern. Listing the inbox with no filter answers most of
that in one request.
A job cancelled mid-run never reaches its teardown, and its inbox keeps counting against the
plan. GET /v1/inboxes lists them all with createdAt; a scheduled job that deletes the
smoke- and py- ones older than an hour keeps the count honest.
Where to go next
- The API reference documents every endpoint and field used here, with samples.
- The quickstart covers creating the key and the first inbox in more detail.
- Writing tests in JavaScript? Playwright and Cypress both have an SDK that does the waiting for you.