email59

Development

Test emails that never reach a real person: catching mail in development

Sooner or later a staging server emails real customers. Three fences that stop it: a local catch-all inbox, an allowlist in one send function, and test addresses that can't belong to anyone.

The story is always the same. Someone copies the production database to staging to chase a bug. Staging has the same code, the same nightly job and, because nobody thought about it, the same mail key. At 2 a.m. four thousand real people get "Your invoice is overdue" from a server that was never supposed to talk to anyone.

Nobody plans that. It happens because sending real email is the default and not sending is the thing you have to build. So turn it round: make "reaches nobody" the default, and make real delivery something an environment has to be given on purpose.

Diagram: three environments. On your machine every email goes to a local catch-all inbox. On staging only allowlisted team addresses get real mail and the rest is redirected to one team inbox. Only production sends to customers.
One fence per environment. Only production has a road out.

Fence 1: on your machine, mail has nowhere to go

If your app sends over SMTP, point it at a catch-all inbox running on your own machine. Mailpit is the one most people use now: one small program that accepts any email on an SMTP port and shows it in a web page. It took over from MailHog, which is no longer maintained.

docker run -d --name mailpit -p 8025:8025 -p 1025:1025 axllent/mailpit

# in your app's dev settings
SMTP_HOST=localhost
SMTP_PORT=1025        # no username, no password, no TLS

Open http://localhost:8025 and every email your app "sends" is sitting there, whoever it was addressed to. Things worth knowing:

  • It checks your work. Each message has an HTML check (which mail clients will break your layout), a link check and, if you wire it up, a SpamAssassin score.
  • It keeps the latest 500 messages by default and drops older ones, so it doesn't fill a disk.
  • It has an API, so a test can ask "did the welcome email go out, and what's the link in it?"
  • It can let chosen mail through. A message can be released to a real SMTP server by hand, and --smtp-relay-matching forwards only recipients that match a pattern. Leave --smtp-relay-all alone: that turns your catch-all into an ordinary mail server with a copy.

A test that reads the inbox instead of trusting a mock:

import re, requests

def test_signup_sends_a_working_link(client):
    requests.delete("http://localhost:8025/api/v1/messages")           # empty the inbox
    client.post("/signup", data={"email": "new.user@example.test"})

    found = requests.get("http://localhost:8025/api/v1/search",
                         params={"query": "to:new.user@example.test"}).json()
    assert found["messages_count"] == 1
    msg = requests.get(f"http://localhost:8025/api/v1/message/{found['messages'][0]['ID']}").json()
    link = re.search(r"https?://\S+/confirm\S+", msg["Text"]).group(0)
    assert client.get(link).status_code == 200

Fence 2: one send function, and it fails closed

