Skip to main content

Our email CSS engine is open source. Give it a star on GitHub 

Emailens
Blog

How to Catch Outgoing Emails in a Staging Environment

Point staging's mail settings at a catcher, a local SMTP server such as Mailpit or a hosted sandbox, that accepts every message and delivers none; then take production mail credentials out of staging and scrub real addresses from copied data.

Philippe Kam, founder of Emailens · Published 11 Nov 2026

@emailens/engine 0.18.22 · data last changed 10 Nov 2026

Part of the HTML email guide · all guides

The usual way a staging server emails real customers: someone copies the production database to staging, the mail settings still hold the production provider's key, and a test run sends a password reset, an invoice or a "your order has shipped" to every address in it. Staging should accept every email it sends and deliver none of them.

Three ways to stop staging email

ApproachWhat you seeWhat can still go wrong
Catch: send to an SMTP server that delivers nothingEvery message, in a web inboxMail sent by third-party services never reaches it
Reroute: rewrite every recipient to one addressThe real email, in a real inboxA code path that skips the rewrite emails the customer
Disable: log or print messages instead of sendingRaw text in a logYou never see how the email looks

Catching is the safe default: nothing leaves the machine, whatever the code does. The rest of this guide sets it up, then covers rerouting for when you need a real inbox, and the checks that prove nothing leaks.

Run a catcher: Mailpit

Mailpit is a small SMTP server that accepts any message, keeps it, and shows it in a web inbox. It replaces MailHog, whose last release was v1.0.1 in August 2020; both use port 1025 for SMTP and 8025 for the web interface, so moving from one to the other is a change of image. Run it next to your staging app:

Mailpit with Docker
6 lines
docker run -d --restart unless-stopped --name=mailpit \
  -p 8025:8025 -p 1025:1025 \
  axllent/mailpit

# SMTP:          127.0.0.1:1025
# Web interface: http://127.0.0.1:8025

Keep port 8025 private, behind your VPN or an SSH tunnel: whoever can reach it can read every message your staging app sends. Mailpit also has a REST API, so a test can fetch the message it just triggered and read the link or code inside.

Point your framework at it

Each framework reads its mail settings per environment. Set them in staging's own environment, never in a shared config file that production also loads. In Laravel:

Laravel
9 lines
# .env on the staging server
MAIL_MAILER=smtp
MAIL_HOST=127.0.0.1
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null

# Or write every message to the log and send nothing
# MAIL_MAILER=log

With Laravel Sail, Mailpit already runs as a service and its inbox is at http://localhost:8025. In Rails:

Rails
3 lines
# config/environments/staging.rb
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = { address: "127.0.0.1", port: 1025 }

In Django:

Django
7 lines
# settings for staging
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = "127.0.0.1"
EMAIL_PORT = 1025

# Or print every message to the console and send nothing
# EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"

Django's test runner already swaps in an in-memory backend, so this matters for a running staging server, not for your unit tests. In Node with Nodemailer:

Node
8 lines
import nodemailer from "nodemailer";

// Read from the environment, so staging never holds production values
const transporter = nodemailer.createTransport({
  host: process.env.SMTP_HOST, // 127.0.0.1 on staging
  port: Number(process.env.SMTP_PORT), // 1025 on staging
  secure: false,
});

For a quick look without running anything, Nodemailer's Ethereal is a free hosted fake SMTP service: messages are never delivered and are kept for 15 days.

Queue workers and scheduled jobs send most transactional email. They often run in their own process, container or server, with their own environment; check that they read the staging settings too, or they keep sending through production.

Reroute everything to one inbox

Sometimes you need the real thing: the email sent by your real provider, opened in a real client, on your phone. Rerouting keeps delivery and rewrites the recipient. Laravel has it built in:

Laravel
10 lines
// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Mail;

public function boot(): void
{
    if ($this->app->environment('staging')) {
        // Every message goes here; cc and bcc are removed
        Mail::alwaysTo('qa@yourcompany.com');
    }
}

In Rails, an interceptor runs before every delivery. The example in the Rails guide rewrites only the to address; clear cc and bcc too, or the people on them still get the email:

