There is a specific kind of frustration that comes from having a nearly-complete automated test suite — one that handles login, forms, payments, navigation — and then hitting a wall at email verification.
You can automate the browser flawlessly. But the moment your app sends a "verify your email" message, someone has to actually read it. In most teams, that someone is still a human, which means your CI pipeline either skips the step, mocks it away, or breaks at 2 AM when a developer accidentally uses the shared test inbox.
I spent way too long doing it wrong before finding a setup that actually works. Here is everything I learned.
Why the Common Approaches All Break Eventually
I have tried most of the workarounds that come up when you search for this problem:
Shared Gmail account — works until it doesn't. Two parallel test jobs submit signups at the same time and each reads the other's OTP. Flaky tests that are impossible to debug.
Hardcoded temp mail URL — you need a second Selenium session pointed at a web UI to read it. Fragile DOM scraping, timing issues, and the site goes down at the worst time.
Mocking the email step — this one is actually the most dangerous. Your signup flow "passes" in CI but your real email pipeline could be silently broken. You find out in production.
nodemailer + IMAP — complex OAuth setup, token rotation, and you still have to write regex to parse the OTP from the email body.
What I actually needed was simple: a fresh inbox per test, fast delivery, and OTP extraction that does not require me to maintain regex patterns. That is what the FreeCustom.Email API provides.
The Setup
Register at freecustom.email, then install the CLI:
curl -fsSL freecustom.email/install.sh | sh
fce login
# Opens browser → signs you in → saves key to OS keychain
Or pick your package manager:
brew tap DishIs/homebrew-tap && brew install fce # Homebrew
npm install -g fcemail@latest # npm
choco install fce # Chocolatey (Windows)
scoop bucket add fce https://github.com/DishIs/scoop-bucket && scoop install fce # Scoop
go install github.com/DishIs/fce-cli@latest # Go
All releases: github.com/DishIs/fce-cli/releases
For the Python tests below, install the SDK:
pip install freecustom-email selenium pytest pytest-asyncio
The Pattern That Works
Every email verification automation follows three steps:
1. Create a fresh disposable inbox
2. Submit it in the signup form
3. Extract the OTP
The key word is fresh. Each test gets its own inbox. Parallel tests never collide.
Python + Selenium — Full Example
Here is a pytest fixture-based setup I have been using in production CI:
# conftest.py
import asyncio
import os
import time
import pytest
from freecustom_email import FreeCustomEmail
fce = FreeCustomEmail(api_key=os.environ["FCE_API_KEY"])
@pytest.fixture
def inbox():
addr = f"sel-{int(time.time())}@ditapi.info"
asyncio.run(fce.inboxes.register(addr))
yield addr
asyncio.run(fce.inboxes.unregister(addr))
# tests/test_signup.py
import asyncio
import pytest
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from freecustom_email import FreeCustomEmail
import os
fce = FreeCustomEmail(api_key=os.environ["FCE_API_KEY"])
APP_URL = os.environ.get("APP_URL", "https://staging.myapp.com")
@pytest.fixture
def driver():
opts = webdriver.ChromeOptions()
opts.add_argument("--headless")
opts.add_argument("--no-sandbox")
drv = webdriver.Chrome(options=opts)
yield drv
drv.quit()
@pytest.mark.asyncio
async def test_signup_and_verify(driver, inbox):
wait = WebDriverWait(driver, 15)
# Fill the signup form with our temp inbox address
driver.get(f"{APP_URL}/signup")
wait.until(EC.presence_of_element_located((By.ID, "email"))).send_keys(inbox)
driver.find_element(By.ID, "password").send_keys("TestPass123!")
driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
# Wait for the OTP screen
wait.until(EC.presence_of_element_located((By.ID, "otp-input")))
# This is the part that used to require regex — now it's one line
otp = await fce.otp.wait_for(inbox, timeout=30)
assert otp and otp.isdigit()
# Fill OTP and verify
driver.find_element(By.ID, "otp-input").send_keys(otp)
driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
wait.until(EC.url_contains("/dashboard"))
assert "/dashboard" in driver.current_url
The fce.otp.wait_for() call handles all the polling and parsing internally. It returns the numeric code (or verification link, depending on your app). No regex to write or maintain.
JavaScript + Selenium — Same Idea
import { Builder, By, until } from 'selenium-webdriver';
import { FreecustomEmailClient } from 'freecustom-email';
const fce = new FreecustomEmailClient({ apiKey: process.env.FCE_API_KEY! });
async function testSignup() {
const inbox = `sel-${Date.now()}@ditapi.info`;
await fce.inboxes.register(inbox);
const driver = await new Builder().forBrowser('chrome').build();
try {
await driver.get(`${process.env.APP_URL}/signup`);
await driver.wait(until.elementLocated(By.id('email')), 10_000);
await driver.findElement(By.id('email')).sendKeys(inbox);
await driver.findElement(By.id('password')).sendKeys('TestPass123!');
await driver.findElement(By.css("button[type='submit']")).click();
await driver.wait(until.elementLocated(By.id('otp-input')), 10_000);
const otp = await fce.otp.waitFor(inbox, { timeout: 30_000 });
await driver.findElement(By.id('otp-input')).sendKeys(otp);
await driver.findElement(By.css("button[type='submit']")).click();
await driver.wait(until.urlContains('/dashboard'), 10_000);
console.log(`✓ Signup verified. OTP was: ${otp}`);
} finally {
await driver.quit();
await fce.inboxes.unregister(inbox);
}
}
testSignup();
What the OTP Output Actually Looks Like
When you call fce otp <inbox> from the CLI (useful for debugging), here is what you get:
────────────────────────────────────────────────
OTP
────────────────────────────────────────────────
OTP · 212342
From · noreply@myapp.com
Subj · Your verification code
Time · 20:19:54
In a shell script you can grab just the code:
OTP=$(fce otp "$INBOX" | grep "OTP ·" | awk '{print $NF}')
Parallel Tests — This Is Where It Gets Good
Because every test has its own inbox, you get parallel safety for free:
pip install pytest-xdist
pytest -n 4 tests/test_signup.py
Four Chrome sessions, four isolated inboxes, no collisions. This was impossible with any shared inbox approach.
GitHub Actions
- name: E2E signup tests
env:
FCE_API_KEY: ${{ secrets.FCE_API_KEY }}
APP_URL: ${{ vars.STAGING_URL }}
run: |
pip install selenium pytest pytest-asyncio pytest-xdist freecustom-email
pytest -n 4 tests/ -v
Get FCE_API_KEY from the dashboard after running fce login locally. Set it as a repository secret.
What About Magic Links Instead of Numeric OTPs?
Some apps send a clickable link rather than a code. The FCE API handles this too — the message object includes a verification_link field:
messages = await fce.messages.list(inbox)
msg = await fce.messages.get(inbox, messages[0].id)
driver.get(msg.verification_link)
Plans and Pricing
The OTP extraction endpoint requires Growth plan ($49/mo). WebSocket real-time delivery (instead of polling) requires Startup ($19/mo). Everything else — inbox creation, message fetching, the CLI — works on the free tier.
| Plan | Price | OTP extraction | WebSocket |
|---|---|---|---|
| Free | $0 | ✗ | ✗ |
| Developer | $7/mo | ✗ | ✗ |
| Startup | $19/mo | ✗ | ✓ |
| Growth | $49/mo | ✓ | ✓ |
Full details at freecustom.email/api/pricing.
Links
- 🖥️ CLI docs: freecustom.email/api/cli
- 📦 GitHub: github.com/DishIs/fce-cli
- 🚀 Releases: github.com/DishIs/fce-cli/releases
- 📖 API docs: freecustom.email/api/docs
- 💬 Discord: discord.com/invite/Ztp7kT2QBz
If you are testing email verification flows today, what approach are you using? Curious whether anyone has found a better pattern for magic links specifically — drop it in the comments.
Top comments (0)