Mailpit only catches SMTP. If your app sends through an HTTPS API (email59, or any provider's REST endpoint), nothing local sits in the way: the request goes straight out to the internet. The fence has to be in your code, and it only works if every email goes through one function.

import os, re, requests

MODE  = os.environ.get("MAIL_MODE", "capture")           # capture | allowlist | live
ALLOW = re.compile(os.environ.get("MAIL_ALLOW", r"^$"))  # e.g. @yourcompany\.com$
CATCH = os.environ.get("MAIL_CATCH", "")                 # e.g. dev-mail@yourcompany.com

def send_email(to, subject, text):
    if MODE not in ("allowlist", "live"):                # dev, CI, a misspelt mode, anything not configured
        print(f"[mail not sent] to={to} subject={subject!r}\n{text}\n")
        return
    if MODE == "allowlist" and not ALLOW.search(to):     # staging
        if not CATCH:
            return
        user, domain = CATCH.split("@")
        tag = re.sub(r"[^a-z0-9]+", "-", to.lower()).strip("-")[:40]
        subject, to = f"[staging, was for {to}] {subject}", f"{user}+{tag}@{domain}"
    requests.post("https://email59.com/v1/send", timeout=10,
        headers={"Authorization": f"Bearer {os.environ['EMAIL59_KEY']}"},
        json={"from": "yourapp", "to": to, "subject": subject, "text": text}).raise_for_status()

The dev mail fence builds this function for your own domain and catch inbox in Python, JavaScript, PHP, C# or Go. Three details carry the weight:

  • The default is capture. A new laptop, a CI runner or a forgotten preview deployment has no MAIL_MODE set, so it prints and sends nothing. Production is the only place that says live. If the default were live, every mistake would end in someone's inbox.
  • Staging redirects instead of dropping. Mail for anyone outside the allowlist goes to one team inbox, with the original recipient in the subject and in the plus tag (dev-mail+jo-example-org@yourcompany.com), so testers still see that the email went out and can filter by who it was for.
  • The key isn't there at all in development. Don't put the production mail key in .env.example, in the shared dev .env or in CI. A send that can't authenticate can't reach anyone.

Then grep the codebase for any other place that talks to a mail server or a mail API. Password resets written two years ago and the cron job a contractor added are the usual holes.

Fence 3: test data that can't belong to anyone

  • Use reserved domains in fixtures and seeds. example.com, example.org, example.net and anything ending in .test, .example or .invalid are set aside by RFC 2606 and will never be registered to a person. test@test.com and asdf@gmail.com are real mailboxes that get a remarkable amount of other people's test mail.
  • Reserved doesn't mean free to send to. Real mail to someone@example.com bounces, and a pile of bounces counts against whoever sent it. Reserved addresses are safe because the first two fences stop them from leaving. They're the backstop, not the plan.
  • Scrub production copies before anything else runs. If a dump has to go to staging, rewrite the addresses as part of the restore, not as a step someone remembers:
    UPDATE users
       SET email = 'user' || id || '@example.test'
     WHERE email NOT LIKE '%@yourcompany.com';
  • For the real end-to-end check, use plus addresses on your own mailbox. you+signup1@gmail.com and you+signup2@gmail.com are two accounts to your app and one inbox to you. Gmail, Outlook.com, Fastmail and iCloud support it; it's an optional feature (RFC 5233 describes it), so some company mail servers don't. And check that your own sign-up form accepts a +. Plenty reject it with a homemade regex.

What a provider's sandbox does and doesn't give you

Several senders have a test mode. They're useful, and they answer a narrower question than people expect.

SenderTest modeWhat you learn
PostmarkA sandbox server, or the token POSTMARK_API_TESTThe API accepted your request. Nothing is delivered.
Amazon SESNew accounts start in a sandbox that only mails verified addresses; a mailbox simulator (success@, bounce@, complaint@simulator.amazonses.com)How your code handles a delivery, a bounce and a complaint.
ResendTest addresses: delivered@, bounced@, complained@resend.devThe same three outcomes, including webhooks.

A sandbox tells you the call is well formed and your bounce handling runs. It doesn't show you the email, doesn't stop a staging server that holds the live key, and doesn't know which of your recipients are real. You still want the fences.

With email59

email59 is an email router with one small HTTPS API, so fence 2 is where it plugs in. To be straight about it: there is no sandbox switch on /v1/send today. An accepted call is a sent email. What you do have:

  • A key per environment. Create one key labelled production and one labelled staging on the account page; revoke the staging one without touching production. Development gets no key.
  • A separate account for staging (bots and scripts can sign up on their own with an email code), so staging's test sends are counted apart from production's and a runaway loop on staging can't use up what production needs.
  • The deep email check never sends anything. GET /v1/check reads DNS only, so it's safe to call from any environment, including against real addresses.

When you get to the part of testing that does need real inboxes, two things from earlier posts are worth checking with your plus addresses: that a sign-in code email arrives in seconds, and that a company mail filter doesn't open your one-time link before the person does. The link scanner test shows you that second one in a couple of minutes.

The short version

  • Development: SMTP to Mailpit, or MAIL_MODE=capture. No mail key on the machine.
  • CI: same as development; tests read the captured mail.
  • Staging: its own key, allowlist of team addresses, everything else redirected to one inbox with the original recipient in the subject.
  • Production: the only environment that says live.
  • Fixtures and scrubbed dumps use @example.test. Real end-to-end tests use plus addresses you own.

Sources

Get your API key in 2 minutes More articles