← Back to Blog

7 Mistakes That Make Email OTP Tests Flake in CI

Seven common mistakes that make email OTP and magic-link tests flake in CI — shared inboxes, fixed sleeps, HTML scraping, and more — plus the fix for each.

testingci-cdotplisticle
7 Mistakes That Make Email OTP Tests Flake in CI

Email verification and OTP tests fail unpredictably in CI for reasons that never appear locally. What works perfectly on your laptop races, times out, or breaks silently when running in parallel CI jobs. This isn't a flaky test—it's a predictable outcome of seven avoidable mistakes.

Each mistake below includes the failure mode, why it happens, and the fix with working OTPBox API examples.

1. Sharing One Inbox Across Tests and CI Jobs

The mistake: Reusing the same email address (test@example.com or a single disposable inbox) across all tests and parallel CI jobs.

Why it flakes: Multiple tests race for the same inbox. Job A triggers a signup OTP, Job B triggers a password-reset OTP—both poll the same inbox and grab whichever code arrives first. Sequential retries are equally broken: a test fails, retries, and reads the stale OTP from the first attempt instead of the fresh one.

The fix: Create a unique disposable inbox per test run. Services like OTPBox make this trivial:

# Each test gets a fresh inbox with unique email + token
RESPONSE=$(curl -s -X POST https://otpbox.app/api/inbox)
TOKEN=$(echo "$RESPONSE" | jq -r '.token')
EMAIL=$(echo "$RESPONSE" | jq -r '.email')

echo "Test inbox: $EMAIL"

The email address is at the *@box.otpbox.app domain, and the token provides isolated access. No test can accidentally read another test's OTP. Parallel CI jobs work without coordination, and retries don't see stale messages.

Related reading: CI Email OTP Checklist explains why inbox isolation is the foundation of reliable email tests.


2. Using Fixed Sleeps Instead of Bounded Polling

The mistake: Hard-coding sleep 5 or sleep 10 after triggering an email send, then fetching the OTP once.

Why it flakes: Email delivery latency varies from under 1 second to 15+ seconds depending on SMTP queue depth, provider throttling, and network conditions. A fixed sleep 5 either wastes 4 seconds when email arrives in 1 second or fails prematurely when delivery takes 7 seconds. You're guessing—CI doesn't tolerate guesses.

The fix: Poll with a bounded timeout budget. Check every 2–3 seconds for up to 30 seconds. If no email arrives, fail the test—this signals a real problem, not transient slowness.

MAX_ATTEMPTS=10
SLEEP_SECONDS=3

for i in $(seq 1 $MAX_ATTEMPTS); do
  RESPONSE=$(curl -s "https://otpbox.app/api/inbox/${TOKEN}/latest")
  OTP=$(echo "$RESPONSE" | jq -r '.otp // empty')
  
  if [ -n "$OTP" ] && [ "$OTP" != "null" ]; then
    echo "✓ OTP received: $OTP"
    break
  fi
  
  echo "  Waiting... (attempt $i/$MAX_ATTEMPTS)"
  sleep $SLEEP_SECONDS
done

if [ -z "$OTP" ] || [ "$OTP" = "null" ]; then
  echo "✗ Timeout: No OTP after ${MAX_ATTEMPTS} attempts"
  exit 1
fi

The /latest endpoint returns 404 when the inbox exists but has no messages yet. This isn't an error—it means "keep polling."

Why 30 seconds? Typical SMTP delivery ranges from 1–10 seconds. A 30-second timeout catches 99%+ of normal deliveries while failing fast enough to detect misconfigurations (wrong SMTP credentials, DNS issues) before they waste CI minutes.


3. Scraping HTML with Regex Instead of Using Structured Extraction

The mistake: Fetching raw email bodies and running regex like grep -oP 'code:\s*\K\d{6}' or parsing HTML with sed to find the OTP.

Why it flakes: Email templates change—marketing rewrites the welcome email, and your regex no longer matches. Multipart MIME emails include both text and HTML bodies; parsing requires base64 decoding and content-type handling. Verification patterns vary: "Your code is 123456", "Verification: 123456", or <button>123456</button>. Every variation breaks your regex silently.

The fix: Use an API that extracts OTP codes for you. The OTPBox /latest endpoint returns extracted codes automatically:

{
  "otp": "123456",
  "link": "https://app.com/verify?token=abc...",
  "from": "noreply@yourapp.com",
  "subject": "Verify your email",
  "receivedAt": 1726377600000
}

Both otp (4–8 digit codes) and link (common verification URL patterns) are extracted via regex patterns maintained by the service. Template changes don't break your tests—the extraction logic adapts.

// Playwright example: works for OTP codes or magic links
const response = await fetch(`https://otpbox.app/api/inbox/${token}/latest`);
const { otp, link } = await response.json();

if (link) {
  // Magic link flow
  await page.goto(link);
} else if (otp) {
  // OTP code flow
  await page.fill('input[name="code"]', otp);
  await page.click('button:has-text("Verify")');
}

Local alternative: For local development, Mailpit provides a local SMTP catcher with an API. Use it locally; use cloud disposable email like OTPBox for CI E2E tests.

Related reading: Disposable Email for CI OTP Testing covers when to use local SMTP versus cloud disposable inboxes.


4. Reusing the Same Inbox on CI Retry

The mistake: Creating an inbox once at the start of a test suite, then reusing it when the test retries after failure.

Why it flakes: The retry reads the stale OTP from the first failed attempt instead of waiting for the fresh one triggered by the retry. If your app doesn't send duplicate emails to the same address within a short window (sensible spam prevention), the test hangs forever waiting for a message that will never arrive.

The fix: Either create a brand-new inbox per retry, or trigger a fresh OTP without recreating the inbox (depending on your app's behavior). Most reliably: create the inbox at test start and ensure retries trigger a new verification flow with a new OTP.

# Correct: create inbox once, trigger FRESH verification each time
create_inbox() {
  RESPONSE=$(curl -s -X POST https://otpbox.app/api/inbox)
  export TOKEN=$(echo "$RESPONSE" | jq -r '.token')
  export EMAIL=$(echo "$RESPONSE" | jq -r '.email')
}

trigger_and_verify() {
  # Trigger signup (generates fresh OTP)
  curl -X POST https://yourapp.com/api/signup \
    -H "Content-Type: application/json" \
    -d "{\"email\": \"$EMAIL\"}"
  
  # Poll for the latest OTP
  poll_for_otp "$TOKEN"
}

# Create inbox ONCE
create_inbox

# Retry logic calls trigger_and_verify again
# App sends a NEW OTP to the same inbox
for attempt in {1..3}; do
  if trigger_and_verify; then
    break
  fi
  echo "Retry $attempt failed, retrying..."
done

Rate limit note: OTPBox free tier allows 10 inbox creates per hour per IP. Polling is unrestricted. Reusing the inbox across retries conserves your creation quota.


5. Matching "Latest Email" by Subject Only

The mistake: Polling for "any email with subject 'Verify your email'" without checking sender, timestamp, or inbox isolation.

Why it flakes: If you're sharing an inbox (see Mistake #1) or testing multiple flows, the "latest email" might be from a different test entirely. Subject-only matching gives you password-reset links when you expected signup OTPs, or vice versa.

The fix: Isolate inboxes per test (Mistake #1), then fetch the /latest message. The isolation guarantees the latest message is your message. If you need additional filtering, check the from and subject fields returned by the API:

async function pollForEmail(token, expectedFrom) {
  const maxAttempts = 10;
  
  for (let i = 0; i < maxAttempts; i++) {
    const response = await fetch(`https://otpbox.app/api/inbox/${token}/latest`);
    
    if (response.ok) {
      const data = await response.json();
      
      // Validate sender if needed (but isolation already guarantees relevance)
      if (data.from === expectedFrom) {
        return data;
      }
    }
    
    await new Promise(resolve => setTimeout(resolve, 3000));
  }
  
  throw new Error('Timeout: No matching email received');
}

const email = await pollForEmail(token, 'noreply@yourapp.com');

Better approach: Trust the isolation. If each test has its own inbox, the latest message is always the one you triggered.

Related reading: How to Test Email OTP in CI covers isolation and polling patterns in depth.


6. Creating a New Inbox Inside the Polling Loop

The mistake: Recreating the disposable inbox on every poll attempt or retry.

Why it flakes: You hit rate limits. OTPBox free tier permits 10 inbox creates per hour per IP. If you create a new inbox every 3 seconds for 30 seconds, that's 10 creates in 30 seconds—you've exhausted your hourly quota in half a minute. The test fails with 429 Too Many Requests.

The fix: Create the inbox once before triggering the email send. Poll the same inbox using its token. Polling endpoints are unrestricted—you can poll every second if needed.

# ✓ CORRECT: Create once, poll many times
RESPONSE=$(curl -s -X POST https://otpbox.app/api/inbox)
TOKEN=$(echo "$RESPONSE" | jq -r '.token')
EMAIL=$(echo "$RESPONSE" | jq -r '.email')

# Trigger email
curl -X POST https://yourapp.com/api/signup -d "{\"email\": \"$EMAIL\"}"

# Poll unrestricted
for i in {1..20}; do
  curl -s "https://otpbox.app/api/inbox/${TOKEN}/latest"
  sleep 2
done
# ✗ WRONG: Creating inbox inside polling loop
for i in {1..20}; do
  # This hits rate limits after ~10 iterations
  RESPONSE=$(curl -s -X POST https://otpbox.app/api/inbox)
  TOKEN=$(echo "$RESPONSE" | jq -r '.token')
  curl -s "https://otpbox.app/api/inbox/${TOKEN}/latest"
  sleep 2
done

If you hit 429: Your test suite is creating too many inboxes. Consolidate test runs, reuse inboxes across related assertions within a single test, or throttle parallel CI jobs.


7. Not Testing Failure Paths or Logging the Test Email

The mistake (Part A): Only testing the happy path—signup → OTP → success. No tests for expired codes, wrong codes, or rate limits.

Why it causes pain: Production bugs hide in edge cases. You discover your app accepts any 6-digit code (not just the valid OTP) when a security researcher reports it. Or expired OTPs crash your verification handler because you never tested that path.

The fix (Part A): Write explicit tests for failure scenarios:

// Test expired OTP
test('expired OTP is rejected', async ({ page }) => {
  const { email, token } = await createInbox();
  
  await page.goto('https://yourapp.com/signup');
  await page.fill('input[name="email"]', email);
  await page.click('button:has-text("Sign Up")');
  
  const { otp } = await pollForOTP(token);
  
  // Simulate expiration (adjust to your app's timeout)
  await new Promise(resolve => setTimeout(resolve, 300000)); // 5 minutes
  
  await page.fill('input[name="code"]', otp);
  await page.click('button:has-text("Verify")');
  
  await expect(page.locator('text=Code expired')).toBeVisible();
});

// Test wrong code
test('wrong OTP is rejected', async ({ page }) => {
  const { email, token } = await createInbox();
  
  await page.goto('https://yourapp.com/signup');
  await page.fill('input[name="email"]', email);
  await page.click('button:has-text("Sign Up")');
  
  // Don't fetch the real OTP; submit a fake one
  await page.fill('input[name="code"]', '000000');
  await page.click('button:has-text("Verify")');
  
  await expect(page.locator('text=Invalid code')).toBeVisible();
});

The mistake (Part B): Not logging the test email address. When a CI test fails with "No OTP received," you have no way to check whether the app sent email at all.

The fix (Part B): Always log the test inbox at test start:

- name: Create inbox
  id: inbox
  run: |
    RESPONSE=$(curl -s -X POST https://otpbox.app/api/inbox)
    TOKEN=$(echo "$RESPONSE" | jq -r '.token')
    EMAIL=$(echo "$RESPONSE" | jq -r '.email')
    echo "token=$TOKEN" >> $GITHUB_OUTPUT
    echo "email=$EMAIL" >> $GITHUB_OUTPUT
    echo "::notice::Test inbox: $EMAIL"

Now when the test fails, you can:

  • Check your app's send logs for that email address
  • Inspect bounce/reject reports
  • Manually trigger a verification email to the same inbox to reproduce the issue

Anti-pattern: Creating inboxes silently, then spending 20 minutes debugging because you don't know which email the test used.

Related reading: Email OTP Testing FAQ covers debugging strategies and failure-path testing in Q&A format.


Bonus Mistake: Using Mocks for End-to-End Tests

The mistake: Mocking your sendEmail() function in CI E2E tests to "speed things up" or "avoid flakiness."

Why it defeats the purpose: Mocking removes the entire point of E2E testing. You won't catch:

  • Invalid SMTP credentials
  • Missing DNS records (SPF/DKIM)
  • Email template rendering bugs (broken HTML, missing variables)
  • Deliverability issues (bounces, spam filters)

The fix: Use real email delivery in E2E tests. Mock in unit tests where you're testing business logic, not the full flow. For CI E2E tests, let your app send email normally and use a disposable inbox service to receive it.

When to use local SMTP: For local development and unit tests, Mailpit (actively maintained) or MailHog (largely unmaintained) run a local SMTP server with a web UI and API. Fast, offline, no external dependency. But for CI E2E tests where you want to validate real-world email delivery, use cloud disposable email.

Decision matrix:

Scenario Use Local SMTP Use Cloud Disposable
Local dev feedback
Unit tests (logic only)
CI E2E tests
Parallel CI jobs
Validate deliverability

Related reading: Magic Link Testing Without Polluting Real Inboxes explains when disposable email is essential versus nice-to-have.


Putting It All Together

Avoiding these seven mistakes boils down to three principles:

  1. Isolate state: Unique inbox per test, no shared email addresses
  2. Handle async correctly: Bounded polling with deadlines, not fixed sleeps
  3. Use the right tools: Structured extraction APIs, real SMTP in E2E tests, logged email addresses for debugging

A complete GitHub Actions example fixing all seven mistakes:

name: E2E Email Verification

on: [push, pull_request]

jobs:
  test-otp:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        flow: [signup, password-reset]
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Create unique inbox (fixes Mistakes 1, 4, 6)
        id: inbox
        run: |
          RESPONSE=$(curl -s -X POST https://otpbox.app/api/inbox)
          echo "token=$(echo "$RESPONSE" | jq -r '.token')" >> $GITHUB_OUTPUT
          echo "email=$(echo "$RESPONSE" | jq -r '.email')" >> $GITHUB_OUTPUT
          echo "::notice::Test inbox: $(echo "$RESPONSE" | jq -r '.email')"
      
      - name: Trigger ${{ matrix.flow }}
        run: |
          curl -X POST https://yourapp.com/api/${{ matrix.flow }} \
            -H "Content-Type: application/json" \
            -d "{\"email\": \"${{ steps.inbox.outputs.email }}\"}"
      
      - name: Poll with bounded timeout (fixes Mistakes 2, 3, 5)
        id: otp
        run: |
          MAX_ATTEMPTS=10
          for i in $(seq 1 $MAX_ATTEMPTS); do
            RESPONSE=$(curl -s "https://otpbox.app/api/inbox/${{ steps.inbox.outputs.token }}/latest")
            OTP=$(echo "$RESPONSE" | jq -r '.otp // empty')
            
            if [ -n "$OTP" ] && [ "$OTP" != "null" ]; then
              echo "otp=$OTP" >> $GITHUB_OUTPUT
              exit 0
            fi
            
            sleep 3
          done
          
          echo "✗ Timeout after 30s"
          exit 1
      
      - name: Verify OTP
        run: |
          curl -X POST https://yourapp.com/api/verify \
            -d "{\"code\": \"${{ steps.otp.outputs.otp }}\"}"
      
      - name: Test failure path (fixes Mistake 7)
        run: |
          # Test wrong code
          curl -X POST https://yourapp.com/api/verify \
            -d "{\"code\": \"000000\"}" \
            | grep -q "Invalid code"

This workflow runs two parallel jobs (signup, password-reset), each with isolated inboxes, bounded polling, structured extraction, logged email addresses, and failure-path tests. No shared state, no fixed sleeps, no HTML scraping, no rate limit issues.


Related Articles

Resources


Email OTP tests don't have to flake. Avoid these seven mistakes—shared inboxes, fixed sleeps, HTML scraping, stale retry inboxes, subject-only matching, inbox creation spam, and missing failure tests—and your CI suite becomes predictable, fast, and maintainable.