Email verification is one of the last stubborn manual steps in otherwise automated test suites. Teams that run thousands of automated tests per day will still have a QA engineer manually clicking "resend code" in a staging environment because their CI pipeline has no way to receive emails programmatically.
This post fixes that. Using the FreeCustom.Email API and CLI — the only disposable email tooling of its kind in 2026 — you can fully automate every email-based flow in your test suite, from simple OTP verification to multi-step account setup workflows.
Why Traditional Approaches Break in CI
Before diving into solutions, it is worth being precise about what fails with existing approaches:
Approach | Problem |
|---|---|
Shared test email account | Race conditions when tests run in parallel; inbox fills up; credentials rotate |
Web UI disposable services | No programmatic access; no API; manual copy-paste |
Mocking the email step | Does not test the actual email delivery pipeline; silent failures in production |
Polling a basic API | Slow, rate-limited, no real-time delivery, fragile regex for OTP extraction |
| Requires maintaining credentials; OAuth token rotation; no OTP parsing |
None of these scale to a CI environment running dozens of parallel jobs. What you need is fresh isolated inboxes per test run, real-time delivery, and automatic OTP extraction — all scriptable from a shell. That is exactly what the fce CLI provides.
Prerequisites
A FreeCustom.Email account at freecustom.email
Run
fce loginlocally to authenticate and generate your API keyThe
fceCLI or access to the REST API
For OTP extraction (fce otp) you need Growth plan or above. For real-time WebSocket streaming (fce watch) you need Startup plan or above. All other commands work on the free tier.
The Core Pattern
Every email verification automation follows the same three steps:
1. Create a fresh disposable inbox
2. Trigger your app to send a verification email to it
3. Extract the OTP or verification linkIn the CLI:
bash
# Step 1: Create a fresh inbox
INBOX=$(fce inbox add random)
echo "Testing with: $INBOX"
# Testing with: dev-fy8x@ditcloud.info
# Step 2: Trigger your app
curl -s -X POST https://staging.myapp.com/signup \
-H "Content-Type: application/json" \
-d "{\"email\": \"$INBOX\"}"
# Step 3: Extract OTP
fce otp $INBOX────────────────────────────────────────────────
OTP
────────────────────────────────────────────────
OTP · 212342
From · noreply@myapp.com
Subj · Your verification code
Time · 20:19:54Capture just the code for use in a script:
bash
OTP=$(fce otp $INBOX | grep "OTP ·" | awk '{print $NF}')GitHub Actions: Complete Working Example
yaml
# .github/workflows/email-verification.yml
name: E2E Email Verification
on:
push:
branches: [main, develop]
pull_request:
jobs:
email-verification:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Install fce CLI
run: curl -fsSL freecustom.email/install.sh | sh
- name: Verify fce is working
env:
FCE_API_KEY: ${{ secrets.FCE_API_KEY }}
run: fce status
- name: Run E2E signup verification
env:
FCE_API_KEY: ${{ secrets.FCE_API_KEY }}
APP_URL: ${{ vars.STAGING_URL }}
run: |
INBOX=$(fce inbox add random | tr -d '[:space:]')
echo "Using inbox: $INBOX"
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST "$APP_URL/api/signup" \
-H "Content-Type: application/json" \
-d "{\"email\": \"$INBOX\"}")
if [ "$HTTP_STATUS" != "200" ]; then
echo "Signup failed: HTTP $HTTP_STATUS"
exit 1
fi
OTP=$(fce otp "$INBOX" | grep "OTP ·" | awk '{print $NF}')
if [ -z "$OTP" ]; then
echo "No OTP received"
exit 1
fi
echo "Got OTP: $OTP"
VERIFY_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST "$APP_URL/api/verify" \
-H "Content-Type: application/json" \
-d "{\"email\": \"$INBOX\", \"otp\": \"$OTP\"}")
if [ "$VERIFY_STATUS" != "200" ]; then
echo "Verification failed: HTTP $VERIFY_STATUS"
exit 1
fi
echo "Verification successful!"
- name: Cleanup inbox
if: always()
env:
FCE_API_KEY: ${{ secrets.FCE_API_KEY }}
run: fce inbox remove "$INBOX" || trueSetting up the secret
Run
fce loginon your local machineGo to the FreeCustom.Email dashboard and copy your API key
In GitHub: Settings → Secrets and Variables → Actions → New repository secret
Name:
FCE_API_KEY, Value: your key
GitLab CI: Complete Working Example
yaml
# .gitlab-ci.yml
stages:
- test
email-verification:
stage: test
image: ubuntu:24.04
timeout: 10 minutes
variables:
FCE_API_KEY: $FCE_API_KEY
APP_URL: "https://staging.myapp.com"
script:
- apt-get update -qq && apt-get install -y curl
- curl -fsSL freecustom.email/install.sh | sh
- fce status
- INBOX=$(fce inbox add random | tr -d '[:space:]')
- echo "Using inbox $INBOX"
- 'curl -s -X POST "$APP_URL/api/signup"
-H "Content-Type: application/json"
-d "{\"email\": \"$INBOX\"}"'
- OTP=$(fce otp "$INBOX" | grep "OTP ·" | awk '{print $NF}')
- echo "OTP received $OTP"
- 'curl -s -f -X POST "$APP_URL/api/verify"
-H "Content-Type: application/json"
-d "{\"email\": \"$INBOX\", \"otp\": \"$OTP\"}"'
after_script:
- fce inbox remove "$INBOX" || trueParallel Inbox Testing
One of the biggest advantages over shared test accounts is parallel isolated inboxes. Here is a bash script that creates 5 inboxes and tests them concurrently:
bash
#!/usr/bin/env bash
# parallel-otp-test.sh
set -euo pipefail
APP_URL="${APP_URL:-https://staging.myapp.com}"
COUNT="${1:-5}"
run_test() {
local n=$1
local inbox
inbox=$(fce inbox add random | tr -d '[:space:]')
echo "[$n] Inbox: $inbox"
curl -s -X POST "$APP_URL/api/signup" \
-H "Content-Type: application/json" \
-d "{\"email\": \"$inbox\"}" > /dev/null
local otp
otp=$(fce otp "$inbox" | grep "OTP ·" | awk '{print $NF}')
if [ -z "$otp" ]; then
echo "[$n] FAIL: No OTP"
return 1
fi
local status
status=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST "$APP_URL/api/verify" \
-H "Content-Type: application/json" \
-d "{\"email\": \"$inbox\", \"otp\": \"$otp\"}")
if [ "$status" = "200" ]; then
echo "[$n] PASS: $inbox → OTP $otp → HTTP $status"
else
echo "[$n] FAIL: HTTP $status"
return 1
fi
fce inbox remove "$inbox" > /dev/null
}
pids=()
for i in $(seq 1 "$COUNT"); do
run_test "$i" &
pids+=($!)
done
failed=0
for pid in "${pids[@]}"; do
wait "$pid" || ((failed++))
done
echo ""
echo "Results: $((COUNT - failed))/$COUNT passed"
[ "$failed" -eq 0 ]Using the JavaScript SDK in a Test Suite
typescript
// tests/signup.test.ts
import { FreecustomEmailClient } from 'freecustom-email';
import { describe, it, expect } from 'vitest';
const fce = new FreecustomEmailClient({ apiKey: process.env.FCE_API_KEY! });
describe('Email verification flow', () => {
it('completes signup and verifies OTP', async () => {
const inbox = `test-${Date.now()}@ditmail.info`;
await fce.inboxes.register(inbox);
const signupRes = await fetch(`${process.env.APP_URL}/api/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: inbox }),
});
expect(signupRes.status).toBe(200);
const otp = await fce.otp.waitFor(inbox, { timeout: 30_000 });
expect(otp).toMatch(/^\d{6}$/);
const verifyRes = await fetch(`${process.env.APP_URL}/api/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: inbox, otp }),
});
expect(verifyRes.status).toBe(200);
await fce.inboxes.unregister(inbox);
});
});Using the Python SDK in a Test Suite
python
# tests/test_signup.py
import asyncio, os, pytest, httpx
from freecustom_email import FreeCustomEmail
fce = FreeCustomEmail(api_key=os.environ["FCE_API_KEY"])
APP_URL = os.environ["APP_URL"]
@pytest.mark.asyncio
async def test_signup_verification():
inbox = "test-pytest@ditmail.info"
await fce.inboxes.register(inbox)
async with httpx.AsyncClient() as client:
r = await client.post(f"{APP_URL}/api/signup", json={"email": inbox})
assert r.status_code == 200
otp = await fce.otp.wait_for(inbox, timeout=30)
assert otp and otp.isdigit()
r2 = await client.post(f"{APP_URL}/api/verify", json={"email": inbox, "otp": otp})
assert r2.status_code == 200
await fce.inboxes.unregister(inbox)Advanced: Password Reset Flow
The same pattern applies to password reset flows:
bash
#!/usr/bin/env bash
INBOX=$(fce inbox add random | tr -d '[:space:]')
# Create and verify an account
curl -s -X POST "$APP_URL/api/signup" \
-d "{\"email\": \"$INBOX\", \"password\": \"test1234\"}"
SIGNUP_OTP=$(fce otp "$INBOX" | grep "OTP ·" | awk '{print $NF}')
curl -s -X POST "$APP_URL/api/verify" \
-d "{\"email\": \"$INBOX\", \"otp\": \"$SIGNUP_OTP\"}"
echo "Account created."
# Trigger password reset
curl -s -X POST "$APP_URL/api/forgot-password" \
-d "{\"email\": \"$INBOX\"}"
RESET_OTP=$(fce otp "$INBOX" | grep "OTP ·" | awk '{print $NF}')
curl -s -X POST "$APP_URL/api/reset-password" \
-d "{\"email\": \"$INBOX\", \"otp\": \"$RESET_OTP\", \"new_password\": \"newpass456\"}"
echo "Password reset complete."
fce inbox remove "$INBOX"Timeout and Retry Helper
bash
wait_for_otp() {
local inbox=$1
local max_attempts=${2:-12} # 12 × 5s = 60s max
local attempt=0
while [ $attempt -lt $max_attempts ]; do
OTP=$(fce otp "$inbox" 2>/dev/null | grep "OTP ·" | awk '{print $NF}')
if [ -n "$OTP" ]; then
echo "$OTP"
return 0
fi
attempt=$((attempt + 1))
echo "Waiting... ($attempt/$max_attempts)" >&2
sleep 5
done
echo "Timeout waiting for OTP" >&2
return 1
}Comparing Approaches
Approach | Best for | Complexity | Parallelism |
|---|---|---|---|
| Shell scripts, simple CI steps | Low | Via |
| GitHub Actions, GitLab CI, CircleCI | Low | Matrix jobs |
JS/TS SDK + Vitest/Jest | Node.js test suites | Medium | Native async |
Python SDK + pytest | Python test suites | Medium |
|
REST API + any HTTP client | Any language, custom tooling | Medium | Your own logic |
OpenClaw AI agent | Conversational automation | None | Single agent |
n8n workflow | Visual, event-driven automation | Low | n8n workers |
For a deep dive into AI agent and no-code options, see Automate Email Workflows with AI Agents in 2026.
Best Practices Checklist
Create a fresh inbox per test run — never reuse inboxes across tests
Store
FCE_API_KEYas a CI secret, never hardcode itSet a reasonable timeout on OTP extraction (30–60 seconds)
Clean up inboxes in an
always:/after_script:blockUse matrix jobs for parallel testing rather than sequential runs
Log the inbox address in CI output so failures are debuggable
Test the full flow including final HTTP response, not just OTP receipt
Frequently Asked Questions
Does fce otp wait for an email or does it require the email to already exist? fce otp polls for the latest OTP and retries with backoff until an email arrives. For instant delivery, use fce watch (WebSocket) on Startup plan+.
Can I test password reset flows, not just signup? Yes. fce otp always returns the most recent code in the inbox regardless of the email's purpose.
What if my app sends a verification link instead of a numeric OTP? The REST API's OTP endpoint returns a verification_link field when a link is present in the email. fce otp surfaces this too.
Can I use a custom domain for test inboxes? Yes, on Growth plan and above. Useful if your app's signup validation rejects public disposable domains.
What happens if fce otp times out in CI? The command exits with a non-zero code, failing the CI step. Log the inbox address before this point for debugging.
Can I use fce dev in CI? fce dev is designed for interactive terminal use. In CI, use fce inbox add random + fce otp or the SDK.
Is there a rate limit on creating inboxes? Yes, per-plan. For parallel CI testing the Growth plan (50 req/sec, 2M/month) or Enterprise (100 req/sec, 10M/month) is recommended.
Summary
The fce CLI and FreeCustom.Email API give you everything needed to fully automate email verification testing in any CI/CD environment — fresh isolated inboxes per run, real-time delivery under 200ms, automatic OTP extraction, official SDKs for Node.js and Python, and a design that is parallel-safe by default. No other disposable email provider offers this depth of CI/CD tooling in 2026.
Related reading:
Written by
Dishant Singh
A full stack developer with good knowledge of email server, SEO, proxies, and networking, have more than 3 years of experience in building webapps for the netizens. Developing open source, fast, and free SaaS for all.
Frequently Asked Questions
Does fce otp wait for an email or does it require the email to already exist?+
fce otp polls for the latest OTP and retries with backoff until an email arrives. For instant delivery, use fce watch (WebSocket) on Startup plan+.
Can I test password reset flows, not just signup?+
Yes. fce otp always returns the most recent code in the inbox regardless of the email's purpose.
What if my app sends a verification link instead of a numeric OTP?+
The REST API's OTP endpoint returns a verification_link field when a link is present in the email. fce otp surfaces this too.
Can I use a custom domain for test inboxes?+
Yes, on Growth plan and above. Useful if your app's signup validation rejects public disposable domains.
Is there a rate limit on creating inboxes?+
Yes, per-plan. For parallel CI testing the Growth plan (50 req/sec, 2M/month) or Enterprise (100 req/sec, 10M/month) is recommended.
No comments yet. Be the first to share your thoughts.