TempMaily.co
Developers

Selenium Email Testing: Verify Signups with WebDriver

TempMaily Team12 min read

Selenium can fill and submit your signup form, but it can't open the inbox your app emails a verification code to. To test email verification end-to-end, you pair Selenium with a disposable-mail API: mint a real address per test, use it in the form, poll the API for the message, extract the code, type it back into the browser, and assert. This post is the working setup — Python with pytest as the primary example, plus the Java variant of the poll for the other half of Selenium's audience.

If you want the framework-agnostic version of the argument — why the browser alone can't see the email, and how the three usual workarounds (a shared Gmail account over IMAP, one inbox for the whole suite, or mocking the mail service) each bite — the Playwright email testing guide covers it. Here we stay on the Selenium-specific mechanics, in both languages.

Quick answer

Selenium email testing works like this: before the test runs, call a disposable-inbox API to create a real, unique address. Fill your signup form with that address and submit. The app sends its verification email; your test polls the mail API until the message lands, extracts the one-time code (OTP) with a scoped regex, types it into the verification field, and asserts the account is confirmed. Tear the inbox down afterward.

The Selenium twist is that there is no twist. Cypress makes you route network calls through cy.task; Playwright has a request context. Selenium is just a WebDriver client, so the mail poll is plain Python or Java sitting next to your test — a while loop with a deadline, using requests or java.net.http. That is genuinely simpler than the framework-specific plumbing the other tools need. The one thing to get right is keeping the mail poll out of WebDriverWait, which exists to poll the DOM, not an API.

The one Selenium-specific catch: don't poll with WebDriverWait

Selenium developers reach for WebDriverWait reflexively, because it is the right tool for the browser: wait.until(EC.visibility_of_element_located(...)) retries until an element appears or a timeout fires. The instinct is to use the same construct for the email — wait.until(lambda d: message_has_arrived()). Resist it.

WebDriverWait is built to poll the DOM. It calls your condition with the driver on every attempt, its default polling interval is tuned for page rendering (500ms), and its failure mode is a TimeoutException that reads as a browser problem. None of that fits an HTTP poll of a mail API, where there is no driver involved and mail latency is measured in seconds, not milliseconds. Bending WebDriverWait around a network call works, but it obscures what is happening and couples your mail wait to Selenium's browser clock.

Use a plain loop for the mail wait instead — it is a few lines and it reads exactly as what it is:

deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
    if message := check_inbox():
        return message
    time.sleep(interval)
raise TimeoutError(...)

Keep WebDriverWait for the two browser moments where it shines: waiting on the "check your email" confirmation after submit, and the redirect after the code is accepted. Two waits, two clocks, kept apart.

The worked example (Python + pytest)

Two files: a small helper module that talks to the TempMaily API, and a pytest spec that uses it to test a signup-and-verify flow end to end.

The helper is deliberately self-contained — plain requests, no SDK — so you can read every call. Every endpoint, header, and field name below matches the API reference; reconcile against that page if anything ever drifts. One naming note: Python's standard library already has a mailbox module, so name this file mailbox_api.py, not mailbox.py, or your import will shadow the stdlib.

# mailbox_api.py
import os
import re
import time
import requests

API = "https://tempmaily.co/api/v1"
KEY = os.environ.get("XTM_KEY")  # Premium API key, xtm_-prefixed

if not KEY:
    raise RuntimeError("XTM_KEY is not set")

HEADERS = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}


def create_inbox() -> dict:
    """Create a fresh disposable inbox. Each call returns a unique address,
    so every test (and every parallel run) gets its own mailbox."""
    res = requests.post(f"{API}/inboxes", headers=HEADERS, json={"expiry": "1d"})
    res.raise_for_status()
    data = res.json()
    return {"id": data["id"], "address": data["address"], "expiresAt": data["expiresAt"]}


