Testing transactional email in staging: the mistakes a sandbox catches, and the ones it does not

Why 'just use a test address' fails, what an SMTP sandbox actually intercepts, the six classes of email bug that only show up on a captured message, and how to wire a sandbox into staging and CI without touching application code.

The Mailclutch teamUpdated 7 min read

Every application that sends email has, at some point, sent it to the wrong person. A staging database restored from production, a background job that did not know it was in staging, a test account with a real customer's address left over from a support ticket. The email goes out, it is real, and the first the team hears of it is the reply.

The fix everyone reaches for first is a guard: if (app()->environment('production')), a MAIL_TO_OVERRIDE, a mock in the test suite. These work until the one path that bypasses them, and they all share a deeper problem — when they work, nobody sees the email. The message that was suppressed is also the message that was never checked.

An SMTP sandbox inverts this. Staging sends exactly as production would; the sandbox accepts everything, delivers nothing, and shows the team every message with its headers, its rendering and its authentication results. This article is about what that actually catches, what it does not, and how to set it up so it stays set up.

What a sandbox intercepts

The application is configured with the sandbox's SMTP host and a per-inbox credential pair, instead of the real provider's. Nothing in the code changes. Every message the application sends — through the framework mailer, a queue worker, a cron job, a third-party package that sends its own mail — goes through the same SMTP configuration, authenticates against the sandbox, and is captured.

The key property is that the credentials, not the recipient address, decide where the message goes. A staging environment with ten thousand real customer addresses in its database can send to all of them; every one lands in the sandbox inbox and none is contacted. There is no address to override, no allowlist to maintain, no path that forgets to check.

Which also means the sandbox sees the message as sent: the real To:, the real From:, the real subject with the real customer's name in it, the real body. That is what makes the next section possible.

Six classes of bug that only show on a captured message

1. The wrong recipient, or the wrong number of them

A notification that goes to the whole team instead of the assignee. A Cc: that leaks another customer's address. A digest that sends once per item instead of once per user. In a mocked test these are assertions nobody wrote; in a sandbox they are ten messages where there should be one, visible in the list.

2. The rendered HTML

Templates are tested in a browser, and mail clients are not browsers. <style> blocks that Gmail strips, flexbox that Outlook ignores, a hero image at 1200px that the client renders at full width, dark-mode inversions that turn a white logo invisible. A captured message renders in an actual client engine, and the source panel shows what the client received — including the inline CSS the framework was supposed to add and did not.

3. The plain-text part

Most templates generate a text/plain alternative automatically, from the HTML or from a separate template. It is almost never looked at. Then a rule fires (MPART_ALT_DIFF, the text and HTML parts disagree), or a user with a text-only client sees "Please view this email in an HTML-capable client" and nothing else. The sandbox shows both parts side by side.

4. Headers the code thought it set

List-Unsubscribe dropped by the queue serialiser. Reply-To overwritten by the provider's API. Message-ID missing because the SMTP client is hand-rolled. X-Priority: 1 added years ago by someone who wanted the alert to stand out. None of these are visible in the code that sends the message; all of them are visible in the headers of the message that arrived. The deliverability score lists the ones that matter.

5. Authentication and spam signals

Is the message DKIM-signed, and on which domain? Does the From: align with it? What does SpamAssassin say, and which rules fired? These are properties of the message as produced, and they change when someone changes the mailer, the provider, the From address or the template. A sandbox runs the checks on every capture, so the change shows up on the next staging send with a named cause.

6. Attachments and encoding

A PDF invoice attached as application/octet-stream with no filename. A CSV export that is actually the HTML of the login page because the session expired in the job. A subject line with an accented character that arrived as =?utf-8?Q?...?= unencoded, or as mojibake. Non-ASCII in a From: display name that a strict receiver rejects outright. All of it is visible once you can open the message.

What a sandbox does not catch

