GUIDES / EMAIL VERIFICATION

Testing email verification, end to end.

Email verification is the gate in front of your product: sign up, receive a link, click it, and only then does the account become real. It is not login — a magic link signs an existing user in; a verification link flips a verified bit that everything downstream depends on. That flip is exactly the kind of state change test suites quietly skip, because the credential the test needs is a URL inside an email your app just sent.

Quick answer. Create a private inbox inside the test, point your signup form at its address, long-poll until the verification email arrives, read the link as a classified verify field instead of scraping HTML, then click it — in the browser, or server-side via an API call — and assert the account actually flipped from gated to verified. No sleeps, no shared mailbox, no regex.

After this guide you can write that test in Playwright, in pytest, or with nothing but curl, and you'll know the four edge cases that deserve a test each — including the one where a corporate mail scanner clicks your link before any human does.

A verification test is three assertions, not one

Most "verification tests" assert only that the email showed up. That proves your app called its email provider. It does not prove verification works. The real test has three parts:

  1. The gate holds. Immediately after signup, the account must be restricted — can't log in, can't act, sees "check your email." If this assertion is missing, the day someone accidentally marks users verified at creation, every test stays green while the gate silently disappears.
  2. The flip happens. Clicking the link transitions the account to verified, observably: the user can now log in, or your API reports verified: true.
  3. The gate closes behind it. The token is single-use (or expires). Following the same link twice must not succeed twice.

Everything below is machinery for making those three assertions reliable.

Why these tests flake

Fixed sleeps. waitForTimeout(5000) is a bet against the tail of your delivery-latency distribution. When delivery takes longer, the test fails red on a working feature; when it takes less, the suite burns the difference, multiplied by every test.

Shared inboxes. One qa@company.com mailbox shared by parallel workers means worker A fishes worker B's verification link out of the pile. Verification tokens are bound to an account, so A's click verifies B's account — or fails with "invalid token" — intermittently, order-dependently, and never on your machine.

Scraping HTML for hrefs. querySelector('a') finds the logo link. The first-https:// regex finds the tracking pixel's host. A verification email routinely carries five or more URLs — logo, help center, unsubscribe, social footer, and somewhere in there, the button. "The verification link" is a classification problem, not a string search, and every hand-rolled extractor breaks at once the day marketing wraps the button in a click-tracker.

Asserting arrival instead of state. The test that ends at "an email arrived with a link in it" tests your email provider, not your product. The flip is the product.

The pattern: inbox-per-test, long-poll, classified link

Create the inbox inside the test — fresh, private, expiring on its own. Let the server hold the request open until the email lands. Read the link as a typed field: extraction runs on the receiving side, on every message, and each URL arrives labeled verify, reset, unsubscribe, or other.

With Playwright and the JS SDK (mailfixture on npm, v0.5.x, Node 18+; timeouts are milliseconds):

verify.spec.ts · Playwrightnpm i -D mailfixture
import { test, expect } from '@playwright/test';
import { MailFixture } from 'mailfixture';

const mail = new MailFixture(); // reads MAILFIXTURE_API_KEY from the environment

test('signup gates until the email is verified', async ({ page }) => {
  const inbox = await mail.createInbox({ ttlSeconds: 900 }); // cleans itself up

  await page.goto('/signup');
  await page.fill('#email', inbox.address);
  await page.fill('#password', 'a-real-test-password');
  await page.click('text=Create account');

  // 1. the gate holds
  await expect(page.locator('.banner')).toContainText('check your email');

  // 2. the flip happens
  const link = await inbox.waitForLink({ kind: 'verify', timeout: 30_000 });
  await page.goto(link.url);
  await expect(page.locator('h1')).toHaveText('Email verified');

  // 3. the gate closes behind it
  await page.goto(link.url);
  await expect(page.locator('body')).toContainText('already been used');
});

The timeout is a ceiling, not a duration — waitForLink resolves the moment the email arrives. And because the link comes back classified, the test survives template rewrites, tracking wrappers, and however many footer links legal adds next quarter.

Keep the API key out of the repo: MAILFIXTURE_API_KEY as a CI secret, one key per environment so a leaked staging key can be revoked without touching the rest.

No browser? Click server-side.

API-level suites don't have a page to navigate. For those, the API performs the click for you: it GETs the extracted link server-side, follows up to five redirects, and reports how the target answered — final URL, status, redirect count. The response body is never returned, and only URLs that extraction actually found in the message can be followed.

With pytest and the Python SDK (mailfixture on PyPI, v0.5.x, Python 3.9+; timeouts are seconds):

test_verification.py · pytestpip install mailfixture
from mailfixture import MailFixture

mf = MailFixture()  # reads MAILFIXTURE_API_KEY from the environment

def test_verification_flips_the_account(app):
    inbox = mf.create_inbox(ttl_seconds=900)
    app.signup(email=inbox.address, password="a-real-test-password")

    assert app.get_user(inbox.address)["verified"] is False   # the gate holds

    msg = inbox.wait_for_message(match="subject:verify", timeout=30)
    result = mf.follow_link(msg.id, kind="verify")            # server-side click
    assert result.ok                                          # target answered 2xx
    assert "/welcome" in result.final_url

    assert app.get_user(inbox.address)["verified"] is True    # the flip happened

    second = mf.follow_link(msg.id, kind="verify")            # the gate closes
    assert not second.ok

    inbox.delete()