def wait_for_message(inbox_id: str, match, timeout: float = 30, interval: float = 2) -> dict:
    """Poll the inbox until a message whose subject satisfies `match` arrives,
    then return its full body. The rate limit is 300 requests / 60s per key,
    so a 2s interval leaves plenty of headroom."""
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        res = requests.get(f"{API}/inboxes/{inbox_id}/messages", headers=HEADERS)
        res.raise_for_status()
        messages = res.json().get("messages", [])
        hit = next((m for m in messages if match(m["subject"])), None)
        if hit:
            # The list endpoint returns summaries only — fetch the full body.
            full = requests.get(f"{API}/messages/{hit['id']}", headers=HEADERS)
            full.raise_for_status()
            return full.json()  # { subject, bodyText, bodyHtml, ... }
        time.sleep(interval)
    raise TimeoutError(f"No matching message in inbox {inbox_id} within {timeout}s")


def extract_code(message: dict) -> str:
    """Pull a six-digit OTP out of the message. Prefer the text part; fall back
    to HTML. Anchor to nearby copy so a footer number never matches first."""
    body = message.get("bodyText") or message.get("bodyHtml") or ""
    anchored = re.search(r"code(?:\s+is)?[:\s]+(\d{6})", body, re.IGNORECASE)
    if anchored:
        return anchored.group(1)
    bare = re.search(r"\b(\d{6})\b", body)
    if bare:
        return bare.group(1)
    raise ValueError("No 6-digit code found in message body")


def delete_inbox(inbox_id: str) -> None:
    """Delete the inbox and its mail once the test is done."""
    res = requests.delete(f"{API}/inboxes/{inbox_id}", headers=HEADERS)
    # 204 on success; 404 means it was already gone — both are fine in teardown.
    if res.status_code not in (204, 404):
        res.raise_for_status()

Now the spec. Two pytest fixtures do the setup and teardown: one yields a headless Chrome driver, the other creates an inbox before the test and deletes it after. Because both are function-scoped, every test gets a fresh browser and a fresh mailbox — which is exactly what keeps parallel runs from colliding.

# test_signup_verification.py
import re
import pytest
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

from mailbox_api import create_inbox, wait_for_message, extract_code, delete_inbox

BASE_URL = "http://localhost:3000"


@pytest.fixture
def driver():
    options = webdriver.ChromeOptions()
    options.add_argument("--headless=new")
    d = webdriver.Chrome(options=options)
    yield d
    d.quit()


@pytest.fixture
def inbox():
    created = create_inbox()
    yield created
    delete_inbox(created["id"])  # runs even if the test body fails


def test_user_can_sign_up_and_confirm_email(driver, inbox):
    wait = WebDriverWait(driver, 10)

    # 1. Drive the real signup form with the disposable address.
    driver.get(f"{BASE_URL}/signup")
    driver.find_element(By.NAME, "email").send_keys(inbox["address"])
    driver.find_element(By.NAME, "password").send_keys("Sufficiently-Long-Pw-9!")
    driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
    wait.until(EC.visibility_of_element_located(
        (By.XPATH, "//*[contains(text(), 'Check your email')]")))

    # 2. Wait for the verification email and pull out the OTP (plain Python).
    message = wait_for_message(
        inbox["id"],
        lambda subject: re.search(r"verify|confirm|code", subject, re.IGNORECASE),
        timeout=30,
        interval=2,
    )
    code = extract_code(message)
    assert re.fullmatch(r"\d{6}", code)

    # 3. Complete verification in the browser and assert the result.
    driver.find_element(By.NAME, "verificationCode").send_keys(code)
    driver.find_element(By.XPATH, "//button[contains(., 'Verify')]").click()
    wait.until(EC.url_contains("/dashboard"))
    assert "Email verified" in driver.page_source

That is the whole flow: a real inbox, a real email, a real code typed into a real browser, asserted end to end. No mock, no shared mailbox, no human copying a code out of Gmail.

The same poll, in Java

Java is the other half of Selenium's audience, and the shape is identical — only the syntax changes. Here is waitForMessage on the JDK's built-in java.net.http client, so there is no extra HTTP dependency to pull in. Jackson (ObjectMapper MAPPER) parses the responses.