Rails
11 lines
# app/mailers/sandbox_email_interceptor.rb
class SandboxEmailInterceptor
  def self.delivering_email(message)
    message.to = ["qa@yourcompany.com"]
    message.cc = nil   # the Rails guide's example only rewrites "to":
    message.bcc = nil  # without these, cc and bcc still go out
  end
end

# config/environments/staging.rb
config.action_mailer.interceptors = %w[SandboxEmailInterceptor]

Rerouted mail is real mail: it uses your provider's quota and your domain's reputation, and any email sent outside the framework's mailer, by a raw SMTP call or a provider SDK, skips the rewrite. Catch by default; reroute on purpose.

When a local catcher is not enough

A catcher on one machine stops working when the staging app runs on several machines, when CI needs to read the email a test just triggered, or when the email comes from a service you don't run. Auth0, Supabase or Stripe send their sign-up, magic-link and receipt emails from their own servers, so your SMTP settings never see them.

A hosted sandbox covers those cases. The Emailens sandbox gives a free account a private receiving address for mail sent by other services, and a paid plan adds an SMTP relay with credentials per service. It holds every message and delivers none:

staging environment
5 lines
# Staging environment: a hosted sandbox instead of a local catcher
SMTP_HOST=smtp.emailens.dev
SMTP_PORT=2525          # or 587; STARTTLS is required
SMTP_USER=esmtp_staging_clx9
SMTP_PASS=<per-service credential>

A CI test can wait for the message and read the one-time code or link extracted from it, and you can open the caught email in a live preview of 21 email clients to see how it renders before it ships. For where it differs from a local catcher, see Emailens vs Mailpit.

Check that nothing leaks

Settings drift. Four checks keep staging safe after the next deploy, the next new worker and the next database refresh:

  1. Take production mail credentials out of staging entirely: no provider API key, no SMTP password. Without them, a wrong setting fails instead of mailing a customer.
  2. Scrub real addresses when you copy production data. Rewrite them to a domain that can never receive mail:
after each refresh
4 lines
-- Run on the staging copy, never on production.
-- .test is reserved (RFC 6761): mail to it can never be delivered.
UPDATE users
SET email = CONCAT('user-', id, '@example.test');
  1. Prove it after every change: trigger a password reset for an address you own and confirm it arrives in the catcher, not in your inbox.
  2. Check the senders you don't control: list every third-party service that emails your users and confirm its staging project uses test addresses.

To check a mail server itself from the command line, see how to test an SMTP server. Before an email goes to real inboxes, run it through the pre-send checklist.

Questions

Is MailHog still maintained?

Its last release, v1.0.1, came out in August 2020. Mailpit is the actively maintained alternative and uses the same default ports, SMTP on 1025 and the web interface on 8025, so switching is usually a change of Docker image and nothing else.

What ports does Mailpit use?

SMTP on 1025 and the web interface on 8025. Point your app's mail host at the machine running Mailpit on port 1025, then open port 8025 in a browser to read what was caught. Keep 8025 off the public internet: anyone who can reach it can read your test mail.

How do I stop Laravel sending real emails in staging?

Set MAIL_MAILER=log to write every message to the log instead of sending it, or keep MAIL_MAILER=smtp and point MAIL_HOST and MAIL_PORT at a catcher such as Mailpit. To deliver everything to one inbox instead, call Mail::alwaysTo() in a service provider for the staging environment; it also removes cc and bcc addresses.

How do I catch emails that Auth0, Supabase or Stripe send from staging?

You can't with your own SMTP settings: those services send from their servers, not through your app. Give your test users addresses at a domain that captures mail, such as a sandbox receiving address, or use the service's own test mode. Never sign test accounts up with real customers' addresses.

What's the difference between catching and rerouting email?

A catcher accepts every message and delivers none, so nothing can leak. Rerouting still sends through your real provider but changes the recipient to one inbox you own; you see the email in a real client, but it counts against your sending quota and reputation, and any code path that bypasses the rewrite emails the real recipient.

Your next question

Also useful: Deliverability Checker · Email Header Analyzer · Emailens vs Mailpit

Catch every email your staging app sends, read the codes and links in it, and preview it in 21 clients.

Catch your staging email, free

Get new email CSS fixes by email, or follow by RSS.