back to blog
Guides

automate otp & magic-link capture in your tests

June 5, 2026 8 min readguides
A magic-link envelope emitting a sparkly gradient link with a robot hand clicking it, framed by code brackets
on this page

// the takeaway

the takeaway

If your end-to-end tests touch a signup, login, or password-reset flow, you need a programmatic way to read the email that comes back. The reliable recipe is three pieces: a disposable address per test, a waitForMessage primitive that long-polls until the message lands, and an extractor that pulls the OTP or magic link out of the body. The extractor is where most teams reinvent the wheel — skip the regex zoo and let the inbox do it.

// why parsing email is painful

why parsing email is painful

Email looks structured until you actually try to parse it. A single "verification" message can arrive as:

  • plain text with the code on its own line
  • HTML with the code wrapped in a styled <span>
  • multipart/alternative where the text and HTML disagree
  • a quoted-printable body where =3D appears mid-code
  • a magic link with tracking params appended after the real token

Then marketing redesigns the template and your regex breaks at 2am. The real problem isn't writing the parser — it's owning it forever.

// regex vs ai extraction

regex vs ai extraction

Regex is fine for a single, stable template. A 6-digit numeric code in a transactional email you control? /\b\d6\b/ is honestly enough.

// works until someone adds a year ("© 2026") to the footer
const code = body.match(/\b\d{6}\b/)?.[0];

It stops being fine the moment any of these are true: codes can be 4–8 digits, codes can include letters, the email mixes plain and HTML parts, or you're testing against a third-party (Stripe, Auth0, your own identity provider) whose template you don't control.

That's where a small model earns its keep. ephemail runs an extractor at delivery time and attaches the result to the message payload:

{
  "id": "msg_01HZ...",
  "subject": "Your verification code",
  "extracted": {
    "type": "otp",
    "value": "819203",
    "confidence": 0.98
  }
}

You stop maintaining a parser. You read a field. The same extractor handles magic links — it picks the action URL and ignores the unsubscribe footer.

// the wait-for-message pattern

the wait-for-message pattern

The second half of automation is timing. Email delivery is asynchronous and the latency varies — usually under a second, occasionally five.setTimeout(5000) is the wrong answer twice: it's too slow on a happy day and too fast on a bad one.

The right answer is a long-poll endpoint that blocks until a matching message arrives or a timeout fires. ephemail exposes it as waitForMessage with a filter:

POST /v1/wait-for-message
{
  "address": "qa-stg-checkout@ephemail.io",
  "subjectContains": "verification code",
  "timeoutMs": 30000
}

On the server, it subscribes to the inbox stream and returns the moment a message matches. No polling loop in your test. No fixed sleep.

// code samples

code samples

javascript / playwright

import { test, expect } from "@playwright/test";
import { Ephemail } from "@ephemail/sdk";

const mail = new Ephemail({ apiKey: process.env.EPHEMAIL_KEY! });

test("signup flow extracts otp", async ({ page }) => {
  const inbox = await mail.createAddress();

  await page.goto("/signup");
  await page.fill('[name="email"]', inbox.address);
  await page.click('button[type="submit"]');

  const msg = await mail.waitForMessage({
    address: inbox.address,
    subjectContains: "verification code",
    timeoutMs: 30_000,
  });

  // no regex — read the extracted field
  expect(msg.extracted?.type).toBe("otp");
  await page.fill('[name="code"]', msg.extracted!.value);
  await page.click('button[type="submit"]');

  await expect(page).toHaveURL("/welcome");
});

python / pytest

import os
from ephemail import Ephemail

mail = Ephemail(api_key=os.environ["EPHEMAIL_KEY"])

def test_magic_link_login(page):
    inbox = mail.create_address()

    page.goto("/login")
    page.fill('[name="email"]', inbox.address)
    page.click('button[type="submit"]')

    msg = mail.wait_for_message(
        address=inbox.address,
        subject_contains="sign in",
        timeout_ms=30_000,
    )

    assert msg.extracted.type == "magic_link"
    page.goto(msg.extracted.value)
    assert page.url.endswith("/dashboard")

Both samples use the same shape: spin up an address, trigger the flow, await the message, read the extracted field. No mailbox cleanup — the address auto-expires.

// flaky-test pitfalls

flaky-test pitfalls

  • reusing one inbox across tests. Parallel tests see each other's mail and grab the wrong code. Create a fresh address per test.
  • matching on a stale code. If your flow can resend, an older verification email might still be in the inbox. Filter by receivedAfter (or by a unique correlation ID you embed in the subject) instead of just "first match".
  • following the wrong link. Transactional emails contain an unsubscribe link too. A naive "first https://" extractor will eventually unsubscribe you mid-test. Use the typed magic_link field or scope your selector to the CTA.
  • silent timeouts. Default timeoutMs too low → flaky on cold-start environments. Too high → a real outage takes ten minutes to surface. 30 seconds is a good starting point; tune per environment.
  • HTML-only templates without a text part. Some servers strip HTML, leaving you with nothing to parse. Always prefer the provider's parsed/extracted field over rolling your own.

// key takeaways

key takeaways

  • Use one disposable address per test — never share an inbox across parallel runs.
  • Replace setTimeout with a long-poll waitForMessage filtered by subject or correlation ID.
  • Skip the regex graveyard: read a typed extracted field so a template redesign doesn't break CI.
  • Set a 30s timeout, scope link selectors to the CTA, and filter by receivedAfter to avoid stale codes.
  • Auto-expiring inboxes mean no cleanup step and no permanent record of test data.
spin up a throwaway inbox in a browser tab — same address works in your tests. try it →

Disposable inboxes are for testing software you control. When testing against third-party providers, follow their terms of service.

Amine Gharby
// written by
Amine Gharby
Founder, ephemail

Amine builds ephemail and writes about email plumbing, developer tooling, and the small annoyances of testing software.

// try it

spin up a throwaway inbox

no signup. instant. gone in ten minutes.

open inbox
// related posts

keep reading