Volver al blog
·9 min de lectura

Temporary Email for Selenium Automation

How to use a programmable temporary email API with Selenium to test signup flows, OTP emails, and password resets reliably in CI/CD pipelines.

Selenium Testing Python Automation Email QA CI/CD

Temporary Email for Selenium Automation

Every Selenium test that touches a signup form eventually hits the same wall: after the form is submitted, the app sends a confirmation email, and the test has no way to read it. The browser is waiting. Your test has no inbox. The flow is stuck.

This is not an edge case. Registration, password reset, magic link login, and OTP verification are among the most common flows in any web application — and among the most routinely skipped in automated test suites. This article explains why email testing breaks in Selenium, what the common workarounds cost you, and how to replace them with a programmable temporary inbox API that makes the entire flow testable in under 50 lines of Python.


Why Email Testing Breaks in Selenium Automation

Selenium gives you a browser. It can fill forms, click buttons, and assert on page content. What it cannot do is open a mail client, find an email, and extract a link from it. That gap has to be filled externally — and the common solutions all introduce friction that eventually breaks pipelines or degrades test quality.

Hardcoded inboxes. Teams often maintain a qa@company.com address and configure their staging environment to send all test emails there. This works until you run tests in parallel. Two jobs submit the signup form at the same time, and both wait for confirmation. The inbox receives both emails. Each job picks up the wrong one. Intermittent failures start appearing on Fridays and nobody can reproduce them locally.

Gmail test accounts. Some teams provision a real Gmail account and use IMAP to read emails in tests. Gmail IMAP requires OAuth2 tokens, app passwords, or "less secure app" settings — all of which expire, get blocked by workspace policies, or break when Google changes their authentication requirements. IMAP polling adds 5–30 seconds of latency. You also end up parsing raw RFC 2822 MIME messages, which is not what you want inside a Selenium test.

Mocked email systems. Mocking sendgrid.send() or patching the email provider at the service layer tells you that your code called the send function — not that the email is deliverable, not that the template renders correctly, not that the confirmation link is well-formed, not that the OTP matches what the backend expects. The test passes. The production signup flow is broken. You find out from a user.

Staging SMTP with no retrieval API. Some teams configure a local SMTP sink like MailHog or Mailtrap on staging. These work for manual inspection but become awkward in CI. MailHog has no reliable message filtering API. Mailtrap shared inboxes cause the same cross-contamination problem as hardcoded addresses. Per-test inbox isolation on Mailtrap requires a paid plan and SDK integration.

The root problem is the same in every case: the test cannot programmatically create an inbox, use that address during the Selenium session, and then retrieve the resulting email through a clean HTTP interface.


The Better Approach: Temporary Inbox APIs

A programmable temporary email API changes the model. Instead of pre-existing shared inboxes, you create a fresh inbox at the start of each test. That inbox has:

  • A unique @uncorreotemporal.com address ready to receive real SMTP email
  • A session token that authenticates all subsequent operations
  • A REST endpoint to list and read messages
  • A WebSocket stream that emits an event the moment mail arrives
  • An automatic expiration — no cleanup required

The inbox exists for the duration of the test and is gone after. No shared state. No cross-contamination. No authentication overhead beyond a single HTTP call.

This is not a browser-based disposable email service. It is an API-first infrastructure designed for use in code — test fixtures, CI pipelines, and automation scripts. You interact with it entirely over HTTP. Selenium never needs to know it exists.


Example Workflow with Selenium

The pattern is consistent regardless of what the application under test does with the email:

  1. Create an inbox via the API. You get back an address and a session token.
  2. Run the Selenium session, entering the temporary address wherever the signup form asks for email.
  3. Poll or subscribe for incoming messages. Wait up to 30 seconds.
  4. Retrieve the full message body using the message ID returned by the list endpoint.
  5. Extract the verification link or OTP from body_text or body_html using a regex.
  6. Complete the flow — either by navigating Selenium to the confirmation URL or by submitting the OTP in the browser.

Each of these steps maps to one or two HTTP calls. Selenium handles the browser. requests handles the inbox API. They never need to share state beyond the email address string.


Python Example

The following example tests a signup flow end-to-end: creating a real inbox, filling a registration form with Selenium, waiting for the confirmation email, extracting the verification link, and asserting the account is active.

import re
import time
import requests
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.options import Options

BASE_URL = "https://uncorreotemporal.com"
APP_URL = "https://your-app-staging.example.com"


# ── Step 1: Create a temporary inbox ─────────────────────────────────────────

