DEV Community

Cover image for How to Use Disposable Email in Your CI/CD Pipeline for E2E Testing
Diego Ramos
Diego Ramos

Posted on

How to Use Disposable Email in Your CI/CD Pipeline for E2E Testing

The Problem

You have a signup flow. It sends a verification email. You need to test it end-to-end.

In development, you probably skip email verification with a flag, or use a shared test inbox that everyone on the team reads from. In CI, things get worse:

  • Shared test inboxes create race conditions when parallel jobs read the same messages
  • Mocking the email service means you're not actually testing email delivery
  • Skipping verification means you're not testing the real flow
  • Hardcoded test emails break when someone changes the email template

What you want is: create a fresh inbox, run the test, check for the email, extract the code, verify it works, delete the inbox. All programmatically, all isolated per test run.

The Solution: Disposable Email API

GoneBox provides a REST API for temporary email. No SDK to install for basic usage — just HTTP calls. Create an inbox, get an email address, poll for messages, read them, delete when done.

Here's the full lifecycle:

1. POST /api/v1/inboxes           → Create inbox (get address)
2. [Your app sends email to that address]
3. GET  /api/v1/inboxes/:addr/messages  → Poll until message arrives
4. GET  /api/v1/messages/:id      → Read the full email
5. [Extract code/link, verify it works]
6. DELETE /api/v1/inboxes/:addr   → Cleanup
Enter fullscreen mode Exit fullscreen mode

Inboxes auto-expire after 1 hour anyway, but explicit cleanup keeps things tidy.

Example: Playwright + GoneBox API

Here's a real E2E test for a signup flow using Playwright:

import { test, expect } from '@playwright/test';

const API_BASE = 'https://api.gonebox.email/api/v1';

// Helper: create a temp inbox
async function createInbox(): Promise<{ address: string }> {
  const res = await fetch(`${API_BASE}/inboxes`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ domain: 'gonebox.email' }),
  });
  const data = await res.json();
  return { address: data.address };
}

// Helper: wait for an email to arrive
async function waitForEmail(
  address: string,
  timeoutMs = 30_000,
  pollMs = 2_000,
): Promise<any> {
  const deadline = Date.now() + timeoutMs;
  const encoded = encodeURIComponent(address);

  while (Date.now() < deadline) {
    const res = await fetch(`${API_BASE}/inboxes/${encoded}/messages`);
    const data = await res.json();
    const messages = Array.isArray(data) ? data : data.messages ?? [];

    if (messages.length > 0) {
      // Fetch full message content
      const msgRes = await fetch(`${API_BASE}/messages/${messages[0].id}`);
      return await msgRes.json();
    }

    await new Promise((r) => setTimeout(r, pollMs));
  }

  throw new Error(`No email received at ${address} within ${timeoutMs}ms`);
}

// Helper: extract verification code from email body
function extractCode(text: string): string | null {
  const match = text.match(
    /(?:code|verify|otp|pin)\s*[:=\-]?\s*(\d{4,8})/i,
  );
  return match ? match[1] : null;
}

// Helper: cleanup
async function deleteInbox(address: string): Promise<void> {
  const encoded = encodeURIComponent(address);
  await fetch(`${API_BASE}/inboxes/${encoded}`, { method: 'DELETE' });
}

test('signup with email verification', async ({ page }) => {
  // 1. Create a disposable inbox
  const { address } = await createInbox();

  try {
    // 2. Fill the signup form
    await page.goto('https://your-app.com/signup');
    await page.fill('[name="email"]', address);
    await page.fill('[name="password"]', 'TestPassword123!');
    await page.click('button[type="submit"]');

    // 3. Wait for verification email
    await expect(page.locator('.check-email-message')).toBeVisible();
    const email = await waitForEmail(address);

    // 4. Extract and enter verification code
    const code = extractCode(email.body_text);
    expect(code).not.toBeNull();

    await page.fill('[name="verification-code"]', code!);
    await page.click('button[type="submit"]');

    // 5. Verify successful signup
    await expect(page).toHaveURL(/\/dashboard/);
    await expect(page.locator('h1')).toContainText('Welcome');
  } finally {
    // 6. Always cleanup
    await deleteInbox(address);
  }
});
Enter fullscreen mode Exit fullscreen mode

Key points:

  • Each test gets its own inbox — no shared state, no race conditions
  • The finally block ensures cleanup even if the test fails
  • Polling interval of 2 seconds keeps the test responsive without hammering the API
  • The timeout is configurable per test

