Guides / Selenium + Python email OTP

Reading an emailed OTP in Selenium with Python

Tested 13 Sep 2026 · Selenium 4.49 · pytest 9.1 · Python 3.13

The usual ways to get an emailed code into a Selenium test are an IMAP login to a real mailbox, or a time.sleep loop that checks until something shows up. Both need credentials for a real account, and both break once tests run in parallel against the same mailbox.

There is no Python client for inboxsink, and you don't need one: the API is plain HTTP, and the helpers below are about thirty lines of requests. Everything on this page ran on 13 September 2026 against a small signup app sending real email through Mailjet.

What the run looked like

tests/test_signup.py::test_signup_with_emailed_code otp e640c1e74fb2@mailhusk.com code=229076 after 1799 ms
PASSED
tests/test_signup.py::test_sign_in_with_magic_link magic link http://localhost:4175/magic-link?token=578e5fa9ff1ba3528e6511e2ceb28fa4
PASSED
============================== 2 passed in 11.99s ==============================

Output of pytest -s -v, trimmed to the test lines. The code was in hand 1.8 seconds after the click. Most of the 12 seconds is headless Chrome starting twice.

Setup

  1. pip install selenium pytest requests. Selenium Manager downloads a matching ChromeDriver on first run; we installed nothing else.
  2. Create a free account and an API key (1,000 calls a month, no card).
  3. export INBOXSINK_API_KEY=ibsk_…

Three helpers

import os
import requests

API = "https://inboxsink.com/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['INBOXSINK_API_KEY']}"}


def create_inbox(ttl_seconds=900):
    r = requests.post(f"{API}/inboxes", json={"ttl_seconds": ttl_seconds}, headers=HEADERS, timeout=10)
    r.raise_for_status()
    return r.json()


def wait_for_message(inbox_id, timeout_ms=60_000):
    # the HTTP timeout must be LONGER than the wait, or requests gives up first
    r = requests.get(
        f"{API}/inboxes/{inbox_id}/wait",
        params={"timeout": timeout_ms},
        headers=HEADERS,
        timeout=timeout_ms / 1000 + 10,
    )
    if r.status_code == 204:
        raise TimeoutError(f"no email arrived within {timeout_ms} ms")
    r.raise_for_status()
    return r.json()["message"]


def wait_for_otp(inbox_id, timeout_ms=60_000):
    message = wait_for_message(inbox_id, timeout_ms)
    if not message["otp"]:
        raise AssertionError(f'email "{message["subject"]}" arrived without a verification code')
    return message["otp"]

wait_for_message is a single request that the server holds open until the email is stored, for up to 120 seconds. A 204 means the delay ran out with nothing received; the helper turns it into an exception so the test fails with a sentence you can act on.

The requests timeout trap

Linters ask for a timeout on every requests call (Bandit flags calls without one as B113), and 10 seconds is the value most people type. On the wait call, that cuts the request long before the email has a chance to arrive. We ran it with timeout=10, a 60-second wait and no email sent:

ReadTimeout after 10.5 s

A ReadTimeout reads like a network problem, so you go and check the wrong thing. Derive the HTTP timeout from the wait, as the helper does: timeout_ms / 1000 + 10.

Fixtures and the test

import pytest
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait

BASE_URL = "http://localhost:4175"


@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():
    box = create_inbox()
    yield box
    requests.delete(f"{API}/inboxes/{box['id']}", headers=HEADERS, timeout=10)


def test_signup_with_emailed_code(driver, inbox):
    driver.get(f"{BASE_URL}/signup")
    driver.find_element(By.NAME, "email").send_keys(inbox["address"])
    driver.find_element(By.XPATH, "//button[text()='Create account']").click()

    code = wait_for_otp(inbox["id"])

    driver.find_element(By.NAME, "code").send_keys(code)
    driver.find_element(By.XPATH, "//button[text()='Verify']").click()
    WebDriverWait(driver, 10).until(
        EC.text_to_be_present_in_element((By.TAG_NAME, "h1"), "Welcome aboard")
    )
def test_sign_in_with_magic_link(driver, inbox):
    driver.get(f"{BASE_URL}/login")
    driver.find_element(By.NAME, "email").send_keys(inbox["address"])
    driver.find_element(By.XPATH, "//button[text()='Email me a sign-in link']").click()

    message = wait_for_message(inbox["id"])
    assert message["link"], f'email "{message["subject"]}" has no detectable link'

    driver.get(message["link"])
    WebDriverWait(driver, 10).until(
        EC.text_to_be_present_in_element((By.TAG_NAME, "h1"), f"Signed in as {inbox['address']}")
    )

link is only filled when the URL contains a word like confirm, verify, reset, magic or login. Otherwise it is None: fetch the full message with GET /v1/messages/{id} and search its text field with re.search(r"https?://\S+", text). The detection rules for codes and links are detailed in the Playwright guide.

Free API key

1,000 calls a month, no card asked. Enough to wire it into your test suite and see if it holds.

Then, when your tests start getting refused: a domain of your own is published nowhere, so it lands on no blocklist — 29 € a month.

Other guides