def create_inbox(ttl_minutes: int = 10) -> tuple[str, str]:
    """
    Create a temporary inbox via the API.
    Returns (address, session_token).
    Anonymous use: no API key required.
    """
    resp = requests.post(
        f"{BASE_URL}/api/v1/mailboxes",
        params={"ttl_minutes": ttl_minutes},
    )
    resp.raise_for_status()
    data = resp.json()
    return data["address"], data["session_token"]


# ── Step 2: Poll the inbox for a new message ──────────────────────────────────

def wait_for_message(
    address: str,
    session_token: str,
    timeout: int = 30,
    poll_interval: float = 2.0,
) -> dict:
    """
    Poll the message list until at least one message arrives.
    Returns the first message summary (id, subject, from_address, etc.).
    Raises TimeoutError if nothing arrives within `timeout` seconds.
    """
    headers = {"X-Session-Token": session_token}
    deadline = time.monotonic() + timeout

    while time.monotonic() < deadline:
        resp = requests.get(
            f"{BASE_URL}/api/v1/mailboxes/{address}/messages",
            headers=headers,
        )
        resp.raise_for_status()
        messages = resp.json()
        if messages:
            return messages[0]  # ordered by received_at desc
        time.sleep(poll_interval)

    raise TimeoutError(f"No email received at {address} within {timeout}s")


# ── Step 3: Fetch the full message body ───────────────────────────────────────

def get_full_message(address: str, session_token: str, message_id: str) -> dict:
    """
    Retrieve the full message content including body_text and body_html.
    Automatically marks the message as read.
    """
    headers = {"X-Session-Token": session_token}
    resp = requests.get(
        f"{BASE_URL}/api/v1/mailboxes/{address}/messages/{message_id}",
        headers=headers,
    )
    resp.raise_for_status()
    return resp.json()


# ── Step 4: Extract the confirmation link ────────────────────────────────────

def extract_confirmation_link(body_text: str | None, body_html: str | None) -> str:
    """
    Extract the confirmation URL from the email body.
    Tries body_text first (simpler to parse), falls back to body_html.
    Adjust the pattern to match your application's confirmation URL format.
    """
    pattern = rf"{re.escape(APP_URL)}/confirm\?token=[A-Za-z0-9._-]+"

    for content in [body_text, body_html]:
        if content:
            match = re.search(pattern, content)
            if match:
                return match.group(0)

    raise ValueError("Confirmation link not found in email body")


# ── Main test ─────────────────────────────────────────────────────────────────

def test_signup_and_email_confirmation():
    # 1. Create a fresh, isolated inbox for this test run
    address, session_token = create_inbox(ttl_minutes=10)
    print(f"Test inbox: {address}")

    # 2. Open the browser and fill the signup form
    options = Options()
    options.add_argument("--headless")
    options.add_argument("--no-sandbox")
    options.add_argument("--disable-dev-shm-usage")

    driver = webdriver.Chrome(options=options)
    try:
        driver.get(f"{APP_URL}/signup")

        # Enter the temporary inbox address where the form asks for email
        driver.find_element(By.NAME, "email").send_keys(address)
        driver.find_element(By.NAME, "password").send_keys("TestPassword1!")
        driver.find_element(By.CSS_SELECTOR, "[type=submit]").click()

        # Assert the app shows a "check your email" page
        assert "check your email" in driver.page_source.lower(), \
            "Expected confirmation prompt after signup"

        # 3. Wait for the confirmation email to arrive
        summary = wait_for_message(address, session_token, timeout=30)
        assert "confirm" in summary["subject"].lower(), \
            f"Unexpected subject: {summary['subject']}"

        # 4. Retrieve the full message
        message = get_full_message(address, session_token, summary["id"])

        # 5. Extract the confirmation link from the body
        confirm_url = extract_confirmation_link(
            message["body_text"],
            message["body_html"],
        )

        # 6. Navigate Selenium to the confirmation link
        driver.get(confirm_url)

        # Assert the account is now active
        assert "dashboard" in driver.current_url or \
               "verified" in driver.page_source.lower(), \
               "Email confirmation did not complete successfully"

        print("Test passed.")

    finally:
        driver.quit()


if __name__ == "__main__":
    test_signup_and_email_confirmation()

A few notes on this implementation:

body_text over body_html for extraction. The API returns both fields pre-decoded — multipart MIME, quoted-printable encoding, and base64 are all handled server-side. You work with plain Python strings. For regex matching, body_text is less noisy than HTML, but both are available.

