Email flows are among the hardest parts of a web application to test properly. Registration confirmations, password resets, transactional emails, onboarding sequences — they all require a working inbox to verify.
Using real email addresses for this is a bad idea: it clutters inboxes, risks sending test emails to real users, and doesn't scale to automated testing. Disposable and temporary email addresses solve all three problems.
This guide covers the full toolkit — from quick manual testing with a temp inbox to fully automated email verification in CI/CD pipelines.
The Core Problem With Email Testing
When you build an email flow, you need to verify:
- The email was actually sent
- It was delivered (not bounced or caught by spam filters)
- The content is correct (subject, body, links)
- The links in the email work correctly (confirmation links, password reset tokens)
- The flow completes correctly after the link is clicked
Doing this with real email addresses creates several problems:
- Inbox pollution — Your team's inboxes fill with hundreds of test emails
- Accidental sends to real users — A misconfigured environment variable and your test emails go to production users
- No programmatic access — You can't read a real inbox from a CI/CD pipeline without OAuth or IMAP configuration
- Slow feedback loops — Checking a real inbox manually for every test run doesn't scale
Tool Categories for Email Testing
There are four main approaches, each suited to different situations:
1. Public Disposable Inboxes (Manual Testing)
Services like InstantTempEmail, Guerrilla Mail, or 10 Minute Mail give you a working inbox instantly. Use these when you're manually testing a flow and just need to receive a verification email quickly.
When to use:
- Local development testing of a new email flow
- One-off QA checks during development
- Testing how your email renders in a real inbox
When not to use:
- Automated tests — you can't programmatically read these inboxes in a test suite
2. Development SMTP Catchers (Automated, Isolated)
These tools intercept all outgoing emails in your development or staging environment — nothing is actually delivered to real addresses. You inspect the captured emails via a web UI or API.
Mailtrap is the most widely used. You configure your app to send via their SMTP server in non-production environments. All emails go to a sandbox inbox.
MailHog is a self-hosted alternative — a lightweight Go application that runs an SMTP server and web UI locally. Zero cost, zero external dependency.
Mailpit is a newer MailHog replacement with a better UI and active development.
3. Public API Inboxes (Automated, With Inbox Access)
Services like Mailinator provide public inboxes accessible via HTTP API. You send email to testuser@mailinator.com from your app, then check the inbox via their API in your test suite.
When to use:
- End-to-end tests that need to verify email content and click links
- CI/CD pipelines where you need inbox access without OAuth
- Testing email-triggered flows (confirmation → redirect → onboarding)
4. Transactional Email Testing Services
Services like Mailtrap (their testing product, separate from the sandbox), Postmark's test mode, or SendGrid's sandbox mode test the full email sending pipeline — API calls, rendering, deliverability — without actually sending.
Setting Up MailHog for Local Development
MailHog is the easiest way to set up email testing locally. It runs an SMTP server that captures all outgoing mail and displays it in a web UI.
Install with Docker Compose
Add MailHog to your docker-compose.yml:
services:
mailhog:
image: mailhog/mailhog
ports:
- "1025:1025" # SMTP port
- "8025:8025" # Web UI port
restart: unless-stopped
Configure your app to use MailHog
In your .env for development:
MAIL_HOST=localhost
MAIL_PORT=1025
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_ENCRYPTION=null
All emails your app sends in development now go to MailHog. Access the inbox at http://localhost:8025.
Why this is better than real email in development
- Emails never leave your machine — no accidental sends to real users
- Instant delivery — no waiting for real SMTP delivery
- Full email content visible in browser — headers, HTML, text parts, attachments
- No authentication required
- Easy to clear all emails between test runs
Automated Email Testing With Mailinator API
For end-to-end tests that need to read emails programmatically, Mailinator's API is the most straightforward option.
The free tier has limits, but their paid API is affordable for most teams.
Basic inbox check with fetch
async function getLatestEmail(inboxName) {
const response = await fetch(
`https://mailinator.com/api/v2/domains/mailinator.com/inboxes/${inboxName}`,
{
headers: {
Authorization: process.env.MAILINATOR_API_KEY
}
}
)
const data = await response.json()
if (!data.msgs || data.msgs.length === 0) {
throw new Error(`No emails found in inbox: ${inboxName}`)
}
return data.msgs[0] // Most recent email
}
async function getEmailBody(messageId) {
const response = await fetch(
`https://mailinator.com/api/v2/domains/mailinator.com/inboxes/test/messages/${messageId}`,
{
headers: {
Authorization: process.env.MAILINATOR_API_KEY
}
}
)
return response.json()
}
Full end-to-end registration test with Playwright
import { test, expect } from '@playwright/test'
const TEST_EMAIL = `e2e-test-${Date.now()}@mailinator.com`
test('user registration and email confirmation', async ({ page }) => {
// Step 1: Register on the site
await page.goto('https://staging.yourapp.com/register')
await page.fill('[name="email"]', TEST_EMAIL)
await page.fill('[name="password"]', 'TestPassword123!')
await page.click('[type="submit"]')
// Expect redirect to "check your email" page
await expect(page).toHaveURL(/check-email/)
// Step 2: Wait for and fetch the confirmation email
const email = await waitForEmail(TEST_EMAIL.split('@')[0], {
timeout: 30000,
subject: 'Confirm your email'
})
// Step 3: Extract confirmation link from email body
const confirmUrl = extractConfirmationLink(email.body)
expect(confirmUrl).toBeTruthy()
// Step 4: Visit the confirmation link
await page.goto(confirmUrl)
// Step 5: Verify successful confirmation
await expect(page).toHaveURL(/dashboard/)
await expect(page.locator('h1')).toContainText('Welcome')
})
async function waitForEmail(inbox, options = {}) {
const { timeout = 15000, subject } = options
const startTime = Date.now()
while (Date.now() - startTime < timeout) {
const response = await fetch(
`https://mailinator.com/api/v2/domains/mailinator.com/inboxes/${inbox}`,
{ headers: { Authorization: process.env.MAILINATOR_API_KEY } }
)
const data = await response.json()
const emails = data.msgs || []
const match = subject
? emails.find(e => e.subject?.includes(subject))
: emails[0]
if (match) {
// Fetch full email body
const bodyResponse = await fetch(
`https://mailinator.com/api/v2/domains/mailinator.com/inboxes/${inbox}/messages/${match.id}`,
{ headers: { Authorization: process.env.MAILINATOR_API_KEY } }
)
return bodyResponse.json()
}
await new Promise(resolve => setTimeout(resolve, 1000))
}
throw new Error(`Email not received within ${timeout}ms`)
}
function extractConfirmationLink(emailBody) {
const htmlContent = emailBody.parts?.[0]?.body || ''
const urlPattern = /https?:\/\/[^\s"'<>]+confirm[^\s"'<>]*/i
const match = htmlContent.match(urlPattern)
return match?.[0] || null
}
Using Unique Addresses Per Test Run
A common mistake is reusing the same test email address across test runs. Old emails pollute the inbox and tests may pick up the wrong email.
Pattern: timestamp-based unique addresses
// Generate a unique inbox per test run
const testRunId = Date.now()
const email = `registration-test-${testRunId}@mailinator.com`
Pattern: UUID-based per test
import { randomUUID } from 'crypto'
function testEmail(prefix = 'test') {
return `${prefix}-${randomUUID().slice(0, 8)}@mailinator.com`
}
// Usage
const email = testEmail('registration') // registration-a1b2c3d4@mailinator.com
This ensures each test run has a fresh inbox with no history.
Setting Up Mailpit (Modern MailHog Replacement)
Mailpit has a better UI, active development, and more features than MailHog. It's the better choice for new setups.
# docker-compose.yml
services:
mailpit:
image: axllent/mailpit
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI
environment:
MP_MAX_MESSAGES: 500
MP_DATABASE: /data/mailpit.db
MP_SMTP_AUTH_ACCEPT_ANY: 1
MP_SMTP_AUTH_ALLOW_INSECURE: 1
volumes:
- mailpit_data:/data
volumes:
mailpit_data:
Mailpit also has a REST API for programmatic inbox access, making it viable for automated tests in a Docker Compose environment:
// Mailpit API — for use in local/CI Docker Compose setups
async function getMailpitInbox() {
const response = await fetch('http://localhost:8025/api/v1/messages')
return response.json()
}
async function deleteAllMessages() {
await fetch('http://localhost:8025/api/v1/messages', { method: 'DELETE' })
}
Clear the inbox at the start of each test run with deleteAllMessages() to ensure a clean state.
Preventing Accidental Production Email Sends
This is critical. A misconfigured environment variable that points staging to production SMTP credentials will send real emails to real users.
Approach 1: Environment-based SMTP switching
// email.config.js
const emailConfig = {
development: {
host: 'localhost',
port: 1025,
secure: false,
auth: null
},
test: {
host: 'localhost',
port: 1025,
secure: false,
auth: null
},
staging: {
host: process.env.MAILHOG_HOST || 'mailhog',
port: 1025,
secure: false
},
production: {
host: process.env.SMTP_HOST,
port: 587,
secure: true,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
}
}
}
export const smtpConfig = emailConfig[process.env.NODE_ENV] || emailConfig.development
Approach 2: Domain allowlist for non-production environments
function shouldSendEmail(toAddress) {
if (process.env.NODE_ENV === 'production') return true
const allowedTestDomains = [
'mailinator.com',
'guerrillamail.com',
'instanttempemail.com',
'yourcompany.com' // Internal test addresses
]
const domain = toAddress.split('@')[1]
return allowedTestDomains.includes(domain)
}
Testing Email Rendering Across Clients
The tools above verify email delivery and content, but not how the email renders in different clients. Email HTML behaves differently across Gmail, Outlook, Apple Mail, and mobile clients.
For rendering tests, use Email on Acid or Litmus — they render screenshots of your email across 90+ clients. Both have free trials.
For simpler checks, the Mailtrap email preview feature renders your email in common client contexts.
Frequently Asked Questions
Can I use Gmail or Outlook for automated testing? Technically yes, using OAuth or IMAP access. In practice, it's slower, more complex to set up, and introduces rate limits and authentication management. Dedicated tools are always faster and more reliable.
What's the difference between Mailtrap and MailHog? Mailtrap is a hosted SaaS service — your emails are sent to their servers and viewable in their web UI. MailHog is self-hosted — it runs on your machine or in your Docker Compose setup. MailHog has zero cost and zero data leaving your environment. Mailtrap is easier to share across a team.
Is Mailinator safe to use for sensitive test data? No. Mailinator inboxes are public — anyone who knows the inbox name can read the emails. Use test data only. Never use real user data in Mailinator inboxes. For private automated testing, use MailHog or Mailpit in your Docker Compose setup instead.
How do I test email flows in a GitHub Actions CI pipeline?
Add a MailHog or Mailpit service to your GitHub Actions workflow using the services key in your workflow YAML. Your app sends to localhost:1025, and your tests read via the local API.
jobs:
test:
services:
mailpit:
image: axllent/mailpit
ports:
- 1025:1025
- 8025:8025
Should I test with real email providers in staging? Only if you specifically need to test deliverability (spam scoring, DKIM/DMARC, inbox placement). For functional testing of email flows, a local catcher like MailHog is faster and safer. Run real deliverability checks separately, less frequently, using test accounts at major providers.