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
- Polling the inbox with
WebDriverWait. It is built for the DOM and passes the driver to your condition. Use a plain deadline loop for the mail wait; keepWebDriverWaitfor the browser steps. - Naming the helper
mailbox.py. Python's standard library owns that name — the import will shadow the stdlibmailboxmodule. Usemailbox_api.pyor similar. - Matching any six digits.
\b\d{6}\bmatches a year or an order number before the code. Anchor to the surrounding copy, asextract_codedoes. - Reading the wrong field. The full-message response exposes
bodyTextandbodyHtml— nottext/html. PreferbodyText; some senders only fill the HTML part, so fall back tobodyHtml. - Extracting from the list endpoint.
GET /inboxes/{id}/messagesreturns summaries with asnippet, no body. You must fetchGET /messages/{id}forbodyText/bodyHtmlfirst —wait_for_messagedoes that second call for you. - A session-scoped inbox fixture. Fine until the first parallel run, then it is the flakiest thing you own. Keep it function-scoped.
- Fixed
time.sleepfor the browser. Either flaky or slow. Explicit waits for the DOM; a fixed sleep only between mail polls.
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.