How to Automate Email Verification Testing in CI/CD Pipelines (2026)

Dishant SinghMarch 15, 2026

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

nodemailer test accounts

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

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 link

In 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:54

Capture 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" || true

Setting up the secret

  1. Run fce login on your local machine

  2. Go to the FreeCustom.Email dashboard and copy your API key

  3. In GitHub: Settings → Secrets and Variables → Actions → New repository secret

  4. 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" || true

Parallel 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

fce CLI + bash

Shell scripts, simple CI steps

Low

Via & / xargs

fce CLI + YAML

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

pytest-asyncio

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_KEY as a CI secret, never hardcode it

  • Set a reasonable timeout on OTP extraction (30–60 seconds)

  • Clean up inboxes in an always: / after_script: block

  • Use 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

D

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.

FAQ

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.

Discussion0

No comments yet. Be the first to share your thoughts.