Every SaaS app sends a verification email after signup. But how do you actually test that flow end-to-end? Most teams skip it, mock it, or test against a shared Gmail account that breaks every other week. There's a better way: use a disposable email API to create a real inbox, receive the real email, and assert on the real verification link — all inside a Jest test that finishes in under 15 seconds.
The Problem With Testing Email Verification
Email verification is one of the most critical flows in any application — it's the very first thing a new user does after signing up. Yet it's also one of the hardest things to test reliably. Here's why the common approaches break down:
Shared Gmail accounts
Emails pile up, parallel test runs collide, and Google locks the account after too many IMAP logins.
Mocking the email service
You test your mock, not your real email pipeline. Template bugs, broken links, and missing headers slip through.
Mailtrap / SMTP capture
Requires changing SMTP config per environment, doesn't work well in CI without extra setup, and emails never actually arrive.
Skipping the test entirely
Broken verification emails are now discovered by real users in production. Not a great look.
The ideal solution uses real email delivery but with throwaway inboxes you can create and tear down programmatically. That's exactly what a disposable email API gives you.
What You'll Build
By the end of this guide you'll have a Jest integration test that:
- 1Creates a fresh disposable inbox via the OpenInbox API
- 2Submits your app’s signup form with that temp address
- 3Polls the inbox until the verification email arrives
- 4Extracts the confirmation link from the email body
- 5Visits the link and asserts the account is verified
- 6Tears down cleanly (the inbox auto-expires)
The whole test runs in your CI pipeline with zero manual intervention. Let's set it up.
Prerequisites
Before writing the test, you'll need:
- Node.js 18+ and Jest configured in your project
- An OpenInbox API key (free tier works — grab one at /for-developers)
- A running instance of your app that sends real verification emails
- The node-fetch or undici library (or native fetch in Node 18+)
Install the test helper:
npm install --save-dev jest @types/jest ts-jest
Set your API key as an environment variable. In CI, store it as a secret (we'll cover GitHub Actions at the end):
OPENINBOX_API_KEY=your_api_key_here
Step 1 — Create the OpenInbox Helper Module
First, wrap the OpenInbox API in a small helper so tests stay clean:
const BASE = 'https://openinbox.io/api/v1';
const API_KEY = process.env.OPENINBOX_API_KEY!;
interface Inbox {
id: string;
email: string;
expiresAt: string;
}
interface Email {
id: string;
from: string;
subject: string;
receivedAt: string;
isRead: boolean;
preview: string;
}
interface FullEmail extends Email {
textBody: string;
htmlBody: string;
}
const headers = { 'X-API-Key': API_KEY };
/**
* Create a new disposable inbox.
* Returns the inbox id + email address.
*/
export async function createInbox(): Promise<Inbox> {
const res = await fetch(`${BASE}/inboxes`, {
method: 'POST',
headers,
});
if (!res.ok) throw new Error(`createInbox failed: ${res.status}`);
const json = await res.json();
return json.data;
}
/**
* Poll the inbox until at least one email arrives.
* Throws after `timeoutMs` (default 30 s).
*/
export async function waitForEmail(
inboxId: string,
timeoutMs = 30_000,
intervalMs = 2_000,
): Promise<Email> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await fetch(`${BASE}/inboxes/${inboxId}/emails`, { headers });
const json = await res.json();
if (json.data.length > 0) return json.data[0];
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(`No email received within ${timeoutMs} ms`);
}
/**
* Fetch the full content of a single email (body + attachments).
*/
export async function getEmail(emailId: string): Promise<FullEmail> {
const res = await fetch(`${BASE}/emails/${emailId}`, { headers });
if (!res.ok) throw new Error(`getEmail failed: ${res.status}`);
const json = await res.json();
return json.data;
}
/**
* Delete an inbox (optional — inboxes auto-expire).
*/
export async function deleteInbox(inboxId: string): Promise<void> {
await fetch(`${BASE}/inboxes/${inboxId}`, {
method: 'DELETE',
headers,
});
}The helper is completely framework-agnostic — it works with Jest, Vitest, Mocha, or anything else that speaks TypeScript. All endpoints are documented in the API reference.
Step 2 — Write the Verification Test
Here's the complete test. It signs up a user, waits for the verification email, extracts the confirmation URL, and hits it:
import { createInbox, waitForEmail, getEmail } from './helpers/openinbox';
// Increase timeout — we're hitting real networks
jest.setTimeout(60_000);
describe('Email verification flow', () => {
let inboxId: string;
it('should send a verification email and confirm the account', async () => {
// 1. Create a disposable inbox
const inbox = await createInbox();
inboxId = inbox.id;
expect(inbox.email).toMatch(/@/);
console.log(`Test inbox: ${inbox.email}`);
// 2. Hit your app's signup endpoint
const signupRes = await fetch('https://staging.yourapp.com/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: inbox.email,
password: 'Str0ng!Pass',
name: 'Test User',
}),
});
expect(signupRes.status).toBe(201);
// 3. Poll the inbox until the verification email arrives
const emailSummary = await waitForEmail(inbox.id);
expect(emailSummary.subject.toLowerCase()).toContain('verify');
// 4. Fetch the full email body
const fullEmail = await getEmail(emailSummary.id);
expect(fullEmail.textBody).toBeDefined();
// 5. Extract the verification link
const linkMatch = fullEmail.textBody.match(
/https?:\/\/[^\s]+verify[^\s]*/i,
);
expect(linkMatch).not.toBeNull();
const verifyUrl = linkMatch![0];
console.log(`Verification URL: ${verifyUrl}`);
// 6. Visit the verification link
const verifyRes = await fetch(verifyUrl, { redirect: 'follow' });
expect(verifyRes.ok).toBe(true);
// 7. Confirm the account is now verified
const profile = await fetch('https://staging.yourapp.com/api/me', {
headers: {
'Content-Type': 'application/json',
// Use session from signup or login here
},
});
// Depending on your app, assert on the profile response
expect(profile.status).toBeLessThan(500);
});
// Optional: clean up the inbox (it auto-expires anyway)
afterAll(async () => {
if (inboxId) {
await fetch(`https://openinbox.io/api/v1/inboxes/${inboxId}`, {
method: 'DELETE',
headers: { 'X-API-Key': process.env.OPENINBOX_API_KEY! },
});
}
});
});How Each Step Works Under the Hood
Creating the inbox
POST /api/v1/inboxes returns a unique email address like [email protected]. The inbox lives for one hour by default and accepts emails from any sender. You can create as many as your plan allows.
Polling for emails
GET /api/v1/inboxes/:inboxId/emails returns a list of received emails. The helper polls every 2 seconds with a 30-second timeout. In practice, emails arrive within 3–5 seconds. If you need instant notification, set up a webhook instead.
Reading the full email
GET /api/v1/emails/:emailId returns the complete email object including textBody, htmlBody, headers, and attachments. You extract the verification link from the text body with a simple regex.
Cleanup
Inboxes auto-expire after one hour, so you don't need to clean up. But calling DELETE /api/v1/inboxes/:inboxId in afterAll keeps things tidy if you're running hundreds of tests a day.
Running the Test in GitHub Actions
Drop this into your workflow file. The only secret you need is the API key:
- name: Run email verification tests
env:
OPENINBOX_API_KEY: ${{ secrets.OPENINBOX_API_KEY }}
run: npx jest test/email-verification.test.ts --runInBandUse --runInBand so email tests run sequentially. Parallel test runs work fine since every test creates its own inbox, but sequential execution keeps API usage predictable during rollout.
Pro Tips for Reliable Email Tests
Use a generous timeout
Email delivery depends on DNS propagation, SMTP queues, and your email provider's processing time. 30 seconds is safe; 60 seconds gives breathing room in CI.
Test the HTML body too
Don't just check that the link exists in the text body. Parse htmlBody as well and assert that CTA buttons link to the right URL. Broken HTML templates are one of the most common email bugs.
Assert on the sender address
Verify that the "from" field matches your expected sender (e.g., [email protected]). If a config change accidentally swaps the sender, this catches it early.
Run one integration test per email type
Verification, password reset, welcome email, OTP — give each its own test file. It keeps failures isolated and CI logs readable.
Pair with unit tests, don't replace them
Unit-test your email templates and rendering logic separately. The integration test with OpenInbox validates the full delivery path — SMTP config, DNS, template rendering, and link generation.
Ready to test email flows the right way?
Create a free OpenInbox account, grab your API key, and run your first email verification test in under 5 minutes.