Being precise about the limits is what makes the tool trustworthy.

  • Reputation. The sandbox has no opinion about your domain's history. Gmail does. A message that scores 100 in the sandbox can still be filtered by a receiver that distrusts the domain — see Gmail's requirements.
  • Sending-host results. SPF and reverse DNS are evaluated on the host that connected to the sandbox — your staging server — not on production's. Those results are correct for what was tested and say nothing about production DNS.
  • Client-specific rendering quirks beyond the engines the preview covers. It catches the structural problems; a pixel-perfect check across forty clients is a different product.
  • Provider-side behaviour. If your production provider rewrites links, adds a footer or re-signs the message, the sandbox sees the message before that happens, because it is the provider in staging. Test the provider's transformations against the provider.
  • Whether the email should have been sent at all. Business logic is your test suite's job. The sandbox tells you what was sent; it cannot tell you what should have been.

Wiring it into staging

Four environment variables. No code change.

.env.staging
MAIL_MAILER=smtp
MAIL_HOST=smtp.mailclutch.com
MAIL_PORT=587
MAIL_ENCRYPTION=tls
MAIL_USERNAME=mc_xxxxxxxxxxxxxxxx
MAIL_PASSWORD=pw_xxxxxxxxxxxxxxxx

That is Laravel's naming; the framework guides have the equivalent for Django, Rails, Node.js, Spring, .NET, Postfix and the rest, each shown against a live credential pair. The one universal rule: use the framework's own SMTP transport, not a provider SDK, in staging. A provider SDK sends over HTTPS to the provider's API and bypasses SMTP entirely — the sandbox never sees it. If production uses the SDK, staging should use the SMTP transport of the same provider's configuration shape, so the message is the same and only the transport differs.

Two practices that keep it working:

One inbox per environment, or per client. A staging inbox, a QA inbox, a preview-deployment inbox. Mixing them makes "which deploy sent this?" a question; separating them makes it the inbox name.

Rotate credentials like any other secret. They are a secret. If a credential pair leaks, anyone can send into your inbox — which is a nuisance, not a breach, but a nuisance you will want to end by rotating.

Wiring it into CI

The same configuration in the CI environment turns the sandbox into an assertion target. The shape:

  1. The test suite triggers the code path — a signup, an order, a password reset.
  2. The application sends through the sandbox.
  3. The test polls the API for a message matching a subject or recipient, with a short timeout.
  4. It asserts on the message: recipient, subject, that a link is present, that the verification code in the body matches the one in the database — and, if you want, on the deliverability result: DKIM must pass, SpamAssassin must be under 3.0, List-Unsubscribe must be absent on this receipt.
  5. The inbox is purged between runs.
ci.sh — waiting for a capture
# Poll until the message with our subject shows up, then assert on it.
for i in $(seq 1 20); do
  MSG=$(curl -sS -H "Authorization: Bearer $MAILCLUTCH_TOKEN" \
        "https://app.mailclutch.com/api/v1/inboxes/$INBOX/emails?subject=Verify+your+address" \
        | jq -r '.data[0]')
  [ "$MSG" != "null" ] && break
  sleep 1
done

echo "$MSG" | jq -e '.to[0] == "new-user@example.com"'
echo "$MSG" | jq -e '.analysis.dkim.status == "pass"'

The polling loop is the part that feels wrong the first time and is correct: SMTP is asynchronous, the message arrives when it arrives, and a one-second poll with a twenty-second cap is the honest way to wait for it. Agents get a cleaner version — the MCP server has a wait_for_email tool that blocks until a matching message lands.

The argument for doing it this way

A mock proves the code called the mailer. A test address proves the email arrived somewhere, once, for whoever checks that mailbox. A sandbox proves what was sent, to whom, with which headers, rendering how, and authenticated by whom — for every message, on every deploy, visible to the whole team, with nothing reaching anyone real.

The email your application sends is the part of your product that customers read most closely and your team sees least. A sandbox is how the team gets to see it first.

Related articles

Keep reading

All articles

Read about it here, check it on your staging.

See it on your own mail

Point your staging SMTP at Mailclutch and every message comes back with its headers, its spam score and its authentication results — before a customer sees it.

Free plan · no credit card · running in under a minute