Example: Jest + Node.js (API-Only Testing)

If you're testing email delivery without a browser:

import { describe, it, expect, afterEach } from '@jest/globals';

const API_BASE = 'https://api.gonebox.email/api/v1';
let testInbox: string | null = null;

afterEach(async () => {
  if (testInbox) {
    const encoded = encodeURIComponent(testInbox);
    await fetch(`${API_BASE}/inboxes/${encoded}`, { method: 'DELETE' });
    testInbox = null;
  }
});

async function createTestInbox(): Promise<string> {
  const res = await fetch(`${API_BASE}/inboxes`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ domain: 'gonebox.email' }),
  });
  const data = await res.json();
  testInbox = data.address;
  return data.address;
}

async function pollForMessage(address: string, maxWaitMs = 15_000) {
  const encoded = encodeURIComponent(address);
  const deadline = Date.now() + maxWaitMs;

  while (Date.now() < deadline) {
    const res = await fetch(`${API_BASE}/inboxes/${encoded}/messages`);
    const data = await res.json();
    const messages = Array.isArray(data) ? data : data.messages ?? [];

    if (messages.length > 0) return messages[0];
    await new Promise((r) => setTimeout(r, 2_000));
  }
  return null;
}

describe('Email delivery', () => {
  it('sends welcome email after user creation', async () => {
    const email = await createTestInbox();

    // Trigger your API to send an email
    const res = await fetch('https://your-api.com/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        email,
        name: 'Test User',
      }),
    });
    expect(res.status).toBe(201);

    // Wait for the email
    const message = await pollForMessage(email);
    expect(message).not.toBeNull();
    expect(message.subject).toContain('Welcome');
  }, 30_000); // 30s timeout for the whole test

  it('sends password reset email', async () => {
    const email = await createTestInbox();

    await fetch('https://your-api.com/auth/forgot-password', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email }),
    });

    const message = await pollForMessage(email);
    expect(message).not.toBeNull();
    expect(message.subject).toMatch(/reset|password/i);
  }, 30_000);
});
Enter fullscreen mode Exit fullscreen mode

CI Configuration

GitHub Actions

name: E2E Tests
on: [push]

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - run: npm ci
      - run: npx playwright install --with-deps

      # No special setup needed — GoneBox API is a public service
      - run: npx playwright test
        env:
          GONEBOX_API_URL: https://api.gonebox.email
          # Optional: use API key for higher rate limits
          # GONEBOX_API_KEY: ${{ secrets.GONEBOX_API_KEY }}
Enter fullscreen mode Exit fullscreen mode

No Docker containers to spin up. No SMTP server to configure. No ports to expose. The API is remote, so your CI just makes HTTP calls.

GitLab CI

e2e-tests:
  image: mcr.microsoft.com/playwright:v1.40.0-focal
  script:
    - npm ci
    - npx playwright test
  variables:
    GONEBOX_API_URL: https://api.gonebox.email
Enter fullscreen mode Exit fullscreen mode

Practical Tips

1. Use unique inboxes per test. Don't pass a custom username — let the API generate a random one. This prevents collisions between parallel test runs.

2. Set reasonable timeouts. Email delivery usually takes 2-10 seconds. A 30-second timeout is safe for CI; 60 seconds if your email provider is slow.

3. Clean up in afterEach/finally. Inboxes auto-expire after 1 hour, but explicit cleanup prevents hitting the 3-inbox-per-IP limit during large test suites.

4. Use API keys for CI. The public tier allows 60 requests/minute. If you have many email tests running in parallel, get an API key for higher limits.

5. Don't test email rendering here. This approach tests delivery and content. For rendering (does the email look right in Gmail/Outlook), use dedicated tools like Litmus or Email on Acid.

What About Alternatives?

Approach Pros Cons
Mock email service Fast, deterministic Not testing real delivery
Shared test inbox Simple setup Race conditions, no isolation
Mailtrap/Mailhog Full SMTP control Requires SMTP config changes per env
GoneBox API No config changes, isolated per test Depends on external service

GoneBox works well when you want to test the actual email delivery path without modifying your application's email configuration. Your app sends to a real email address; the test reads from the API.

If you need full SMTP control (testing DKIM, SPF, bounce handling), you'll want Mailtrap or a similar tool instead.

Links

Top comments (0)