🎉 Limited Time Offer: 30% OFF First Month!

Use code at checkout - Valid on all plans

View Plans

🌐 Custom Domains Now Available!

Receive emails at [email protected] — available on all paid plans

Learn More
Back to Blog
Testing12 min read

How to Test Email Verification Flows in Jest Without a Real Inbox

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:

  1. 1Creates a fresh disposable inbox via the OpenInbox API
  2. 2Submits your app’s signup form with that temp address
  3. 3Polls the inbox until the verification email arrives
  4. 4Extracts the confirmation link from the email body
  5. 5Visits the link and asserts the account is verified
  6. 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:

terminal
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):

.env
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:

test/helpers/openinbox.ts
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:

test/email-verification.test.ts
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:

.github/workflows/test.yml (excerpt)
- name: Run email verification tests
  env:
    OPENINBOX_API_KEY: ${{ secrets.OPENINBOX_API_KEY }}
  run: npx jest test/email-verification.test.ts --runInBand

Use --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.