Selenium drives a browser and nothing else. When the app under test emails a verification code, the code has to come from somewhere WebDriver cannot see, and a common answer is a shared mailbox read over IMAP with a time.sleep() in front of it. This post replaces that with three HTTP calls to the Lumbox API, in Python with pytest and in Java, and then looks at what the code extraction actually returns so your assertions check the right thing.

Python: fixtures and one helper

The client below uses httpx, but any HTTP library works. Every call carries the X-API-Key header, and the long client timeout exists because one of the calls is designed to wait.

# conftest.py
import os
import secrets

import httpx
import pytest


@pytest.fixture(scope="session")
def lumbox():
    headers = {"X-API-Key": os.environ["LUMBOX_API_KEY"]}
    with httpx.Client(base_url="https://api.lumbox.co", headers=headers, timeout=130) as client:
        yield client


@pytest.fixture
def inbox(lumbox):
    r = lumbox.post("/v1/inboxes", json={"name": f"sel-{secrets.token_hex(4)}"})
    r.raise_for_status()
    created = r.json()
    yield created
    lumbox.delete(f"/v1/inboxes/{created['id']}")
# lumbox_helpers.py
def wait_for_code_email(lumbox, inbox_id, since, seconds=60):
    r = lumbox.get(
        f"/v1/inboxes/{inbox_id}/wait",
        params={"has_otp": "true", "since": since.isoformat(), "timeout": seconds},
    )
    if r.status_code == 408:
        raise AssertionError(f"no email with a code within {seconds}s")
    r.raise_for_status()
    return r.json()

POST /v1/inboxes returns the new inbox's id and address, which looks like sel-3fa91c2e@trylumbox.com because the name becomes the local part. GET /v1/inboxes/:id/wait is a long-poll. The server checks the inbox once a second and responds with the full email as soon as a message received at or after since has at least one extracted code, or returns HTTP 408 when timeout runs out. The maximum is 120 seconds.

The test

# test_signup.py
from datetime import datetime, timedelta, timezone

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

from lumbox_helpers import wait_for_code_email


def test_signup_with_emailed_code(lumbox, inbox):
    driver = webdriver.Chrome()
    try:
        driver.get("https://staging.example.com/signup")
        driver.find_element(By.NAME, "email").send_keys(inbox["address"])
        driver.find_element(By.NAME, "password").send_keys("correct-horse-battery-7")

        since = datetime.now(timezone.utc) - timedelta(seconds=5)
        driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()

        email = wait_for_code_email(lumbox, inbox["id"], since)
        codes = email["parsed"]["otp_codes"]
        assert len(codes) == 1, f"expected one code, parser found {codes}"

        field = WebDriverWait(driver, 10).until(
            EC.visibility_of_element_located((By.NAME, "otp"))
        )
        field.send_keys(codes[0])
        driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
        WebDriverWait(driver, 10).until(EC.url_contains("/dashboard"))
    finally:
        driver.quit()

There are two waits in this test and they do different jobs. WebDriverWait polls the page until the OTP field is visible, which is Selenium's explicit wait. The Lumbox call blocks on the server until the email exists. Neither one sleeps for a fixed time, so the test takes as long as the mail takes and no longer.

The timestamp is captured before the click on purpose. Without since, /wait only considers mail that arrives after the request reaches the server, and if the code email lands while the test is still clicking, the wait misses it and runs to the timeout. Subtracting five seconds covers clock drift between the test machine and the API server. The inbox is new, so the wider window cannot pick up anything unrelated.

Java

The same three calls with java.net.http.HttpClient from the JDK and Jackson 2.x for JSON:

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;

public class Lumbox {
  private static final String API = "https://api.lumbox.co/v1";
  private final HttpClient http = HttpClient.newHttpClient();
  private final ObjectMapper json = new ObjectMapper();
  private final String key = System.getenv("LUMBOX_API_KEY");

  private JsonNode send(HttpRequest.Builder b, int expected) throws Exception {
    HttpResponse<String> res =
        http.send(b.header("X-API-Key", key).build(), HttpResponse.BodyHandlers.ofString());
    if (res.statusCode() == 408) throw new AssertionError("no email with a code before timeout");
    if (res.statusCode() != expected)
      throw new IllegalStateException(res.statusCode() + ": " + res.body());
    return json.readTree(res.body());
  }

