Anyone who has worked on email features has a story about testing email going wrong: a staging server that mailed a real customer list, a loop that sent thousands of test messages, or a test run against fake addresses that hurt the production domain reputation. Email is unusual because mistakes leave your system and reach real inboxes, and they cannot be recalled. This guide describes how to set up safe environments for development and staging, what to test and how to go live gradually.
The risks of careless testing
| Mistake | Result | Prevention |
|---|---|---|
| Staging uses a copy of the production database | Real customers get test or duplicate emails | Anonymise data; disable sending outside production |
| Tests send to invented addresses | Bounces harm reputation; strangers receive mail | Mail catcher or owned inboxes |
| Same API key everywhere | A test script sends as production | Separate keys per environment |
| Test loop without limits | Thousands of messages in minutes | Rate limits and environment guards |
| Test mail from the production domain | Complaints on test mail affect real mail | Separate subdomain for non-production |
| No test of failure paths | First bounce or limit in production breaks the app | Test bounces, retries and limits in staging |
Level 1: Local development with a mail catcher
On a developer machine, the safest approach is not to send at all. A mail catcher is a small program that listens on an SMTP port, accepts any message and stores it for viewing, usually in a web page. Your application is configured with the catcher host and port, so the code path is the same as production, while nothing leaves the machine. Several open-source tools do this, and many frameworks support a configuration for it.
- Set the SMTP host and port in environment variables, so no code changes are needed between environments.
- Use the catcher to check content, subject lines, headers, attachments and the text part.
- Keep the catcher in the default development setup, so that nobody has to remember to turn it on.
- Do not rely on it to test authentication or deliverability. A catcher cannot show how real providers treat your mail.
Do not send to fake addresses at real domains. An address like test123@example.com or at a random real domain either bounces or reaches a stranger. The documentation domains are reserved and not meant to receive mail. Bounces count against your sending reputation, as explained in SMTP bounce codes explained.
Level 2: Staging with real sending to controlled inboxes
Staging should look as much like production as is safe, including actual delivery through your sending platform. This lets you check authentication, headers, rendering in real mail apps and delivery events. The conditions are strict control over who can receive the mail.
Set up the environment
- A separate sending subdomain such as staging-mail.example.com, verified as described in verifying your sending domain, so test traffic builds its own small reputation and cannot touch production mail.
- Separate credentials for staging with the narrowest permissions that work. See API key security and rotation.
- An allow list of recipients. The staging mail layer refuses any recipient not on a short list of team-owned mailboxes or a test domain you control.
- Anonymised data. If staging needs customer-like records, replace the real addresses and names.
- A banner in the subject or body such as [STAGING], so nobody mistakes a test for a real message.
Use mailboxes you control
Create a small set of test mailboxes at the providers that matter to your audience: a consumer webmail account, a business mailbox and any region-specific provider you care about. Use them to see how mail arrives in each client, and to inspect headers. Treat them as spot checks, not as measurements of inbox placement. Our guide to email deliverability metrics explains the limits of seed testing.
Level 3: Production, with care
- Release in stages. Send to internal staff first, then to a small slice of real recipients, then ramp up.
- Watch bounces, complaints and latency during the first sends. Alert on changes.
- Keep a way to pause quickly: a feature flag or a kill switch for each message type.
- For new domains or addresses, follow a warm-up plan, as in our warm-up guide.
What to test
| Area | What to check | Where |
|---|---|---|
| Content | Subject, preheader, text part, links, unsubscribe, personalisation with missing data | Mail catcher, then real inboxes |
| Rendering | Layout in major mail apps, dark mode, images blocked, mobile | Real inboxes |
| Authentication | SPF, DKIM and DMARC pass and align | Real inboxes; see DMARC alignment explained |
| Headers | From, Reply-To, List-Unsubscribe, message IDs | Message source |
| Events | Webhooks for delivery, bounce, complaint and unsubscribe, with signature checks | Staging with tunnel or replay |
| Failure handling | Retries after temporary errors, rate limit responses, invalid recipients | Staging and unit tests with simulated responses |
| Volume | Behaviour when many messages queue at once | Staging with a controlled test, never against real recipients |
| Edge cases | Long names, non-ASCII text, large attachments, missing fields | Mail catcher and real inboxes |
Testing events, bounces and limits safely
The most valuable tests are the ones that cover failure, and these are the hardest to run without risk. Use three techniques.
- Simulate in your own code. Wrap the sending call so that tests can return a fake temporary error, a rate limit response or a permanent rejection. This exercises your retry and suppression logic, as described in rate limits and retries, with no real mail sent.
- Replay sample events. Save example webhook payloads for each event type and post them to your handler, including duplicates and out-of-order sequences. See webhook handler reliability.
- Use documented test features. If your platform offers sandbox modes or test addresses, use them as documented. Check your platform reference for what is available, and do not assume that features exist.
Safeguards in code
# Pseudocode for a guarded mail layer
if environment != "production":
if recipient not in ALLOW_LIST:
redirect_to(TEST_CATCH_ALL) # or drop and log
subject = "[STAGING] " + subject
send(message)
- Put the guard in the shared mail-sending function, not in each feature.
- Fail closed: if the environment cannot be determined, do not send.
- Log every redirected or blocked message so tests are traceable.
- Review the guard as part of release checks, and test it.
A worked example: the staging copy that mailed customers
A team refreshes its staging database from production to test a new notification feature. A scheduled job in staging runs overnight and sends the new notification to everyone in the copied data, using the same API key as production. Thousands of real customers receive a half-finished message with placeholder text, and several reply and complain.
After the incident the team makes four changes. Staging gets its own credentials and its own sending subdomain. The shared mail function refuses any recipient not on an allow list outside production. The database refresh now anonymises addresses before staging can use the data. A mail catcher becomes the default for local development. Later releases go to staff first, and then to a small percentage of real recipients, while the team watches complaints and bounces. The incident is illustrative, but the combination of copied data and live credentials is a well-known cause.
A short checklist
- Local development uses a mail catcher by default.
- Staging has its own subdomain, credentials and allow list.
- No real customer addresses exist outside production.
- Test mailboxes at the main providers are ready.
- Failure paths and webhooks are tested with simulated and replayed events.
- Authentication and headers are checked on real test messages.
- Production release is staged, with monitoring and a way to pause.
Setting up a safe test environment? Tell us how you send today and we will suggest a layout for local, staging and production.
Further reading and sources
Frequently asked questions
Why not just send test emails to made-up addresses?
Made-up addresses at real domains bounce, and bounces count against your sending reputation. Addresses at domains you do not own may belong to real people. Use a mail catcher or mailboxes you control instead.
What is a mail catcher?
A tool that acts as a fake SMTP server. Your application connects to it as if it were a real mail server, and it stores the messages for you to view in a browser instead of delivering them. It is widely used in development.
Can I use example.com in tests?
The domains reserved for documentation, such as example.com, example.net and example.org, are not meant to receive mail. Sending to them through a real platform leads to rejections and bounces. Use them only with a mail catcher that does not deliver.
Should staging use the same credentials as production?
No. Use separate credentials so that staging cannot send as production and so that a leaked staging key has limited impact. Revoke staging keys independently when needed.
How do I test bounces without hurting my reputation?
Use your platform test features or documented test addresses if it provides them, or simulate bounce events in your own webhook tests. Do not deliberately send to invalid addresses on a production sending domain.
How do I test webhooks locally?
Expose your local endpoint through a secure tunnel, or replay saved example payloads against it. Verify signatures in tests with a known secret, and test duplicate and out-of-order events.
What should I check in a test email?
Appearance in several clients, links, text version, headers, authentication results, unsubscribe behaviour, and that no real customer data appears in test content.
How do I prevent staging from emailing real customers?
Use environment checks that disable sending outside production, redirect all mail to a catch-all test address, and keep production data out of staging. Defence in depth matters, because one safeguard can fail.
Do I need a separate domain for testing?
A separate subdomain for non-production mail is a good idea, so that test traffic never affects the reputation of your production domain. Verify it as you would any sending domain.