// The mail poll in Java. JDK 11+ for java.net.http; Jackson for JSON.
static JsonNode waitForMessage(String inboxId, Pattern subjectRe, Duration timeout)
        throws IOException, InterruptedException {
    HttpClient http = HttpClient.newHttpClient();
    String key = System.getenv("XTM_KEY");        // Premium API key, xtm_-prefixed
    long deadline = System.nanoTime() + timeout.toNanos();

    while (System.nanoTime() < deadline) {
        HttpRequest list = HttpRequest.newBuilder()
            .uri(URI.create(API + "/inboxes/" + inboxId + "/messages"))
            .header("Authorization", "Bearer " + key)
            .build();
        HttpResponse<String> res = http.send(list, HttpResponse.BodyHandlers.ofString());
        for (JsonNode m : MAPPER.readTree(res.body()).get("messages")) {
            if (subjectRe.matcher(m.get("subject").asText()).find()) {
                // The list endpoint returns summaries only — fetch the full body.
                HttpRequest get = HttpRequest.newBuilder()
                    .uri(URI.create(API + "/messages/" + m.get("id").asText()))
                    .header("Authorization", "Bearer " + key)
                    .build();
                return MAPPER.readTree(
                    http.send(get, HttpResponse.BodyHandlers.ofString()).body());
            }
        }
        Thread.sleep(2000);
    }
    throw new AssertionError("No matching message in inbox " + inboxId);
}

The create, extractCode, and delete helpers translate the same way. Drive the browser with Selenium's Java bindings, create the inbox in a JUnit @BeforeEach, delete it in @AfterEach, and call waitForMessage between the form submit and the code entry — the structure matches the Python spec above one for one.

Hardening against flaky email tests

The example passes on a good day. Staying green for a year is in the edges.

Set the timeout against real mail latency, not a guess. Verification mail usually lands in a second or two, but a cold sender, a queued job, or a busy CI runner can push it past ten. A 30-second timeout gives comfortable headroom; a five-second one turns a slightly slow delivery into a red build. Measure your own p99 delivery time and set the bound above it, not at it.

One inbox per test, via a function-scoped fixture. The inbox fixture is function-scoped, so every test gets a unique address. Never promote it to scope="module" or scope="session" to "save an API call" — you would hand one inbox to many tests and reintroduce exactly the race that disposable inboxes exist to kill. It stays green locally and turns red the instant you run pytest -n auto across workers.

Let fixture teardown delete the inbox, even on failure. The code after yield in the inbox fixture runs whether the test passes or throws, so delete_inbox always fires and frees your account's storage quota. The helper treats 404 as success — if a test failed before the inbox was fully set up, the delete is a no-op and teardown never throws over it.

Don't sprinkle time.sleep through the browser steps. A fixed sleep is either too short (flaky) or too long (slow), and it is the most common cause of a Selenium suite that drifts red over time. Use WebDriverWait with explicit conditions for the confirmation banner and the redirect, as the spec does, and reserve time.sleep for the one place it belongs: the interval between mail polls, where you genuinely want to wait a fixed beat before asking the API again.

Anchor the OTP regex. \b\d{6}\b matches a copyright year, an order number, or a support line before it matches the real code. extract_code anchors to code(?:\s+is)?[:\s]+ first and only falls back to a bare six-digit match — keep that ordering.

Respect the rate limit. The key is capped at 300 requests / 60s. A two-second poll interval is well within budget, but a tight loop across many parallel runs can trip 429 rate_limited — keep the interval at seconds, not milliseconds. The temp mail API automation guide covers the polling-versus-webhook trade-off in more depth.

Running it in CI

Keep the API key in a CI secret and expose it to the job as XTM_KEY so the helper reads it from os.environ. On GitHub Actions, ubuntu-latest already ships Chrome, and Selenium Manager (bundled since Selenium 4.6) resolves the matching chromedriver at runtime, so a headless run needs no browser-install step:

# .github/workflows/e2e.yml
name: e2e
on: [push]
jobs:
  selenium:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -r requirements.txt   # selenium, pytest, requests
      - run: npm start &                        # start the app under test
      - run: npx wait-on http://localhost:3000  # let it come up first
      - run: pytest
        env:
          XTM_KEY: ${{ secrets.XTM_KEY }}

That is the short version. For the fuller CI story — parallelizing across workers, caching, retries, and artifacts — see the companion guide on email testing in CI, which applies to any runner, Selenium included.

Common mistakes

An honest caveat

