Selenium email testing, without the sleeps.
Selenium gives you explicit waits for the DOM — WebDriverWait, expected conditions — and nothing at all for what happens outside the browser. An email is the definition of outside the browser. After this guide, your Selenium WebDriver tests can receive a real email — signup OTP, verification link, password reset — and assert on it: in Python with a first-class SDK, or in Java, C#, or Ruby with four plain REST calls.
Quick answer. Give every test its own real inbox from an inbox API, drive the browser to trigger the email at that address, then make one long-poll request that the server holds open until the message arrives — and read the OTP or verification link from the extracted fields, not from a regex over raw HTML. No Thread.sleep, no IMAP session against a shared Gmail account, no polling loop to tune.
One honesty note before anything else, because most Selenium content assumes Java and then hand-waves the email part: MailFixture ships SDKs for Python and JS/TS only. If your Selenium suite is Python, you get a typed client and the whole email flow is three lines. If it's Java, C#, or Ruby, you get a small, stable REST API and your language's standard HTTP client — shown plainly below, not disguised as an SDK we don't have. The REST surface is deliberately small enough that this is a fair deal, not a consolation prize.
Why email breaks Selenium suites
Suites improvise around the email gap, and the improvisations fail in three recognizable ways.
1. time.sleep(10) / Thread.sleep(10_000) after the submit click. Delivery latency is a distribution, not a constant. The sleep that passed all week is too short the day your email provider has a slow hour, and pure wasted wall-clock time in the other ninety-five percent of runs. Multiply by every email-touching test and the suite is both slow and flaky — the worst quadrant.
2. One shared mailbox for the whole suite. A team Gmail account read over IMAP, or one address on a catch-all domain. Every test now matches against every message every other test triggered. The suite passes in one run order and fails in another; run it on a Selenium Grid with four parallel nodes and tests start reading each other's OTPs. It looks like flakiness. It's shared mutable state — the exact thing you'd never accept anywhere else in the suite.
3. Regex over raw HTML to find "the link". href="([^"]*)" finds a link, all right: the logo link, the unsubscribe link, or the open-tracking pixel, depending on which template version shipped last sprint. The test breaks on every copy change, and the failure mode is silent — it clicks the wrong URL and fails three steps later with an assertion that names the wrong problem.
The fix for all three is structural, not clever: an inbox per test, created through an API so it exists the moment you need it; a server-held long-poll instead of any sleep or retry loop; and typed extraction — the OTP and the classified links are fields on the message, computed server-side, so your test asserts on otp and links, not on markup.
Where this fits — and where it doesn't
Be honest about layers before reaching for any tool:
- Unit tests of your mail-sending code should mock the provider client. No browser, no network, no inbox API. Milliseconds.
- Local development, watching templates render while you build: a local SMTP-capture tool (Mailpit and friends) is the right tool — zero setup, zero cost, everything stays on your machine.
- End-to-end suites in CI — which is where Selenium lives — need the email to travel the real path: your app, your actual email provider, real DNS, a real receiving MX. That's the only layer where a test catches the staging config still pointing at the provider's sandbox, or the template that renders fine locally but ships with a broken verification URL. This is the layer an inbox API covers, and the layer this guide is about.
If your suite never triggers real email delivery — everything is mocked at the provider boundary — you don't need any of this. The moment a Selenium test clicks "Sign up" against a staging environment that really sends, you do. The strategy guide maps the three layers in full.
Python: Selenium + the SDK
The SDK is stdlib-only (mailfixture on PyPI, v0.5.x, Python 3.9+, zero dependencies) and synchronous, so it drops into a Selenium suite with nothing to configure. Everything below assumes pytest, because that's where most selenium-python suites live; the same calls work under unittest or plain scripts.
The client reads MAILFIXTURE_API_KEY from the environment — keep the key in CI secrets, never in the repo. The conftest carries the two fixtures from the pytest guide — a session-scoped client (stateless, free to share) and a function-scoped inbox (the test's state — never widen its scope) — plus a driver fixture. If you already have the first two, reuse them unchanged:
import pytest from selenium import webdriver from mailfixture import MailFixture @pytest.fixture(scope="session") def mail(): return MailFixture() # reads MAILFIXTURE_API_KEY @pytest.fixture def inbox(mail): inbox = mail.create_inbox(ttl_seconds=900) yield inbox inbox.delete() # runs pass or fail; TTL covers killed runs @pytest.fixture def driver(): driver = webdriver.Chrome() yield driver driver.quit()
The test drives the browser to trigger the email, makes one call that returns when the email lands, and types the code back into the page:
from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC APP = "https://staging.example.com" def test_signup_sends_otp(driver, inbox): driver.get(f"{APP}/signup") driver.find_element(By.NAME, "email").send_keys(inbox.address) driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click() otp = inbox.wait_for_otp(timeout=30) # seconds — held open server-side driver.find_element(By.NAME, "code").send_keys(otp) driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click() WebDriverWait(driver, 10).until(EC.url_contains("/welcome"))
wait_for_otp long-polls: the server holds the request open and answers the moment a message with an extractable code arrives, and raises MailFixtureTimeout only when the deadline is exhausted. The timeout is a ceiling, not a duration.
Two details worth engraving:
- Python timeouts are seconds.
timeout=30is thirty seconds. The JavaScript idiomtimeout=30_000transplanted into Python is eight hours, and nothing catches it by default because pytest imposes no per-test time limit — the CI job dies at its own ceiling with the suite mid-wait. - If several emails race into one inbox (resend flows, welcome + OTP together), filter the wait:
inbox.wait_for_otp(match="subject:code", timeout=30).matchtakessubject:,from:, andto:prefixes; a bare term matches the subject.
Verification and magic links ride the same fixture. The server classifies every extracted link — verify, reset, unsubscribe, other — so you ask for the kind you mean instead of scraping hrefs:
def test_signup_sends_verify_link(driver, inbox): driver.get(f"{APP}/signup") driver.find_element(By.NAME, "email").send_keys(inbox.address) driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click() link = inbox.wait_for_link(kind="verify", timeout=30) driver.get(link.url) # the browser clicks the real link WebDriverWait(driver, 10).until(EC.url_contains("/verified"))
That's the whole Python story. Because each test owns its inbox, running the suite across a Selenium Grid or with pytest -n auto needs no coordination — no shared mailbox, no locks, no cross-node collisions.
Java, C#, Ruby: the REST pattern
No SDK exists for these languages, and this guide won't pretend otherwise. What exists instead is a REST API small enough to wrap in an afternoon: bearer auth, JSON in and out, RFC 7807 errors. The email loop is four requests. Here it is in curl, which translates line for line to java.net.http.HttpClient, .NET's HttpClient, or Ruby's Net::HTTP:
# 1. create an inbox (before the test navigates anywhere) 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}' # 201 → {"id": "9c2e…", "email_address": "k3v9pw2q1d@…", …} # 2. drive Selenium to sign up with that email_address, then long-poll: # the server holds this request open until a message arrives (wait ≤ 60s) curl -s "https://api.mailfixture.com/v1/inboxes/$INBOX_ID/messages?wait=45" \ -H "Authorization: Bearer $MAILFIXTURE_API_KEY" # → {"messages": [{"id": "a1f4…", "subject": "Your code", …}]} # 3. read the extracted OTP (or /links for classified links) curl -s "https://api.mailfixture.com/v1/messages/$MSG_ID/otp" \ -H "Authorization: Bearer $MAILFIXTURE_API_KEY" # → {"best": "482913", "candidates": […]} # 4. teardown curl -s -X DELETE "https://api.mailfixture.com/v1/inboxes/$INBOX_ID" \ -H "Authorization: Bearer $MAILFIXTURE_API_KEY"
The long-poll in step 2 is the piece that kills the sleeps: instead of a client-side retry loop, one request waits server-side and returns the instant the message lands. In Java, that's an ordinary blocking call — just make sure the HTTP client's timeout exceeds the wait value, or the client will kill a perfectly healthy long-poll:
HttpClient http = HttpClient.newHttpClient(); String key = System.getenv("MAILFIXTURE_API_KEY"); HttpRequest poll = HttpRequest.newBuilder() .uri(URI.create("https://api.mailfixture.com/v1/inboxes/" + inboxId + "/messages?wait=45")) .header("Authorization", "Bearer " + key) .timeout(Duration.ofSeconds(60)) // must exceed wait=45 .build(); HttpResponse<String> res = http.send(poll, HttpResponse.BodyHandlers.ofString());
Three rules keep a hand-rolled wrapper honest — they're exactly what the Python SDK does internally:
waitis clamped to 60 seconds server-side. For a longer deadline, chain requests: pass the last summary'sreceived_atback assinceon the next call, so you never re-read a message you already handled.- Filter with
match(subject:,from:,to:prefixes; e.g.match=subject:code) when more than one email can land during the wait. - Respect 429s. Rate limits are per key (2 requests/second sustained, bursting to 60); a 429 carries a
Retry-Afterheader. If you usewait=, you'll essentially never see one — a held request is one request. Tight hand-rolled polling loops are how you meet the limiter.
If your Selenium tests run under JUnit or NUnit, the create/delete calls belong in @BeforeEach/@AfterEach (or their equivalents) — the same inbox-per-test shape as the pytest fixture, in your runner's native idiom.
Failure modes
wait=; chain with sincetimeout=30_000 in Python (seconds, not ms)Job killed externally; no MailFixtureTimeout raisedtimeout=30; add pytest-timeout set above the SDK deadlinewait to 60Response arrives at exactly 60s with {"messages": []}Chain requests under your own deadline, cursor via sincewait valueTimeout exception at N seconds while wait > NSet the client timeout above wait (the SDKs use wait + 15)kind="verify" in the SDK, class on the wire — never regexmatch= and since when several emails raceEdge cases
Grid parallelism. Inbox-per-test is what makes parallel Selenium safe. Each node's tests mint their own addresses through the API; there is nothing shared to lock and no coordination protocol. If you're currently serializing email tests to protect a shared mailbox, this deletes that constraint.
Teardown vs killed runs. Delete the inbox in teardown, and set a ttl_seconds anyway (60 to 2,592,000; the examples use 900). Teardown handles pass and fail; the TTL handles the run your CI provider killed mid-flight. Expired inboxes stop receiving immediately.
Negative expectations are bounded waits. "Unsubscribed users get no email" is not a sleep plus an empty-list assert. In Python it's with pytest.raises(MailFixtureTimeout): inbox.wait_for_message(timeout=10); over REST it's a wait=10 poll asserting the empty list. Be honest about what it proves — no email within ten seconds, not never — and keep the bound tight.
Secrets. The API key rides in the Authorization header from an environment variable or CI secret. Never bake it into the Selenium project, and never pass it through browser-visible state — it's a server credential, and the browser under test has no business seeing it.
Retention. Received messages expire on a plan-based schedule (days, not forever). Tests read a message seconds after it arrives, so this never bites a suite — but don't design a workflow that expects to fetch last month's test email.
What this does not prove
- Receive-only. MailFixture receives email; it never sends any. Your app, through your real provider, does the sending — which is precisely why the test is meaningful.
- Content is not rendering. Asserting on the OTP, links, and bodies proves the email's content and behavior, not how Outlook or Gmail will visually render it. No screenshots-across-clients claims here.
- Arrival at a test inbox is not inbox placement. A green test proves delivery, extraction, and a working link — not that Gmail would have put the message in the primary tab.
- SDK coverage is Python and JS/TS. Java, C#, and Ruby use REST. The surface is small and stable, but you own the wrapper.
Next steps
- The four-minute version of the Python setup: the pytest quickstart.
- Why OTP tests flake everywhere, not just in Selenium — the anatomy in full: the OTP guide.
- Verification links, single-use and expiry edge cases, scanner prefetch: the email-verification guide.
- If parts of your suite are moving off Selenium: the Playwright guide.