Skip to content
System nominalUTC --:--:--Articles 041

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
Technical cover graphic for Why WooCommerce Order Email Lands in Spam, and What Actually Fixes It
Generated schematic — WOO series, REV 2.6.

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.

RecordWhat it authorisesWhat breaks it
SPFHosts allowed to send for the envelope sender's domainForwarding, and any relay that rewrites the envelope sender
DKIMA signature over headers and body, published under a selectorAn edit after signing, or a rotated key the DNS does not know
DMARCA policy for when neither check passes with alignmentNothing 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 your From: is normal; a difference between unrelated domains is the bug.
  • DKIM-Signature, carrying the d= domain and the s= 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.

  1. Get one message into a mailbox you control and read Authentication-Results before changing anything.
  2. Move sending to a subdomain of the store's own domain, and publish SPF, DKIM and a p=none DMARC record there.
  3. Install an SMTP or API transport and confirm the relay signs with the selector you published.
  4. Set woocommerce_email_from_address to a mailbox on that subdomain, and set the envelope sender to a domain you authenticate.
  5. Log wp_mail_failed so a failed send stays visible for longer than the second it takes to return false.
  6. 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

Related in WooCommerce & Storefront