Two constraints worth knowing before you reach for the server-side click:

Raw REST for everything else

The SDKs are JS/TS and Python. From Java, Go, Ruby, C#, or a shell script, the same flow is four HTTP calls against https://api.mailfixture.com:

verify.sh · any language with an HTTP client
# 1. create an inbox
curl -s -X POST https://api.mailfixture.com/v1/inboxes \
  -H "Authorization: Bearer $MAILFIXTURE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"ttl_seconds": 900}'
# → {"id": "…", "email_address": "x7f3a9@…", …}

# 2. trigger your signup flow toward that address, then long-poll:
#    the request holds open until a message arrives (or 45s pass; ceiling 60)
curl -s "https://api.mailfixture.com/v1/inboxes/$INBOX_ID/messages?wait=45" \
  -H "Authorization: Bearer $MAILFIXTURE_API_KEY"

# 3. read the classified links (wire field name: "class")
curl -s "https://api.mailfixture.com/v1/messages/$MESSAGE_ID/links" \
  -H "Authorization: Bearer $MAILFIXTURE_API_KEY"

# 4. server-side click on the first verify-classed link
curl -s -X POST "https://api.mailfixture.com/v1/messages/$MESSAGE_ID/links/follow" \
  -H "Authorization: Bearer $MAILFIXTURE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"class": "verify"}'
# → {"requested_url": "…", "final_url": "…", "status": 200, "redirects": 1, "ok": true}

Then assert the flip against your own app's API, same as the Python version. Note the naming: the wire calls the link category class; both SDKs expose it as kind. Same thing.

Failure modes

SYMPTOM & CAUSEEVIDENCEFIX
Green locally, times out in CI — staging sends through a sandboxed/suppressed provider configApp logs show the send; the inbox's message list stays emptyMake CI receive real mail end to end — that misconfigured provider key is precisely the bug this test exists to catch
Test lands on the unsubscribe page — first-href regex or querySelector('a') grabbed the wrong URLThe URL the test followed doesn't contain your verify pathAsk for the link by classification (kind: 'verify'), not by position
Parallel workers fail with "invalid token" — shared inbox; workers consumed each other's linksFailures correlate with parallelism, vanish when run seriallyOne inbox per test, created inside the test
Wait times out but the message is visible in the dashboardmatch filter doesn't match the actual subjectListing without match returns the messageLoosen the filter, or drop it — a private inbox rarely needs one
Gate assertion fails: account verified before any click — app marks users verified at creation, or verifies on sendFails deterministically, from the first runThat's a product bug — the reason assertion #1 exists
Server-side click returns 400 — link target isn't public HTTPS (localhost, VPN-only staging)follow fails while a browser on the VPN opens the link fineUse the browser click, or give staging a publicly reachable hostname

Edge cases worth a test each

Scanner prefetch — the big one. Corporate mail security (Outlook Safe Links, and most gateway filters) GETs every URL in an email before the human sees it. If your verification completes on a bare GET, the scanner verifies the account while the message is still in transit. Sounds harmless for verification — the user was going to click anyway — until you notice what it breaks: your "gate holds" assertion races the scanner, bot signups verify themselves without a human in the loop, and your activation metrics count machines. The fix is the same one we landed on for our own invite emails after watching scanners hit them: GET renders a confirmation page, POST performs the flip. Then pin it with a test — the server-side click above is a bare GET, so follow_link followed by an assertion that the account is still unverified proves the scanner can't consume your tokens. The browser test performs the real POST click.

Single use. Follow the link twice; the second attempt must fail. This is the assertion that catches real token-handling regressions, and it only means something from a private inbox — in a shared one you can't know whose click was first.

Expiry. If verification links die after 24 hours, test it — with a test hook that ages the token, not a real sleep. Mint, age, follow, expect rejection.

Resend. Users click "resend email" — then which link wins? Newest, oldest, or both? Whatever your product promises, assert it. Record the first message's received_at, trigger the resend, then wait for the second through the message-list API with that value as since, so the test can't accidentally re-read message one. A new waitForLink call by itself starts from inbox history.

When this is the wrong tool

Be honest about which suites need the real flow. Verification is one feature; it shouldn't tax every test that merely needs a logged-in user. The usual architecture:

What this doesn't prove

MailFixture is receive-only — it accepts the mail your app sends and never sends anything itself, so it cannot exercise your provider's sending path beyond observing what arrives. Inspecting the received message proves content, links, and headers; it does not prove how Gmail or Outlook will render the email — that's a different job we don't do. The server-side click reports how your endpoint answered (status, final URL, redirect count) and never returns the page body, so assertions on landing-page content need the browser. And link classification is a heuristic over URL paths and anchor text; if your template defeats it, select by exact url or index instead — and send us the email so the extractor learns it.

NOTE One boundary that isn't technical: test signup flows you own. Using programmatic inboxes to mass-register accounts on services that aren't yours is against the terms and gets accounts frozen.

Next steps

100 messages/mo free · links arrive classified, not scraped
Start free Read the quickstart