Testing and sending WooCommerce transactional emails safely: Mailtrap vs plain SMTP
Disclosure: some links in this guide are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. How this site makes money.
A staging copy of a WooCommerce store holds real customer addresses and sends mail through the server's PHP mail() unless something stops it, so order emails, password resets and scheduled follow-ups can reach real customers from a test site. Route mail by environment: send everything from staging to an email sandbox such as Mailtrap's, block mail when no sandbox is set up, and send real mail only when the site is the live host name. In production, send receipts and resets through a transactional stream kept apart from marketing mail, and publish SPF, DKIM and DMARC records for the sending domain. Our tested mu-plugin does the routing and our Python script checks the DNS records. Mailtrap covers both testing and sending (sending is free up to 4,000 emails a month); Postmark is a strong developer-focused alternative for sending.
The usual way a store emails its customers by mistake: someone copies the live site to staging to test a plugin update, the copy keeps every customer and every scheduled action, and WordPress sends mail the way it always does. A test order triggers a receipt, a subscription renewal runs early, a follow-up email goes to last month's buyers. Nothing in WordPress or WooCommerce stops a copy from sending. This guide sets up the stop, then makes the real emails from the live store reliable.
We tested the routing mu-plugin in a real WordPress 7.1.3 and WooCommerce 11.2.1 site on PHP 8.4 and MariaDB, with WooCommerce's own order emails and WordPress's password reset. A local SMTP server stood in for Mailtrap and recorded which server, port and user the plugin chose, so no mail left our machine. We didn't send through Mailtrap's servers; host names, ports and DNS records come from Mailtrap's documentation. The DNS script was tested against a mock DNS server and run against real domains on October 20, 2026. Mailtrap's and Postmark's plans come from their pricing pages, checked the same month.
1. The short answer
| Plain SMTP or PHP mail() | Mailtrap | Postmark | |
|---|---|---|---|
| Staging | Sends to real addresses unless you stop it | Email Sandbox catches everything; nothing is delivered | No sandbox for capturing; offers a test API token that accepts messages without delivering them |
| Production sending | Shared server IPs, little or no bounce handling or logs | Transactional and bulk streams on separate infrastructure, logs, webhooks | Transactional and broadcast message streams, 45-day logs by default |
| Free tier | Included with hosting | Sending: 4,000 emails a month (150 a day). Sandbox: 50 test emails a month | 100 emails a month |
| Paid from | Sending $15 a month for 10,000; Sandbox $17 a month | $15 a month for 10,000 |
2. Staging and production, side by side
3. Why a staging copy sends real email
- WordPress assumes production.
wp_get_environment_type()returnsproductionunlessWP_ENVIRONMENT_TYPEis defined, and a copy made with a backup or migration plugin often brings the livewp-config.phpwith it. - The data is real. Customers, orders and subscriptions are copied, so every email has a real recipient.
- Scheduled work keeps running. WooCommerce's Action Scheduler and WP-Cron run renewals, follow-ups and reminders on the copy as they do on the live site.
- Nothing intercepts mail by default. WordPress sends through PHP's
mail(), which on most Linux hosts hands the message to the server's mail system for delivery.
Some managed hosts block outgoing mail on staging environments; many don't, and a copy made by hand on a VPS never does. Don't count on it.
4. The routing plugin
Save this as wp-content/mu-plugins/mail-routing.php. Must-use plugins load on every request and can't be switched off from the dashboard, which is what you want for a safety catch. It sends real mail only when the environment type is production and the site's host name is the one you set for the live store. Everywhere else it routes to a Mailtrap sandbox, or blocks mail and logs a line if no sandbox is configured.
<?php
/**
* Plugin Name: Mail routing by environment
* Description: The live site sends through Mailtrap's transactional stream. Every other copy of the site (staging,
* local, a clone that kept the live wp-config.php) sends to a Mailtrap sandbox, and if no sandbox is set up, mail is
* blocked instead of going out through the server's mail().
*
* wp-config.php on the live site: MAIL_PRODUCTION_HOST ('shop.example.com'), MAILTRAP_TOKEN
* wp-config.php on staging: WP_ENVIRONMENT_TYPE ('staging'), MAILTRAP_SANDBOX_USER, MAILTRAP_SANDBOX_PASS
*/
function mailroute_mode() {
// WP_ENVIRONMENT_TYPE defaults to "production", and copies often keep the live wp-config.php, so the environment
// type alone can't be trusted: real mail also needs the live host name.
$live = 'production' === wp_get_environment_type() && defined( 'MAIL_PRODUCTION_HOST' )
&& wp_parse_url( home_url(), PHP_URL_HOST ) === MAIL_PRODUCTION_HOST;
if ( $live ) {
return defined( 'MAILTRAP_TOKEN' ) ? 'live' : 'default';
}
return defined( 'MAILTRAP_SANDBOX_USER' ) ? 'sandbox' : 'blocked';
}
add_filter( 'pre_wp_mail', function ( $return, $atts ) {
if ( 'blocked' !== mailroute_mode() ) {
return $return;
}
error_log( 'mail-routing: blocked "' . $atts['subject'] . '" to ' . implode( ', ', (array) $atts['to'] ) );
return false;
}, 10, 2 );
add_action( 'phpmailer_init', function ( $mail ) {
$mode = mailroute_mode();
if ( 'live' !== $mode && 'sandbox' !== $mode ) {
return; // 'default': no token on the live site, leave the mailer (or another SMTP plugin) alone
}
$mail->isSMTP();
$mail->SMTPAuth = true;
$mail->SMTPSecure = 'tls'; // STARTTLS
if ( 'live' === $mode ) {
$mail->Host = 'live.smtp.mailtrap.io';
$mail->Port = 587;
$mail->Username = 'api';
$mail->Password = MAILTRAP_TOKEN;
} else {
$mail->Host = 'sandbox.smtp.mailtrap.io';
$mail->Port = 2525;
$mail->Username = MAILTRAP_SANDBOX_USER;
$mail->Password = MAILTRAP_SANDBOX_PASS;
$mail->addCustomHeader( 'X-Environment', wp_get_environment_type() . ' ' . home_url() );
}
} );
Setting it up
- Live site, in
wp-config.php:define( 'MAIL_PRODUCTION_HOST', 'shop.example.com' );anddefine( 'MAILTRAP_TOKEN', '...' );with the API token from Mailtrap's Sending Setup for your verified domain (the SMTP user name isapi). - Staging:
define( 'WP_ENVIRONMENT_TYPE', 'staging' );plusMAILTRAP_SANDBOX_USERandMAILTRAP_SANDBOX_PASSfrom the sandbox's Integration tab. - Remove other SMTP plugins or leave their settings empty, so two plugins don't both configure PHPMailer. If you'd rather keep one for production, leave out
MAILTRAP_TOKEN: the plugin then leaves the live mailer alone and only guards the copies. - Set the "From" address in WooCommerce → Settings → Emails to an address on the domain you verified with Mailtrap; a provider can only sign mail for a domain you've verified.
What we saw
Each run created a WooCommerce order, moved it to processing, which sends the admin "New order" email and the customer "Order received" email, and requested a password reset.
| Configuration | Result |
|---|---|
| Staging copy, no plugin (baseline) | All 3 emails went out through PHP mail() to the real addresses |
| Staging with sandbox credentials | All 3 to sandbox.smtp.mailtrap.io:2525 with STARTTLS, sandbox user, plus an X-Environment header naming the copy |
| Staging, no sandbox credentials | Nothing sent; 3 "blocked" lines in the PHP error log |
A copy that kept the live wp-config.php (production type, token set, wrong host) | Nothing sent; the live token was never used |
| Live site, host matches | All 3 to live.smtp.mailtrap.io:587 as user api |
| Live site, no Mailtrap token | Mailer left alone (PHP mail()) |
WooCommerce has an option to send emails in the background through Action Scheduler; it's off by default in 11.2.1, and the plugin works either way, because it hooks into WordPress's mail function wherever that runs.
5. Testing in the sandbox
A sandbox is more than a safety net. With every order email captured, you can check what customers would see before a theme or plugin update goes live:
- HTML check: Mailtrap scores the email's HTML against mail clients' support, which catches CSS that Outlook or Gmail will drop.
- Spam report and blocklists: a spam score for the message and a check of your sending IPs and domain against common blocklists.
- Headers and links: the From, Reply-To and List-Unsubscribe headers, and whether links point to the staging host instead of the live one. That's a common bug after a search-and-replace.
- Automated tests: the Sandbox API takes messages at
https://sandbox.api.mailtrap.io/api/send/{sandbox_id}and lets a test read captured messages back, so a CI job can place an order and assert that the receipt arrived with the right total.
For local development on your own machine, the open-source Mailpit does the capturing part for free. The sandbox earns its place when several people, or a CI pipeline, need the same view of a shared staging site.
6. Production: keep transactional and marketing mail apart
Receipts, shipping updates and password resets are expected; newsletters and promotions get ignored, deleted and sometimes reported as spam. If both leave from the same stream, complaints about the newsletter hurt the delivery of the password reset. Separate them:
| Transactional | Marketing (bulk) | |
|---|---|---|
| Examples | Order received, shipped, refund, password reset, account emails | Newsletters, promotions, cart reminders, win-back emails |
| Mailtrap | live.smtp.mailtrap.io / send.api.mailtrap.io | bulk.smtp.mailtrap.io / bulk.api.mailtrap.io; Mailtrap requires the bulk stream for promotional mail and keeps unsubscribes separate per stream |
| Postmark | Transactional message stream | Broadcast message stream |
| Unsubscribe link | Not required for genuine transactional mail | Required; Gmail and Yahoo require one-click unsubscribe (RFC 8058) from bulk senders |
Some stores also send marketing from a subdomain (news.shop.example.com) so its reputation is tracked separately from the main domain. With separate streams at the provider, that's optional.
7. SPF, DKIM and DMARC
- SPF lists the servers allowed to send for a domain. It's checked against the envelope sender (the Return-Path), which a sending service usually sets to its own or a bounce subdomain, so on its own it often doesn't line up with your From address. One SPF record per domain, at most 10 DNS lookups, ending in
-allor~all. - DKIM signs each message with a key published in DNS under a selector. Mailtrap asks for two CNAME records,
rwmt1._domainkeyandrwmt2._domainkey, so it can rotate keys for you. A DKIM signature from your own domain is what makes DMARC pass for mail sent through a provider. - DMARC tells receivers what to do when neither SPF nor DKIM passes for the domain in the From address (
p=none,quarantineorreject), and where to send reports (rua=). Start withp=noneand a report address, check the reports for a few weeks, then tighten.
Mailtrap's domain setup lists five records: four CNAMEs (domain verification, two DKIM, tracking) and a DMARC TXT record. Since February 2024, Gmail and Yahoo require senders of more than about 5,000 messages a day to their users to have SPF, DKIM and a DMARC record, to offer one-click unsubscribe on marketing mail, and to keep reported spam below 0.3%. Microsoft announced similar requirements for Outlook.com from May 2025. A small store is below those volumes, but the same records decide whether its receipts land in the inbox. See Google's email sender guidelines.
8. Checking the records with a script
DNS changes are easy to get subtly wrong: a second SPF record added by a new tool, an include that pushes SPF over 10 lookups, a DKIM selector that was never published. This script checks all three and exits with an error code if anything fails, so it can run after every DNS change or nightly from cron. It uses DNS over HTTPS (Google's resolver by default), so it needs no packages.
"""Checks the DNS records that decide whether a store's receipts and password resets reach the inbox: SPF (one record,
10-lookup limit, a closing -all/~all), DKIM (each selector you send with publishes a key) and DMARC (a record, its
policy and a report address). Uses DNS over HTTPS, so it runs anywhere with Python 3.9+ and no packages.
python mail_dns_check.py shop.example.com --dkim rwmt1 --dkim rwmt2
Exit code 1 if any check FAILs, so it can run in CI or cron after DNS changes."""
import argparse, base64, json, re, sys, urllib.parse, urllib.request
DOH = 'https://dns.google/resolve'
LOOKUP_TERMS = ('include:', 'a:', 'a/', 'mx:', 'mx/', 'ptr:', 'exists:', 'redirect=') # plus bare a, mx, ptr
def txt(name, doh=DOH):
"""TXT strings at name (CNAMEs followed by the resolver); [] if the name doesn't exist."""
q = urllib.parse.urlencode({'name': name, 'type': 'TXT'})
req = urllib.request.Request(f'{doh}?{q}', headers={'Accept': 'application/dns-json', 'User-Agent': 'mail-dns-check/1.0'})
with urllib.request.urlopen(req, timeout=10) as r:
d = json.load(r)
if d['Status'] not in (0, 3): # 3 = NXDOMAIN
raise RuntimeError(f'DNS error {d["Status"]} for {name}')
out = []
for a in d.get('Answer', []):
if a['type'] == 16: # long records come back as several quoted strings: join them
data = a['data']
out.append(''.join(re.findall(r'"((?:[^"\\]|\\.)*)"', data)) if data.startswith('"') else data)
return out
def spf_lookups(domain, doh, depth=0):
"""DNS lookups an SPF check of domain costs (RFC 7208 caps it at 10), following include: and redirect=."""
if depth > 10: # an include loop; the real check would have failed long before
return 0
rec = [t for t in txt(domain, doh) if t.lower().startswith('v=spf1')]
n = 0
for term in rec[0].split()[1:] if rec else []:
t = term.lstrip('+-~?').lower()
if t in ('a', 'mx', 'ptr') or t.startswith(LOOKUP_TERMS):
n += 1
if t.startswith(('include:', 'redirect=')):
n += spf_lookups(t.split(':', 1)[1] if ':' in t else t.split('=', 1)[1], doh, depth + 1)
return n
def check(domain, selectors, doh=DOH):
res = []
add = lambda level, what, msg: res.append((level, what, msg))
spf = [t for t in txt(domain, doh) if t.lower().startswith('v=spf1')]
if not spf:
add('FAIL', 'SPF', 'no v=spf1 record')
elif len(spf) > 1:
add('FAIL', 'SPF', f'{len(spf)} SPF records; receivers treat that as a permanent error, merge them into one')
else:
n = spf_lookups(domain, doh)
last = spf[0].split()[-1].lower()
if n > 10:
add('FAIL', 'SPF', f'{n} DNS lookups, over the limit of 10: SPF fails as a permanent error')
elif last in ('+all', 'all'):
add('FAIL', 'SPF', 'ends in +all: anyone may send as this domain')
elif last not in ('-all', '~all') and not last.startswith('redirect='):
add('WARN', 'SPF', f'ends in "{last}" instead of -all or ~all')
else:
add('PASS', 'SPF', f'{n}/10 lookups, ends in {last}: {spf[0]}')
for sel in selectors:
keys = [t for t in txt(f'{sel}._domainkey.{domain}', doh) if 'p=' in t]
if not keys:
add('FAIL', f'DKIM {sel}', f'nothing published at {sel}._domainkey.{domain}')
continue
p = re.search(r'\bp=([^;]*)', keys[0]).group(1).replace(' ', '')
if not p:
add('FAIL', f'DKIM {sel}', 'empty key (p=): revoked, or a wildcard that blocks unknown selectors; nothing can be signed with it')
elif 'k=ed25519' in keys[0].replace(' ', ''):
add('PASS', f'DKIM {sel}', 'Ed25519 key published')
elif len(base64.b64decode(p + '==')) < 200: # an RSA key's DER is ~162 bytes at 1024 bits, ~294 at 2048
add('WARN', f'DKIM {sel}', '1024-bit RSA key: still accepted, 2048-bit is recommended')
else:
add('PASS', f'DKIM {sel}', '2048-bit or larger RSA key published')
dmarc = [t for t in txt(f'_dmarc.{domain}', doh) if t.lower().startswith('v=dmarc1')]
if len(dmarc) != 1:
add('FAIL', 'DMARC', 'no DMARC record' if not dmarc else 'more than one DMARC record: receivers ignore both')
else:
tags = dict(kv.strip().split('=', 1) for kv in dmarc[0].split(';') if '=' in kv)
p = tags.get('p', '').lower()
if p not in ('none', 'quarantine', 'reject'):
add('FAIL', 'DMARC', f'invalid policy p={p!r}')
else:
notes = [f'p={p}'] + ([f'pct={tags["pct"]}'] if tags.get('pct', '100') != '100' else [])
if 'rua' not in tags:
notes.append('no rua= report address: you will not see who fails')
level = 'WARN' if p == 'none' or 'rua' not in tags else 'PASS'
if p == 'none':
notes.append('monitoring only: spoofed mail is still delivered')
add(level, 'DMARC', ', '.join(notes))
return res
if __name__ == '__main__':
a = argparse.ArgumentParser()
a.add_argument('domain')
a.add_argument('--dkim', action='append', default=[], help='DKIM selector, e.g. rwmt1 (repeatable)')
a.add_argument('--doh', default=DOH)
args = a.parse_args()
results = check(args.domain.lower().rstrip('.'), args.dkim, args.doh)
for level, what, msg in results:
print(f'{level:4} {what:12} {msg}')
sys.exit(1 if any(l == 'FAIL' for l, _, _ in results) else 0)
Run against mailtrap.io itself on October 20, 2026, with Mailtrap's own selector names:
$ python mail_dns_check.py mailtrap.io --dkim rwmt1 --dkim rwmt2
PASS SPF 3/10 lookups, ends in -all: v=spf1 include:helpscoutemail.com include:_spf.google.com include:_spf.smtp.mailtrap.live -all
PASS DKIM rwmt1 2048-bit or larger RSA key published
PASS DKIM rwmt2 2048-bit or larger RSA key published
PASS DMARC p=reject
The same day, brevo.com answered every unknown selector with an empty key (v=DKIM1; p=), a wildcard that stops anyone signing with a made-up selector; the script reports it as unusable rather than missing. Against a mock DNS server, it handled:
| Case | Result |
|---|---|
| Correct zone with nested SPF includes, a DKIM key split across two TXT strings, an Ed25519 key, DMARC quarantine with reports | All PASS; 4 lookups counted |
SPF with 6 includes that each use a, mx and another include | FAIL: 24 lookups |
Two SPF records / +all / ?all | FAIL / FAIL / WARN |
| 1024-bit DKIM key / empty key / selector not published | WARN / FAIL / FAIL |
DMARC p=none without rua; two DMARC records; none | WARN naming both problems; FAIL; FAIL |
| DNS server error (SERVFAIL) | Error, not "no record" |
9. Mailtrap plans
Testing (Email Sandbox) and sending (Email API/SMTP) are billed separately; you can use one without the other.
| Sending plan | Price per month | Emails per month |
|---|---|---|
| Free | $0 | 4,000 (150 a day), 3 days of logs |
| Basic | $15 / $20 / $30 | 10,000 / 50,000 / 100,000; 5 days of logs |
| Business | From $85 | From 100,000; dedicated IP, 15 days of logs |
| Sandbox plan | Price per month (annual) | Test emails per month |
|---|---|---|
| Free | $0 | 50, 1 sandbox |
| Basic | $17 ($14) | 500, 1 sandbox |
| Team | $42 ($34) | 5,000, 5 sandboxes |
| Business | $123 ($99) | 50,000, 50 sandboxes |
A small store's whole transactional volume often fits the free sending plan, and one staging site fits a free or Basic sandbox. Mailtrap reviews new sending domains before they go live, sometimes by hand, so set it up a few days before you need it.
10. Postmark: the alternative for sending
Postmark has focused on transactional email for years and is known for fast delivery and readable logs. Message streams separate transactional from broadcast mail, each with its own suppression list. Domain setup is a DKIM TXT record and a Return-Path CNAME, so SPF and DKIM both align with your domain. Pricing starts at $15 a month for 10,000 emails, with 45 days of message history by default, and a free developer tier of 100 emails a month. What it doesn't have is a capture sandbox like Mailtrap's: for staging you would pair it with Mailtrap's sandbox or Mailpit, and the routing plugin above works with any SMTP host. Change the host and credentials in the live branch.
11. Our opinion: trade-offs
- Install the routing plugin before you need it. It costs nothing and turns "staging emailed our customers" into a log line. This matters more than which provider you choose.
- Mailtrap if you want one account for both jobs. Sandbox and sending under one login, a free sending tier that covers many small stores, and bulk and transactional streams kept apart.
- Postmark if delivery speed and logs are what you care about most, and you're fine running staging capture elsewhere.
- Plain SMTP through your host or a mailbox provider is fine for a store with a handful of orders a week, as long as SPF, DKIM and DMARC are right. Mailbox SMTP (Gmail, Outlook) has daily sending limits and isn't meant for application mail.
Related: marketing automation from store events, abandoned-cart email and SMS through an API and monitoring checkout and SSL.
12. Tools
Top picks
| Tool | Role | Why it fits |
|---|---|---|
| Email sandbox for staging and transactional sending for production | Captures every staging email for review, then sends the live store's receipts through a separate transactional stream; free sending up to 4,000 emails a month. | |
| Transactional email API and SMTP | Developer-focused sending with message streams, aligned SPF and DKIM and detailed logs, from $15 a month. |
Alternatives: free tools and references
We don't earn anything from these links.
- Mailpit: open-source local mail catcher for development machines.
- wp_get_environment_type(): how WordPress decides the environment, and the values it accepts.
- Google's email sender guidelines: the authentication, unsubscribe and spam-rate rules.
- RFC 7208 and RFC 8058: SPF, including the 10-lookup limit, and one-click unsubscribe.
13. Pitfalls
- Trusting the environment type alone. It defaults to production, and copies keep the live config. Tie real sending to the live host name, as the plugin does.
- Mail isn't the only thing a copy sends. Webhooks, SMS plugins, payment gateway calls in live mode and shipping-label APIs also fire from staging. Switch gateways to test mode and disable outgoing webhooks after every copy.
- Two SPF records. Adding a provider by creating a new
v=spf1record instead of adding an include makes SPF fail everywhere. Merge them. - From address on an unverified domain. WordPress's default
wordpress@yourdomain, or a Gmail address in the From field, can't be signed by your provider and fails DMARC. - Jumping to
p=reject. A forgotten sender, such as the helpdesk, invoicing tool or old newsletter platform, starts bouncing. Read the DMARC reports first. - Staging links in production emails. After moving a site, check the links in a captured email; a missed search-and-replace sends customers to staging.
Make every copy of the store unable to email customers: a short mu-plugin routes staging mail to a Mailtrap sandbox and blocks it entirely when no sandbox is configured, and only the live host name sends real mail. In production, send receipts and resets through a transactional stream kept apart from marketing, and keep SPF, DKIM and DMARC right with a script that fails loudly. Mailtrap covers both testing and sending with free tiers to start; Postmark is the strong alternative for sending.