wait_for_message is synchronous. For headless Selenium in a single-threaded test, polling with time.sleep is the straightforward choice. If you want lower-latency delivery notification without polling overhead, the WebSocket endpoint wss://uncorreotemporal.com/ws/inbox/{address}?token={session_token} emits {"event": "new_message", "message_id": "..."} the instant the message is stored. Using asyncio + websockets removes the polling entirely, but adds async complexity that is rarely necessary in a standard Selenium suite.

The address format is human-readable: adjective-noun-number@uncorreotemporal.com. It reads cleanly in test logs and screenshots, which helps when debugging failures.


Advantages for CI/CD Pipelines

Once you replace shared inboxes with per-test temporary inboxes, several problems in CI disappear without any further changes.

No shared state between test workers. Pytest-xdist workers, GitHub Actions matrix jobs, or parallel Jenkins stages each create their own inbox independently. There is no coordination layer, no locking, no message-routing logic. Each job creates one inbox, uses it, and lets it expire.

Reproducible environments. Every test run starts with the same precondition: an empty inbox. There are no leftover messages from previous runs. You do not need a cleanup job or a teardown step to ensure isolation — the TTL handles it.

Zero credentials to manage. For anonymous use, POST /api/v1/mailboxes requires no authentication header. The session token in the response is the only credential, and it is scoped to a single inbox. You do not need to provision IMAP credentials, rotate API keys, or configure OAuth clients in CI secrets.

Easy to add to an existing pytest suite. Wrapping inbox creation in a function-scoped fixture takes five lines:

import pytest
import requests

BASE_URL = "https://uncorreotemporal.com"

@pytest.fixture
def temp_inbox():
    resp = requests.post(f"{BASE_URL}/api/v1/mailboxes", params={"ttl_minutes": 10})
    resp.raise_for_status()
    data = resp.json()
    yield data["address"], data["session_token"]
    # No explicit teardown needed — inbox expires automatically

Any Selenium test that needs email can declare def test_something(driver, temp_inbox) and receive a fresh inbox address and token without any setup boilerplate. The same fixture works in Playwright — just swap out the Selenium driver.

Integration with GitHub Actions is straightforward. No special service containers or SMTP routing are required:

- name: Run E2E tests
  run: pytest tests/e2e/ -v --timeout=60
  env:
    APP_URL: ${{ vars.STAGING_URL }}

For authenticated access (higher TTL limits, multiple active mailboxes), add an API key secret and pass it as a Bearer token:

headers = {"Authorization": "Bearer ${{ secrets.UCT_API_KEY }}"}
resp = requests.post(f"{BASE_URL}/api/v1/mailboxes", headers=headers)

The Infrastructure Behind It

uncorreotemporal.com is a programmable temporary email infrastructure that allows developers to create and manage inboxes via API. The backend is a FastAPI application with an async PostgreSQL database, Redis pub/sub for real-time event propagation, and an aiosmtpd-based SMTP server for local email ingestion. Inboxes expire automatically via a background worker — no external cron jobs needed.

The WebSocket endpoint (/ws/inbox/{address}) subscribes to the Redis channel mailbox:{address} and forwards events to connected clients in real time. The REST API (/api/v1/mailboxes and /api/v1/mailboxes/{address}/messages) provides the polling interface for environments where WebSockets are not practical.

It is designed to be used in code, not in a browser — which is what makes it a practical fit for Selenium test suites.


Conclusion

Email confirmation flows fail in automated testing because the browser can fill a form but cannot read an inbox. The standard workarounds — shared test addresses, Gmail IMAP, mocked senders, staging SMTP sinks — each introduce a different form of fragility: cross-test contamination, credential management, incomplete coverage, or flaky polling.

A temporary inbox API solves the problem at the right level. Create a real inbox via HTTP before each test. Pass the address to Selenium. Wait for the email. Extract the link or OTP. Complete the flow. The entire pattern fits in a pytest fixture and runs in any CI environment without additional infrastructure.

The result is that email confirmation tests become as reliable as your other Selenium tests — isolated, deterministic, and runnable in parallel without coordination.

Written by

FP
Francisco Pérez Ferrer

Software Engineer · Sr. Python Developer · AWS Certified Solutions Architect

Software engineer with 20 years of experience building Python backends, cloud infrastructure, and AI agent tooling. Builder of UnCorreoTemporal.

LinkedIn

Ready to give your AI agents a real inbox?

Create your first temporary mailbox in 30 seconds. Free plan available.

Create your free mailbox