Skip to content
·8 min read

How to Test Email Sending Without Spamming Real Users

Share

You build the feature, wire up the email, and hit send in production. Then your inbox fills up with replies from real users who just received your test email. It has happened to every developer at least once. Here is how to make sure it never happens to you.

Why Email Testing Is Tricky

Email is deceptively simple from a code perspective. You call a function, pass a recipient and some content, and the provider handles the rest. But that simplicity hides a few problems that make testing genuinely hard.

The first problem is side effects. Unlike a database write you can roll back, a sent email is permanent. There is no undo. If your staging environment is pointed at a real SMTP server with real addresses in your seed data, you will send real emails to real people.

The second problem is deliverability. Your code can return a 200 and the email still ends up in spam, bounces silently, or never arrives at all. The API call succeeded but the outcome failed. You need a way to inspect what was actually sent, including headers, HTML rendering, and spam scores, not just confirm the function ran.

The third problem is cost at scale. Email providers charge per send. Running automated tests against a live provider burns your quota and can trigger rate limits or suspension on free-tier accounts.

These three problems compound when you add a CI pipeline. Tests run on every push, potentially dozens of times a day. Without a proper email testing setup, you are either skipping email tests entirely or paying for them in real sends and real dollars.

Diagram showing three environments (local dev, staging, CI) each routing email through a dedicated test interceptor instead of a live SMTP provider, with arrows showing safe capture versus dangerous live sending

Using Email Testing Services

The good news is that the ecosystem around this problem is mature. Several tools intercept outgoing email before it reaches real inboxes.

Mailtrap is the most popular hosted option. You point your SMTP config at Mailtrap's servers during development, and every email lands in a shared team inbox on their dashboard. You can inspect raw headers, preview HTML and plaintext rendering, check spam scores, and share emails with teammates. Their free tier gives you 1,000 emails per month with a 5-day retention window. Paid plans start at $15/month for higher volume and longer retention. Mailtrap also has an Email Sandbox API if you want to trigger assertions programmatically.

Mailhog is the self-hosted alternative. It runs as a tiny Go binary or Docker container alongside your app, exposes a fake SMTP server on port 1025, and serves a web UI on port 8025. Because it runs locally, there is no cost, no rate limit, and no data leaving your machine. The tradeoff is that it requires a bit of infrastructure setup and does not persist data between restarts. For most local dev workflows it is the right default.

# Run Mailhog with Docker
docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog

Ethereal Email from Nodemailer gives you a disposable SMTP account you generate on demand. It is useful for quick one-off testing without spinning up Docker. Emails are viewable in the Ethereal web UI for a limited time before they expire.

Resend test mode works differently from the others. Instead of intercepting SMTP, Resend has a dedicated test API key prefix (re_test_...). Emails sent with a test key are accepted by the API, logged in your Resend dashboard under a Test section, but never actually delivered. This is useful if you are already using Resend in production and want zero infrastructure overhead for testing. The catch is that it only works within the Resend ecosystem, and you need a paid plan ($20/month) to get the full test mode dashboard with HTML previews.

Key Takeaway

Use Mailhog locally via Docker for zero-cost local development, Mailtrap for shared team review and spam scoring, and Resend test mode if your stack already uses Resend in production. Pick one per environment, not one per developer.

Testing Email Content and Delivery

Intercepting emails is only half the job. You also need to assert that the content is correct.

For HTML emails, open the intercepted message in your testing tool's preview and check it manually first. Look for broken layouts at mobile widths, images that do not load, and links that point to localhost instead of production URLs. This is easy to miss when you are only checking that the function ran.

For automated assertions, Mailtrap's API and Mailhog's REST API both let you query received messages programmatically. With Mailhog you can fetch the latest message and assert on subject, recipient, and body content.

// Fetch the latest email from Mailhog in a test
const res = await fetch("http://localhost:8025/api/v2/messages?limit=1");
const data = await res.json();
const latest = data.items[0];

expect(latest.Content.Headers.Subject[0]).toBe("Welcome to the app");
expect(latest.Raw.To[0]).toBe("newuser@example.com");

Keep these assertions focused. Test that the subject is correct, the recipient is right, and one or two key pieces of dynamic content appear in the body. Do not try to assert on the full HTML string; it will break every time you tweak the template.

For deliverability testing beyond your local environment, Mailtrap's spam analysis and Mail Tester (mail-tester.com) are worth running manually before you go live. They check SPF, DKIM, and DMARC alignment alongside content scoring.

Screenshot-style diagram of a test assertion flow where code sends an email, Mailhog REST API returns the captured message as JSON, and test assertions check subject, recipient, and body fields

Automated Email Testing in CI

Local testing with Mailhog works great on your machine, but CI needs its own approach. The simplest setup is running Mailhog as a Docker service alongside your test runner.

Here is a GitHub Actions config that starts Mailhog before your tests run and tears it down after.

# .github/workflows/test.yml
services:
  mailhog:
    image: mailhog/mailhog
    ports:
      - 1025:1025
      - 8025:8025

steps:
  - name: Run tests
    env:
      SMTP_HOST: localhost
      SMTP_PORT: 1025
    run: npm test

Your app reads SMTP_HOST and SMTP_PORT from environment variables, so in CI it points to the Mailhog service container and in production it points to your real provider. No code changes needed between environments.

If you are using Resend in production, keep a separate RESEND_API_KEY for CI that uses the test key prefix. Your tests call the real Resend API, emails get logged to the test dashboard, and nothing reaches real inboxes.

Common Mistake

Never put a real SMTP password or live provider API key in your CI environment variables and call it a "test" environment. All it takes is one seed file with a real email address for test emails to reach real users. Use a dedicated test interceptor at the infrastructure level.

For end-to-end tests with Playwright or Cypress, consider Mailosaur. It gives you real email addresses that route into a programmable inbox your tests can poll. This lets you test full flows like "user signs up, receives welcome email, clicks confirmation link" without any fake SMTP setup.

One thing worth wiring up in CI regardless of which tool you pick is a test that asserts no email is sent unexpectedly. If a code change accidentally triggers an email outside the expected flow, you want the test suite to catch it. Keep a counter on sent messages in your test setup and assert it at zero before each test that should not produce email output.

What This Means For You

Email testing feels like an afterthought until you accidentally send a password reset to a customer at 2am because your staging seed script ran against production credentials. Setting this up properly takes about 30 minutes and saves you that embarrassment indefinitely.

Start with Mailhog in Docker for local dev. Add a Mailhog service to your CI workflow. If you are already on Resend, grab a test key and add it to your staging environment. That is the whole setup.

The bigger win is that once emails are interceptable, you will actually write assertions for them. Features that send email get test coverage they never had before, and you can refactor email templates with confidence that the content is still correct.

Ship with more confidence

Read more tutorials on building and shipping software that works reliably in production.

Browse more tutorials

Pick your tool, point your SMTP config at it, and write one test today. Your future self will appreciate it.

Found this useful?

More practical guides on testing, shipping, and building indie products are published here regularly.

See all posts
PJ
Pranay Joshi

20+ years building products at scale. VP of Product & Engineering, startup founder, and AI coach. Helping dreamers turn ideas into reality with vibe coding.

The Tuesday Shipping Report

Every Tuesday, one focused email:

  • - The tool or technique that's actually working right now
  • - A real problem from the community (and how to solve it)
  • - What changed this week in the vibe coding landscape

Read by 1,000+ founders, developers, and creators building with AI. Free forever. No spam.