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
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);
}
});
Key points:
- Each test gets its own inbox — no shared state, no race conditions
- The
finallyblock 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);
});
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 }}
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
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
- GoneBox: gonebox.email
- API docs: gonebox.email/api-docs
- Rate limits: 60 req/min public, higher with API key
- Inbox TTL: 1 hour (auto-cleanup, no manual deletion required)
Top comments (0)