David Lewin David Lewin
E-Commerce, Research & Ads

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.

Short answer

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.

How this guide was made

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()MailtrapPostmark
StagingSends to real addresses unless you stop itEmail Sandbox catches everything; nothing is deliveredNo sandbox for capturing; offers a test API token that accepts messages without delivering them
Production sendingShared server IPs, little or no bounce handling or logsTransactional and bulk streams on separate infrastructure, logs, webhooksTransactional and broadcast message streams, 45-day logs by default
Free tierIncluded with hostingSending: 4,000 emails a month (150 a day). Sandbox: 50 test emails a month100 emails a month
Paid fromSending $15 a month for 10,000; Sandbox $17 a month$15 a month for 10,000

2. Staging and production, side by side

Where WooCommerce emails go in staging and production
Where WooCommerce emails go in staging and productionStaging copy / Live store → mail-routing.php / mail-routing.php → Mailtrap Email Sandbox / Mailtrap transactional stream → Your team checks it / Customer's inboxStaging copy: WP_ENVIRONMENT_TYPE = staging, cloned customer dataStaging copyWP_ENVIRONMENT_TYPE =staging, cloned customer dataLive store: environment production, home URL is the live hostLive storeenvironment production, homeURL is the live hostmail-routing.php: sandbox credentials set? route there, otherwise blockmail-routing.phpsandbox credentials set?route there, otherwise blockmail-routing.php: Mailtrap API tokenmail-routing.phpMailtrap API tokenMailtrap Email Sandbox: sandbox.smtp.mailtrap.io: captured, never deliveredMailtrap Email Sandboxsandbox.smtp.mailtrap.io:captured, never deliveredMailtrap transactional stream: live.smtp.mailtrap.io, signed with your domain's DKIM keyMailtrap transactionalstreamlive.smtp.mailtrap.io, signedwith your domain's DKIM keyYour team checks it: HTML, spam score, headers, linksYour team checks itHTML, spam score, headers,linksCustomer's inbox: receipts and password resets; newsletters use the separate bulk streamCustomer's inboxreceipts and password resets;newsletters use the separatebulk stream
The same WordPress code runs in both places. One small plugin decides where mail goes, and on any copy it fails closed: no sandbox configured means no mail at all.

3. Why a staging copy sends real email

  • WordPress assumes production. wp_get_environment_type() returns production unless WP_ENVIRONMENT_TYPE is defined, and a copy made with a backup or migration plugin often brings the live wp-config.php with 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

  1. Live site, in wp-config.php: define( 'MAIL_PRODUCTION_HOST', 'shop.example.com' ); and define( 'MAILTRAP_TOKEN', '...' ); with the API token from Mailtrap's Sending Setup for your verified domain (the SMTP user name is api).
  2. Staging: define( 'WP_ENVIRONMENT_TYPE', 'staging' ); plus MAILTRAP_SANDBOX_USER and MAILTRAP_SANDBOX_PASS from the sandbox's Integration tab.
  3. 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.
  4. 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.

ConfigurationResult
Staging copy, no plugin (baseline)All 3 emails went out through PHP mail() to the real addresses
Staging with sandbox credentialsAll 3 to sandbox.smtp.mailtrap.io:2525 with STARTTLS, sandbox user, plus an X-Environment header naming the copy
Staging, no sandbox credentialsNothing 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 matchesAll 3 to live.smtp.mailtrap.io:587 as user api
Live site, no Mailtrap tokenMailer 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:

TransactionalMarketing (bulk)
ExamplesOrder received, shipped, refund, password reset, account emailsNewsletters, promotions, cart reminders, win-back emails
Mailtraplive.smtp.mailtrap.io / send.api.mailtrap.iobulk.smtp.mailtrap.io / bulk.api.mailtrap.io; Mailtrap requires the bulk stream for promotional mail and keeps unsubscribes separate per stream
PostmarkTransactional message streamBroadcast message stream
Unsubscribe linkNot required for genuine transactional mailRequired; 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 -all or ~all.
  • DKIM signs each message with a key published in DNS under a selector. Mailtrap asks for two CNAME records, rwmt1._domainkey and rwmt2._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, quarantine or reject), and where to send reports (rua=). Start with p=none and 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:

CaseResult
Correct zone with nested SPF includes, a DKIM key split across two TXT strings, an Ed25519 key, DMARC quarantine with reportsAll PASS; 4 lookups counted
SPF with 6 includes that each use a, mx and another includeFAIL: 24 lookups
Two SPF records / +all / ?allFAIL / FAIL / WARN
1024-bit DKIM key / empty key / selector not publishedWARN / FAIL / FAIL
DMARC p=none without rua; two DMARC records; noneWARN 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 planPrice per monthEmails per month
Free$04,000 (150 a day), 3 days of logs
Basic$15 / $20 / $3010,000 / 50,000 / 100,000; 5 days of logs
BusinessFrom $85From 100,000; dedicated IP, 15 days of logs
Sandbox planPrice per month (annual)Test emails per month
Free$050, 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

ToolRoleWhy it fits
MailtrapEmail sandbox for staging and transactional sending for productionCaptures 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.
PostmarkTransactional email API and SMTPDeveloper-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.

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=spf1 record 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.
Bottom line

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.

David Lewin
David Lewin

Founder of StealthScale.io and e-commerce infrastructure engineer writing field-tested playbooks on proxies, cloud VPS, anti-detect browsers and SEO.