Testing Email Flows in Your CI/CD Pipeline
Most applications send critical email — verification codes, magic links, password resets. Testing these flows against a real SMTP path, not a mock, is the only way to catch production failures before your users do.
The problem with mocking email in tests
The standard testing approach for email flows is to mock the email delivery layer. Your test asserts that the sendEmail() function was called with the right arguments, or that a POST /api/send-verification endpoint returns 200. The test passes. Deployment proceeds. In production, the SMTP provider rejects the message due to a DKIM misconfiguration, or the template renders broken HTML on a specific email client, or the OTP extraction logic fails for a specific code format. Users cannot complete sign-up.
The mock caught nothing because it tested the call, not the delivery. Testing email flows properly means sending real email to a real inbox and asserting on what arrives.
What a proper email test looks like
A proper email integration test:
- Creates a unique inbox with a unique, deliverable email address
- Triggers the application's email flow (e.g., submits a sign-up form)
- Waits for the email to arrive in the inbox
- Parses the email content (OTP code, verification link, body HTML)
- Completes the flow (enters the OTP or clicks the link)
- Asserts that the correct post-verification state has been reached
This exercises the full stack: your application's email trigger logic, the SMTP provider, DNS resolution, DKIM signing, message formatting, and your template rendering.
TempMailGrab API quickstart
The TempMailGrab API creates on-demand inboxes for exactly this use case. Authentication uses a Bearer token or X-API-Key header. The core workflow:
// Create an inbox
const createRes = await fetch('https://tempmailgrab.com/api/v1/inbox', {
method: 'POST',
headers: { Authorization: 'Bearer YOUR_API_KEY' },
});
const { id, address } = await createRes.json();
// address: "xk7m2pqr9@tempmailgrab.com"
// Trigger your email flow with `address`
await submitSignupForm({ email: address, password: 'Test123!' });
// Poll for the verification email
let otp = null;
for (let i = 0; i < 15; i++) {
await new Promise(r => setTimeout(r, 2000));
const pollRes = await fetch(
`https://tempmailgrab.com/api/v1/inbox/${id}/messages`,
{ headers: { Authorization: 'Bearer YOUR_API_KEY' } }
);
const { messages } = await pollRes.json();
const msg = messages.find(m => m.extracted_otp);
if (msg) { otp = msg.extracted_otp; break; }
}
// Complete verification
await submitOtpForm({ otp });
The extracted_otp field contains the detected code string (e.g. "847291") or null if no OTP was found. No regex parsing needed in your test code.
Parallelizing with independent inboxes
One inbox per test run is the pattern that makes tests safe to parallelize. Tests do not share state because each inbox is independently scoped. Test A and Test B each have their own address; their emails arrive independently without interference.
// Vitest / Jest parallel test setup
beforeEach(async (ctx) => {
const res = await fetch('https://tempmailgrab.com/api/v1/inbox', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.TMG_API_KEY}` },
});
ctx.inbox = await res.json();
});
test('signup sends OTP', async ({ inbox }) => {
await triggerSignup(inbox.address);
const otp = await waitForOtp(inbox.id);
expect(otp).toMatch(/^d{6}$/);
});
Webhook-based tests (no polling)
For CI environments where latency matters, webhooks eliminate the polling interval entirely. Register a webhook URL on the inbox before triggering the email. TempMailGrab POSTs the parsed message payload to your endpoint within milliseconds of processing.
// Register webhook before triggering email
await fetch('https://tempmailgrab.com/api/v1/inbox/' + inboxId + '/webhook', {
method: 'POST',
headers: {
Authorization: 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: 'https://your-test-receiver.example.com/hook',
}),
});
The webhook payload includes the full parsed message with extracted_otp and extracted_links. Your test receiver resolves a Promise when the target message arrives, and your test proceeds immediately — no polling, no retry loop, no sleep.
Playwright end-to-end example
import { test, expect } from '@playwright/test';
import { createInbox, waitForOtp } from './helpers/email';
test('email OTP sign-up flow', async ({ page }) => {
const { id, address } = await createInbox();
await page.goto('/signup');
await page.fill('[name=email]', address);
await page.fill('[name=password]', 'SecurePass123!');
await page.click('[type=submit]');
// Wait for OTP screen
await expect(page.locator('[data-testid=otp-input]')).toBeVisible();
const otp = await waitForOtp(id, 30_000);
await page.fill('[data-testid=otp-input]', otp);
await page.click('[data-testid=verify-btn]');
await expect(page.locator('[data-testid=dashboard]')).toBeVisible();
});
Rate limits and parallelism
The API allows 100 requests per 10-second window per key. For parallel test suites running 50+ tests simultaneously, stagger inbox creation across the setup phase rather than creating all inboxes simultaneously. The API returns X-RateLimit-Remaining and X-RateLimit-Reset headers to help manage burst usage.
For large test suites with hundreds of tests, consider reusing a single inbox per suite (creating a new one per test file, not per test case), using the inbox TTL to naturally expire old inboxes, and running email-dependent tests in a dedicated suite with its own concurrency limit.
What you should still mock
Not everything email-related needs a real inbox. Mock these:
- Unit tests for email template rendering (verify the HTML structure, not delivery)
- Unit tests for OTP generation logic (verify uniqueness and expiry, not transmission)
- Load tests (do not load-test your SMTP provider with fake traffic)
- Tests for the unsubscribe flow, where the email content is not the variable being tested
Use real inboxes for integration and end-to-end tests where the email delivery is part of the user flow being validated.