Sooner or later every team ships an email feature that accidentally mails real customers: a staging copy of production config, a hardcoded address in a loop, a cron job that was supposed to be disabled. Bounced and complaining recipients damage the reputation of your sending domain, and there is no undo button.

An email sandbox removes that risk: it accepts the message over SMTP, stores it and shows it in a dashboard. It never relays, never delivers and accepts any recipient address — so you can fire the same flow a hundred times without anyone noticing.

Three ways to stop test mail from reaching people

Approach What it actually proves What it does not
Fake the mailer (Mail::fake(), mocks in tests) that your code called the mailer with the expected arguments anything about the message itself: encoding, headers, rendered HTML, attachment content types
A real test mailbox you own that delivery works end to end it fills a real inbox, depends on your provider, and bulk flows are painful to replay
An SMTP sandbox (this instance, Mailpit, smtp4dev) the exact bytes your application sends — headers, HTML, text part, attachments — with no delivery at all deliverability: SPF, DKIM and DMARC still need a real sender and a real domain

Most teams need all three at different moments: a fake for unit tests, a sandbox for integration tests and manual checks, and a real address when the question is deliverability itself. This guide is about the middle one.

What an email sandbox does (and does not do)

  • Accepts SMTP on the port shown on your inbox page, with the username and password you are given.
  • Parses the raw message: subject, From/To/Cc/Bcc, envelopes, all headers, plain text, HTML and attachments.
  • Keeps it for a configurable retention window and deletes it automatically afterwards.
  • Never sends anything: there is no outbound queue and no relay, so even a real customer address stays inside the sandbox.

Step 1. Create an inbox and copy the credentials

Sign up, create an inbox and open it. You get a host, a port, a username and a password. The password is shown once, so copy it right away; regenerating the credentials invalidates the old password.

Step 2. Point your application at the sandbox

Laravel (.env):

MAIL_MAILER=smtp
MAIL_HOST=sandbox-host
MAIL_PORT=2525
MAIL_USERNAME=inbox_xxxxxxxx
MAIL_PASSWORD=the-password-you-copied
MAIL_ENCRYPTION=null

Python:

import smtplib
from email.message import EmailMessage

msg = EmailMessage()
msg["Subject"] = "Welcome to the sandbox"
msg["From"] = "app@example.com"
msg["To"] = "anyone@example.com"
msg.set_content("Hello from the test flow")

with smtplib.SMTP("sandbox-host", 2525) as server:
    server.login("inbox_xxxxxxxx", "the-password-you-copied")
    server.send_message(msg)

Node.js (nodemailer):

await transporter.sendMail({
  from: 'app@example.com',
  to: 'anyone@example.com',
  subject: 'Welcome to the sandbox',
  html: '<p>Hello from the test flow</p>',
});

Django (settings.py):

EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST = 'sandbox-host'
EMAIL_PORT = 2525
EMAIL_HOST_USER = 'inbox_xxxxxxxx'
EMAIL_HOST_PASSWORD = 'the-password-you-copied'
EMAIL_USE_TLS = False

From the shell, without touching any code (handy for a device, a legacy cron job or a quick check):

swaks --to anyone@example.com --from app@example.com \
      --server sandbox-host:2525 --auth LOGIN \
      --auth-user inbox_xxxxxxxx --auth-password 'the-password-you-copied'

Any SMTP client works the same way: Symfony Mailer, PHPMailer, Rails, msmtp, WordPress plugins, or a device that only speaks SMTP.

Step 3. Inspect the result

Send the message and open the inbox: the email appears immediately with the rendered HTML (shown in a sandboxed iframe with scripts disabled), the plain-text part, every header and any attachments. Because nothing was delivered, you can safely send the same flow a hundred times.

Step 4. Check the details that break in production

  • Links and images must be absolute URLs. Relative paths look fine locally and break in a real client.
  • Unsubscribe link and a real physical address are required for bulk mail.
  • Reply-To must be a monitored mailbox, not noreply@.
  • Subject encoding for non-ASCII characters and emoji.
  • Attachments: size limits, correct content types, filenames without spaces.
  • SPF, DKIM and DMARC of your production sender — the sandbox cannot validate these for you, but it does let you see the exact headers your application builds.

Testing email in CI/CD (GitHub Actions)

For integration tests, assert on the captured message instead of a writable mailbox: send through the sandbox, then read it back from the JSON export of a read-only share link. The job needs no mail-driver tricks and no inbox polling — it is the production code path with the SMTP endpoint swapped.

- name: Send the welcome email through the sandbox
  run: php artisan tinker --execute="Mail::to('anyone@example.com')->send(new WelcomeMail())"

- name: Assert what the sandbox actually received
  run: |
    curl -fsS "${{ secrets.SANDBOX_EXPORT_URL }}" -o captured.json
    jq -e '(.count >= 1) and ((.items | last | .subject) == "Welcome to the sandbox")' captured.json

Two practical notes: the share link is a credential — keep it in secrets, and remember it expires and can be revoked — and the assertion is only as good as the template it checks, so update the check when the template changes.

Where to run the sandbox

  • This instance — SMTP and webhooks on one address, nothing to maintain; see its limits and how it compares to the alternatives.
  • Mailpit or smtp4dev on your own host — free and actively developed, if test data must never leave your infrastructure.

Common mistakes when testing email

  • Pointing the sandbox at production config. Keep the sandbox credentials in the staging and test environments only; a copy-paste in the wrong direction is how test mail reaches real inboxes.
  • Testing only the "happy path" template. Send the ones that are easy to forget: password reset, invoice with a PDF attachment, digest with a hundred links, and anything with non-Latin characters or emoji in the subject.
  • Forgetting the text part. Most templates ship HTML only; a sandbox shows the text/plain alternative immediately, and it is the part screen readers and many corporate clients fall back to.
  • Asserting on the HTML with the whole document in the test. Compare the subject, the recipients and a couple of key strings — full-HTML snapshots break on every unrelated design tweak.
  • Treating the sandbox as a deliverability test. It proves your application builds a correct message; it cannot tell you whether the receiving server will accept it.

Next steps