Josh Davies

Senior Software Engineer

I build beautiful, engaging, and perfectly executed frontend and backend experiences. While finding time to write, hike and read.

Blog

Staging Should Never Email a Real Customer

  • laravel
  • typescript
  • wordpress
  • devops
  • email

Here's a fun way to ruin a Thursday. Someone copies the production database into staging so they can reproduce a bug. Staging has a working mail config because somebody needed to test the password reset flow last month. A queued job wakes up, finds a few hundred "overdue" invoices, and does exactly what it was built to do.

Now real customers have emails about invoices that don't exist, from a server that isn't production, with links that point at a URL they were never supposed to see.

Nobody wrote a bug. Every piece worked. That's what makes it annoying.

"Be careful" is not a control

The usual fix is a Slack message that says "heads up, don't run the reminder job on staging." That lasts until the next person who didn't read it.

The goal is simple: outside production, it should be impossible for a message to reach a real person unless you deliberately allow it. Not unlikely. Impossible.

That means fixing it in the code and config, not in people's memories.

Laravel: catch mail before it leaves

For local work, the easy answer is to never send at all. Use the log mailer, or point at a local catcher like Mailpit (Laravel Sail ships with it):

MAIL_MAILER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025

Staging is trickier, because sometimes you actually want to see real emails land in a real inbox. For that, Laravel has a global "to" address. Put it in a service provider and every outgoing message gets redirected:

use Illuminate\Support\Facades\Mail;
 
public function boot(): void
{
    if (! $this->app->isProduction()) {
        Mail::alwaysTo('staging-inbox@yourcompany.test');
    }
}

Every mailable and every mail notification goes through the mailer, so they all get caught. It also strips CC and BCC, which matters, because the one address you forgot to redirect is always the CC.

Notice the condition. It's "not production," not "is staging." If someone spins up a new environment called demo or qa2, it's safe by default. Fail closed.

TypeScript: wrap the transport, don't trust the caller

In Node, the same idea is a thin wrapper around whatever you use to send. Nobody in the app calls the mail library directly. They call this:

import nodemailer from 'nodemailer';
 
const transport = nodemailer.createTransport(process.env.SMTP_URL);
const isProd = process.env.APP_ENV === 'production';
const allowed = ['@yourcompany.com'];
 
export async function sendMail(msg: { to: string; subject: string; html: string }) {
  const safe = isProd || allowed.some((d) => msg.to.endsWith(d));
 
  return transport.sendMail({
    from: 'app@yourcompany.com',
    ...msg,
    to: safe ? msg.to : 'staging-inbox@yourcompany.com',
    subject: safe ? msg.subject : `[${process.env.APP_ENV}] ${msg.subject} (to: ${msg.to})`,
  });
}

Your own team still gets real email. Everyone else gets redirected, and the subject line tells you who it would have gone to, which is handy when you're debugging the template.

If APP_ENV is missing entirely, isProd is false and nothing escapes. Same fail-closed rule.

WordPress: one filter

WordPress routes nearly everything through wp_mail(), and since 5.7 there's a pre_wp_mail filter that can stop it cold. Returning anything other than null short-circuits the send:

add_filter('pre_wp_mail', function ($return, $atts) {
    if (wp_get_environment_type() === 'production') {
        return $return;
    }
 
    error_log('Blocked mail to ' . json_encode($atts['to']));
    return false;
}, 10, 2);

Drop it in a must-use plugin so nobody can deactivate it by accident. The catch: a plugin that talks to an email API directly instead of using wp_mail() walks right past this. Check what your form and WooCommerce plugins actually use.

Email isn't the only way out

Mail is the one everybody remembers. It's not the only one. Anything that reaches a person or another company can leak from staging:

  • SMS and push notifications.
  • Outgoing webhooks to partners.
  • Payment providers. If staging has live Stripe keys, staging can charge a card. Use test mode keys and make the app refuse to boot with a live key outside production.
  • Syncs to a CRM, an email marketing list, or an accounting system.

The pattern is the same for all of them. One wrapper per channel, a "not production means redirect or drop" rule, and no code path that goes around the wrapper.

Scrub the data too

Even with every channel locked down, a production copy in staging is full of real names, emails, and phone numbers. That's a privacy problem on its own, and it's one config mistake away from a messaging problem.

Scrub it as part of the copy, not as a step someone might remember afterward:

UPDATE users
SET email = CONCAT('user', id, '@example.test'),
    phone = NULL;

example.test can't receive mail, so even if everything else fails, the messages go nowhere. Do the same for any other table that stores contact info. There's always another one (looking at you, orders.billing_email).

Test the guard, not just the feature

The guard is code, so it deserves a test. In Laravel, Mail::fake() lets you assert what got sent without sending it, which is great for feature tests. But it also bypasses the real mailer, so it won't prove your alwaysTo works.

For that, the honest test is the boring one. After every fresh staging refresh, trigger a password reset for any user and confirm it lands in the staging inbox and nowhere else. It takes a minute.

Make the safe path the default

Production should be the environment you have to opt into, not the one you get by accident. Redirect by default, scrub on copy, block live keys outside prod, and route every outgoing message through one place you control.

Then when someone copies the database to chase a bug, the worst thing that happens is your staging inbox gets noisy.

← All posts