How to Test Email Signup Flows in CI/CD Pipelines
A practical guide to testing email signup flows in CI/CD pipelines using Python and requests — real temporary inboxes, deterministic polling, and zero mocking.

The Pain Every CI Pipeline Knows
It works locally. The signup form submits, the confirmation email arrives in your inbox, you paste the OTP, it verifies. Ship it.
Then CI runs. The test sends the signup request, polls... and times out. Or worse: it passes because you mocked the email sender and never actually tested that the email arrives, renders correctly, or contains a working code.
Email signup flows break in CI for a handful of predictable reasons:
- Shared inboxes — two parallel test runs both register and poll the same address. Whichever test gets the email first passes; the other times out.
- Mocked senders — you assert that
send_email()was called, not that an email was received. Template bugs, SMTP credential expiry, and malformed links all pass through silently. - Brittle fixtures — hardcoded email addresses that accumulate stale messages across runs.
- No isolation — there's no API to create a fresh inbox per test run, so you bolt on a shared test account and cross your fingers.
The fix is not a better mock. It's a real, isolated, programmable inbox that your test owns for its lifetime.
Quick Start
All examples in this article are from the temporary-email-api-examples repo. The python-example/ folder is a single-file script that demonstrates the full flow with plain requests — no extra libraries required.
Set two environment variables before running:
export UCT_API_BASE_URL="https://uncorreotemporal.com/api/v1"
export UCT_EMAIL_API_KEY="your_api_key_here"
Then:
git clone https://github.com/francofuji/temporary-email-api-examples
cd temporary-email-api-examples/python-example
pip install requests
python main.py
You will see a temporary inbox created, a polling loop start, and — once you trigger a real email to the address — an OTP extracted and printed.
The Flow: Create Inbox → Wait for Email → Extract OTP
The pattern is three steps. Here is how the example implements each one.
1. Create the Inbox
import os, requests
API_BASE_URL = os.getenv("UCT_API_BASE_URL")
API_KEY = os.getenv("UCT_EMAIL_API_KEY")
HEADERS = {"X-API-Key": API_KEY}
create_resp = requests.post(f"{API_BASE_URL}/inboxes", headers=HEADERS, timeout=10)
create_resp.raise_for_status()
inbox = create_resp.json()
inbox_id = inbox.get("id")
One POST, one inbox. No signup flow, no shared state. The inbox_id is the key you use for the next two steps.
2. Poll for Messages
import time, re
pattern = re.compile(r"\b(\d{6})\b")
start_time = time.time()
while True:
if time.time() - start_time > 60:
raise SystemExit("Timed out waiting for OTP")
try:
msg_resp = requests.get(
f"{API_BASE_URL}/inboxes/{inbox_id}/messages",
headers=HEADERS,
timeout=10,
)
if msg_resp.status_code >= 500:
time.sleep(3)
continue
msg_resp.raise_for_status()
messages = msg_resp.json()
except requests.RequestException as exc:
print(f"Request failed: {exc}. Retrying...")
time.sleep(3)
continue
if not messages:
time.sleep(3)
continue
The loop handles three distinct cases: server errors (5xx, which you retry), request failures (network blips), and an empty inbox (normal while waiting). Separating these is what makes polling reliable in CI — you don't bail on transient failures.
3. Extract the OTP
body = messages[0].get("body", "")
match = pattern.search(body)
if match:
otp = match.group(1)
print(f"OTP found: {otp}")
break
The regex \b(\d{6})\b matches any standalone 6-digit sequence. Adjust the pattern to match your application's format — 8-digit codes, alphanumeric tokens, or verification URLs all follow the same extraction logic.
CI/CD Tips
A script that works on a developer laptop needs a few extra considerations before it's reliable in a pipeline.
Set an explicit timeout and fail loudly. The example uses 60 seconds. In CI, an infinite loop or a silently hanging test is worse than a hard failure. If the email doesn't arrive in 60 seconds, something upstream is broken — raise, don't sleep forever.
Use fixed poll intervals, not adaptive backoff. Exponential backoff made sense when you were calling an overloaded third-party API. Here the inbox is isolated and fresh. Poll every 3 seconds. Predictable timing makes failures easier to diagnose.
Handle 5xx separately from 4xx. A 500 from the messages endpoint is probably transient (server restart, deploy in progress). A 401 or 404 means your credentials or inbox ID are wrong — retrying won't help. The example retries 5xx and raises immediately on 4xx via raise_for_status().
Don't share inbox IDs across test cases. Create one inbox per test run. Passing inbox IDs between steps via environment variables or test fixtures is fine; sharing them between parallel runs is not.
Respect rate limits with one inbox per test. The API is designed for isolated per-test inboxes. If you're running 20 parallel test workers and all of them are hitting the same endpoint at the same interval, add a small per-worker jitter (time.sleep(worker_index * 0.5)) at startup to spread the initial burst.
Store credentials in CI secrets, not in code.
# GitHub Actions
env:
UCT_API_BASE_URL: ${{ vars.UCT_API_BASE_URL }}
UCT_EMAIL_API_KEY: ${{ secrets.UCT_EMAIL_API_KEY }}
Both variables are the same ones the script reads from the environment. No code changes needed between local and CI.
Why This Works in CI
Most email testing approaches fail in CI because they depend on something external: a shared inbox, a stubbed SMTP server, or a hardcoded test account that accumulates state across runs.
This approach is stateless from the pipeline's perspective. Each run calls POST /inboxes, gets a fresh address and ID back, and owns that inbox exclusively until the test exits or the TTL expires. There's nothing to clean up, nothing to coordinate between parallel jobs, and no leftover messages from a previous run to confuse the polling logic.
The inbox also receives real SMTP delivery — the same delivery path your production email goes through. If your email provider is down, your template is broken, or your SMTP credentials expired, the test will fail. That's the point. A test that can't catch those failures isn't testing email; it's testing whether your mock function was called.
Conclusion
Email signup testing is broken in most CI pipelines not because it's technically hard but because the default tools — mocks, shared inboxes, SMTP dev servers — don't give you the properties you need: isolation, real delivery, and an API to read the result programmatically.
The pattern in this article is straightforward: create a dedicated inbox per test, poll for the message with a hard timeout, extract the OTP or link, and proceed. The Python example is a working implementation you can drop into any pipeline today.
Next steps:
- Browse the full
temporary-email-api-examplesrepo for Node.js, shell/cURL, Playwright, and pytest examples - Get your API key at uncorreotemporal.com
- Wrap the flow in a pytest fixture for per-test inbox isolation in larger test suites
Written by
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.
LinkedInReady to give your AI agents a real inbox?
Create your first temporary mailbox in 30 seconds. Free plan available.
Create your free mailbox