  public JsonNode createInbox(String name) throws Exception {
    return send(HttpRequest.newBuilder(URI.create(API + "/inboxes"))
        .header("Content-Type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString("{\"name\":\"" + name + "\"}")), 201);
  }

  public JsonNode waitForCodeEmail(String inboxId, Instant since, int seconds) throws Exception {
    String q = "has_otp=true&timeout=" + seconds + "&since="
        + URLEncoder.encode(since.toString(), StandardCharsets.UTF_8);
    return send(HttpRequest.newBuilder(URI.create(API + "/inboxes/" + inboxId + "/wait?" + q))
        .timeout(Duration.ofSeconds(seconds + 10)).GET(), 200);
  }

  public void deleteInbox(String inboxId) throws Exception {
    send(HttpRequest.newBuilder(URI.create(API + "/inboxes/" + inboxId)).DELETE(), 200);
  }
}
Instant since = Instant.now().minusSeconds(5);
driver.findElement(By.cssSelector("button[type=submit]")).click();

JsonNode email = lumbox.waitForCodeEmail(inbox.get("id").textValue(), since, 60);
String code = email.get("parsed").get("otp_codes").get(0).textValue();

new WebDriverWait(driver, Duration.ofSeconds(10))
    .until(ExpectedConditions.visibilityOfElementLocated(By.name("otp")))
    .sendKeys(code);

Put createInbox in a @BeforeEach and deleteInbox in an @AfterEach so every test method gets a fresh address. HttpRequest.Builder.timeout makes send throw HttpTimeoutException if the response never comes, which keeps a network problem from hanging the suite.

What the extraction gives you

The email object from /wait has a parsed block: otp_codes, verification_links, magic_links, code_expiry (set when the text says something like "expires in 10 minutes"), and a keyword-based category. Codes are found with six regular expressions run over the subject and plain-text body. They match 4 to 8 digit numbers next to phrases like "verification code is", "your code:", "enter 123456 to", and a number alone on its own line. Four-digit numbers that look like years (19xx, 20xx) are dropped.

That last pattern is why the Python test asserts exactly one code. An order number or ZIP code sitting on a line by itself also matches, and otp_codes is ordered by which pattern matched first, not by where the number appears in the message. If a template change adds a second number, a codes[0] test might type the wrong one and fail with a confusing "invalid code" from your own app. The length assertion fails first and prints what the parser saw. Codes containing letters, or digits split by a space or hyphen, are not extracted at all. For those, read text_body from the same response and apply a pattern written for your template. The OTP extraction post lists every pattern and the phrasings that slip past them.

Asserting that no email arrives

Some tests need the opposite result. Signing up again with an address that is already registered should not send a second code, and a resend button that is rate limited should not send anything. The same endpoint covers both: call /wait without has_otp, give it a short timeout, and assert on the 408 instead of raising on it. The server only answers 408 after checking once a second for the whole period, so a 15-second window is fifteen looks at the inbox.

r = lumbox.get(
    f"/v1/inboxes/{inbox['id']}/wait",
    params={"since": since.isoformat(), "timeout": 15},
)
assert r.status_code == 408, f"unexpected email: {r.json()['subject']}"

Set the window longer than your app's usual delivery time. A message that arrives on second 16 passes this test.

Running many of these

With pytest-xdist or parallel JUnit, each running test holds one inbox. Lumbox caps live inboxes per organization by plan (3 on Free, 10 on Starter, 50 on Pro, 250 on Scale) and answers 402 past the cap, so keep parallelism under it. Both examples read the key from the LUMBOX_API_KEY environment variable, so in CI it comes from your secret store and never lands in the repository. On Selenium Grid, the remote session sits idle while the email wait runs, so a wait close to the 120-second maximum should be checked against your Grid's session timeout. For why a server-side wait beats a polling loop in the first place, see long-polling for email.

The email object reference documents every parsed field, and the quickstart gets you a key in one command.