Two things worth being straight about. First, free inboxes expire — the default lifetime is a day, plenty for a test that runs and asserts in seconds, but it means you should never hardcode an address and expect it to survive between runs. Create a fresh one each time; that is the pattern here anyway. Second, some staging environments block known disposable domains at signup, which is the whole point of that defense — and it means your own app may reject a free TempMaily address before the flow even starts. If that is happening, it is working as designed; we wrote up the mechanics in why websites block temp mail. For tests, the fix is a Premium dedicated or custom domain your app treats as legitimate, so the verification path behaves exactly as it would for a real user.

And on architecture: webhooks exist for event-driven flows, and TempMaily can POST each message to a signed endpoint as it arrives. But a Selenium test is already blocking and waiting for a result, so polling in a plain loop is the simpler shape — reach for webhooks when there is no test loop to hold open. The API reference covers webhook registration and signature verification when you get there.

Next steps

Pairing Selenium with a disposable-mail API turns email verification from the flakiest step in your suite into one more assertion. Create an isolated inbox per test with a fixture, drive the real form, poll for the message in plain Python or Java, extract the OTP with a scoped regex, verify in the browser, and tear the inbox down. Working in a different stack? The Cypress and Playwright guides do the same thing with those runners' idioms.

Programmatic access is part of TempMaily Premium, and the API reference is the source of truth for every endpoint, field, and limit used above. New to disposable inboxes? Start from the homepage, then wire the helper into your Selenium project and delete the "we skip email in CI" line from your test plan.

Frequently asked questions

How do I test email verification with Selenium?

Selenium drives the browser, so it fills and submits your signup form, but it cannot read the inbox your app emails a code to. Bridge the gap with a disposable-mail API: create a real inbox before the test, use its address in the form, then poll the API for the verification message, extract the code, type it back into the browser, and assert the account reaches a verified state. Because Selenium is just a WebDriver client, the polling is ordinary Python or Java — a plain loop with a deadline, no framework plumbing. That keeps the whole flow real: a real inbox, a real email, a real code.

Can Selenium read emails from an inbox?

No. Selenium automates a browser through WebDriver and has no access to a mail server, IMAP, or a mailbox. The verification email lives entirely outside the page Selenium is looking at. To read it you call a mail API from inside the test — a normal HTTP request with the requests library in Python or java.net.http in Java — that returns the message your app just sent. Selenium owns the browser half of the flow; the mail API owns the email half.

How do I extract an OTP code from an email in Selenium?

Fetch the full message from the API, then run a scoped regular expression over its bodyText, falling back to bodyHtml. For a six-digit OTP, anchor the pattern to nearby copy such as 'your code is' rather than matching any six digits, otherwise a year or a support-line number in the footer can match first. Return the captured group and type it into the verification field with send_keys. Keep the extraction in a small helper so the test body stays readable.

Should I use WebDriverWait to poll for the email?

No — WebDriverWait is built to poll the DOM, and it passes the driver to your condition on every attempt, so bending it into an HTTP poller reads awkwardly. Use a plain loop with a deadline for the mail wait, since no browser is involved in checking the inbox. Keep WebDriverWait for what it is good at: waiting on the signup confirmation and the post-verification redirect in the browser. The two waits do different jobs and should stay separate.

How do I stop Selenium email tests from being flaky?

Give every test its own inbox with a pytest fixture so parallel runs never cross wires, bound the poll with a timeout that comfortably exceeds real mail latency, scope the code regex tightly, and tear the inbox down in fixture teardown treating 404 as success. Load the API key from a CI secret, never a committed file, and use a domain your app accepts so its own validation does not reject the address. Rely on explicit waits rather than time.sleep for the browser steps so a slow render does not fail a passing flow.

Can I run Selenium email tests in CI?

Yes. Store your API key as a CI secret and expose it to the job as XTM_KEY so the helper reads it from the environment. On GitHub Actions, ubuntu-latest ships Chrome and Selenium Manager resolves the matching driver automatically, so a headless Chrome run needs no extra setup. Start your app, wait for it to listen, then run pytest with the secret injected. The mail API calls work the same in CI as locally.

Get a free disposable inbox

A live throwaway address, no signup, real-time delivery. Upgrade to Premium for custom domains, forwarding, and no expiry.