// blog

· 11 min read · by Alaeddine

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:

  1. Create an inbox. A fresh one per test, so the only mail in it is the mail this test caused.
  2. Trigger the email. Sign up in the app under test with the inbox's address.
  3. Wait for it. Poll the inbox until the message arrives — never a fixed sleep.
  4. Read it. Fetch the full message; the links and codes in it are already extracted.
  5. 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.io

Every 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.

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.ok

The 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.

Try it on your own suite

Create an inbox, point a test at it, and watch the email arrive. Every plan starts with a 14-day free trial.