Why WooCommerce Order Email Lands in Spam, and What Actually Fixes It
WordPress sets the From header and leaves the envelope sender to the host. That mismatch is why SPF fails and DMARC buries the receipt.
Ana Ferreiro · @aferreiro
WooCommerce engineering
- Topic
- WOO
- Section
- WooCommerce & Storefront
- Published
- 07 MAY 2026
- Reading time
- 8 MIN
- Words
- 1934W
The store owner knows the mail is working, because the plugin log says so and a test message to a personal Gmail account arrives in a second. The customer's receipt is in the spam folder, or the promotions tab, or nowhere. The order shipped, the tracking number is in the database, and the email that would have said so did not arrive.
Two things have to line up before that stops: the mechanics of how the message leaves the server, and the alignment between what your headers claim and what your DNS authorises. On a WooCommerce 9.6 store running WordPress 6.7 and PHP 8.2 with no SMTP plugin, the mechanics are usually wrong out of the box.
wp_mail() is a function call, and the sending path lives somewhere else
WooCommerce has no mailer of its own. WC_Email::send() attaches filters, hands the
message to a callback, and the default callback is wp_mail():
// WC_Email::send(), condensed
add_filter( 'wp_mail_from', array( $this, 'get_from_address' ) );
add_filter( 'wp_mail_from_name', array( $this, 'get_from_name' ) );
$mail_callback = apply_filters( 'woocommerce_mail_callback', 'wp_mail', $this );
$mail_callback_params = apply_filters(
'woocommerce_mail_callback_params',
array( $to, $subject, $message, $headers, $attachments ),
$this
);
$return = $mail_callback( ...$mail_callback_params );
The whole sending path is replaceable through woocommerce_mail_callback without
touching wp_mail(), which is how an API-based mailer works. More useful when you are
debugging: wp_mail() returns true as soon as PHPMailer has handed the message to the
transport. A true return means PHP raised no error. It does not mean a relay accepted the
message.
The transport is chosen by whatever is hooked to phpmailer_init, or by a plugin that
short-circuits wp_mail() earlier through pre_wp_mail. With nothing hooked,
it is PHP's mail() on a shared host, and that is where the trouble starts. Log
wp_mail_failed, which fires with a WP_Error when PHPMailer throws,
alongside wp_mail_succeeded, which fires once the transport accepts. Together they
separate a failed send from a send that worked and was dropped downstream.
The From header and the envelope sender are different addresses
A message has two senders. The From: header is what the customer sees. The envelope
sender, recorded by the receiving server as Return-Path, is what SPF is checked against.
They are allowed to differ, and in WordPress they nearly always do.
// wp-includes/pluggable.php, inside wp_mail()
$from_email = apply_filters( 'wp_mail_from', $from_email );
$from_name = apply_filters( 'wp_mail_from_name', $from_name );
$phpmailer->setFrom( $from_email, $from_name, false );
That third argument is the story. PHPMailer's setFrom() takes an $auto
flag, and when it is true the method copies the From address into
$phpmailer->Sender. WordPress passes false. PHPMailer's documentation for
that property says the envelope sender is usually turned into a Return-Path header by the
receiver and, when set, goes out as -f to sendmail or as MAIL FROM over
SMTP. Left empty, it falls back to sendmail_from in php.ini. If that is
empty too, no -f is passed and PHP's mail() picks the envelope sender
itself.
On a shared host, that address belongs to the provider. Your customer sees
orders@yourstore.com; the receiving server sees an envelope sender at something like
someuser.sharedbox.net, checks SPF for that domain, and finds the provider's own
authorisation. The message passes SPF and fails alignment, because the authenticated domain is not the
one in the From header.
Core makes this worse before WooCommerce is involved. With no From header supplied, WordPress
builds the address as wordpress@ plus the site host with www. stripped, and
the comment beside that code admits some hosts will refuse mail from an address which does not
exist.
SPF, DKIM and DMARC: alignment is the part that decides the inbox
Three mechanisms, three jobs.
| Record | What it authorises | What breaks it |
|---|---|---|
| SPF | Hosts allowed to send for the envelope sender's domain | Forwarding, and any relay that rewrites the envelope sender |
| DKIM | A signature over headers and body, published under a selector | An edit after signing, or a rotated key the DNS does not know |
| DMARC | A policy for when neither check passes with alignment | Nothing directly; it is the rule that makes the other two matter |
DMARC decides the outcome. It asks for at least one passing authentication that also aligns with
the domain in From:. Relaxed alignment lets a subdomain align with its parent; strict
alignment requires an exact match. A passing SPF check for the provider's domain does not help a
message whose From header is your store's, and neither does a passing DKIM signature whose
d= belongs to your mailing platform.
DKIM is the one you control end to end, and the one that survives forwarding. Forwarding is exactly where SPF breaks: a list or a group address rewrites the envelope sender to its own domain, SPF fails at the final destination, and only the signature still says who wrote the message. If you do one thing this week, sign the mail and publish the key.
Send from a subdomain rather than the apex, so order mail does not inherit the newsletter's reputation:
; SPF: only the relay you actually send through
mail.yourstore.com. TXT "v=spf1 include:relay.example.net -all"
; DKIM: the selector your relay signs with
wc._domainkey.mail.yourstore.com. TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4..."
; DMARC: monitor first, enforce later
_dmarc.mail.yourstore.com. TXT "v=DMARC1; p=none; rua=mailto:dmarc@yourstore.com; adkim=r; aspf=r"
p=none is deliberate. Collect aggregate reports for two weeks and confirm that every
stream your store sends appears in them before you move to quarantine. Enforcing a policy
before you know which plugins send mail as your domain is how a store loses its own receipts. The
subdomain needs its own DMARC record, because a policy on the apex does not cover a subdomain unless
you set the subdomain policy there instead.
Point the From address at a domain you can authenticate
WooCommerce has its own From setting and its own filter in front of it:
// WC_Email::get_from_address(), condensed
public function get_from_address( $from_email = '' ) {
$from_email = apply_filters(
'woocommerce_email_from_address',
get_option( 'woocommerce_email_from_address' ),
$this,
$from_email
);
return sanitize_email( $from_email );
}
The setting is under WooCommerce, Settings, Emails, stored in
woocommerce_email_from_address. Leave it empty and WooCommerce falls through
wp_mail_from to core's wordpress@yourhost. Point it at the sending
subdomain and set the envelope sender to match:
// wp-content/mu-plugins/woo-mail-from.php
add_filter( 'woocommerce_email_from_address', function ( $address, $email ) {
return 'orders@mail.yourstore.com';
}, 10, 2 );
// Set the envelope sender too, so SPF is checked against a domain you own.
add_action( 'phpmailer_init', function ( $phpmailer ) {
$phpmailer->Sender = 'bounces@mail.yourstore.com';
} );
Because WC_Email::send() adds these filters around every send, one that returns a
value unconditionally changes all of the store's transactional mail: receipts, shipping
notifications, password resets. To treat one email type differently, check the $email
object's id before returning.
Now the mistake worth naming, because it is common and self-inflicted. Setting
woocommerce_email_from_address to the customer's own address, so the receipt appears to
come from them, means your server claims a domain it has no authority over. That is what SPF and
DMARC exist to detect. You will fail DMARC at the receiving end, and where the customer's domain
publishes p=reject the receipt is discarded rather than filed. Keep your own address in
From, and use a Reply-To header if replies need to reach a human.
Where the envelope sender is decided
Setting $phpmailer->Sender on phpmailer_init only helps while
PHPMailer is still sending. A plugin that takes over earlier through pre_wp_mail never
runs that hook, and the envelope becomes the provider's business.
On a sale day, volume is what separates transactional from bulk reputation
Everything above can be correct and the mail can still land in spam during a sale, because volume rises in a burst no other day resembles.
Large providers judge transactional and bulk mail separately, but only where you have separated
them yourself, by domain or subdomain or IP. If receipts and the newsletter leave through the same
relay under the same domain, the newsletter's complaint rate attaches to the receipts. That is the
strongest argument for the mail. subdomain and, where you can, a different relay for
campaigns.
Volume limits are the other half. Relays and receiving providers enforce per-hour and
per-connection caps, and the error is usually a temporary 4xx deferral rather than a rejection. A
message deferred at 14:02 and delivered at 14:20 looks like success in a log that recorded "sent".
Look at the Received: chain and the relay's deferral counters. A store doing
200 orders on a normal day and 3,000 on Black Friday should be watching the first ten minutes of the
burst, and that is not the ten minutes in which to send a campaign.
What the received headers actually tell you
Get a copy of a message that landed in spam and read the headers, not the body.
Authentication-Results, the verdict added by the receiving provider, naming each mechanism with the domain it was evaluated for. A pass for somebody else's domain is still a failure.Received-SPF, the SPF result and the envelope sender it was checked against. If that domain is not yours, you have found the alignment failure in one line.Return-Path, the envelope sender as the final server saw it. A difference from yourFrom:is normal; a difference between unrelated domains is the bug.DKIM-Signature, carrying thed=domain and thes=selector. If the header is absent, nothing signed the message.Received, read bottom to top. A long gap between two timestamps is a deferral the sending log never mentioned.
One pattern describes a default WordPress install exactly: spf=pass naming the
hosting provider and dkim=none. Nothing signed the message, so any DMARC policy you
publish will be evaluated against mail that cannot pass it. In that state the first move is a
transport change, not an edit to the template.
An SMTP plugin fixes the mechanics and nothing else
Installing an SMTP plugin and authenticating to a relay fixes the parts a shared host breaks. The relay publishes SPF for its own sending infrastructure, it will sign your mail once you
publish the key it gives you, and it gives you a log that distinguishes accepted from rejected rather
than the single boolean wp_mail() returns. If mail was failing because PHP's
mail() was handing the envelope to the provider's own domain, that is repaired. On the
SMTP path PHPMailer uses the From address as MAIL FROM when no Sender is set, which
stops the envelope leaking the host's identity before you write any filter.
None of that touches content filtering. A receipt with a tracking pixel, a shortened link, an image-heavy table and a call to action about payment is scored on its content. The relay accepts it and reports success; the receiving provider still files it in promotions or in spam. Authentication gets you considered, not read. Nor can a plugin repay reputation: if the domain or the IP has been sending to a list that generates complaints, authenticating properly does not erase the history. No plugin fixes a store that mails every customer on every status change either. That is a volume problem in a deliverability costume, and its complaints attach to the receipts.
Here is the order I work in.
- Get one message into a mailbox you control and read
Authentication-Resultsbefore changing anything. - Move sending to a subdomain of the store's own domain, and publish SPF, DKIM and a
p=noneDMARC record there. - Install an SMTP or API transport and confirm the relay signs with the selector you published.
- Set
woocommerce_email_from_addressto a mailbox on that subdomain, and set the envelope sender to a domain you authenticate. - Log
wp_mail_failedso a failed send stays visible for longer than the second it takes to return false. - Read the DMARC aggregate reports after two weeks, and only then tighten the policy.
Ana Ferreiro · @aferreiro
WooCommerce engineering
Covers the WooCommerce order lifecycle: checkout, HPOS, session handling, transactional email and the post-purchase layer where most storefront complaints actually originate.
Continue