WordPress not sending emails is one of those problems where almost every guide gives you the same answer — install an SMTP plugin, connect SendGrid or Mailgun, done. That answer works often enough to look like the answer. It is also written, in most cases, by companies that sell SMTP plugins or email services.
What it skips is the part that decides whether any of it will help: finding out which layer is actually broken. “No email arrived” has at least four distinct causes, and an SMTP plugin only fixes one of them. Install it on a site whose form never called wp_mail() in the first place and you have changed nothing — you have just added a plugin.
Here is how to find the layer first, and what your options are at each one, including the one nobody selling SMTP wants to mention.
The four layers, and how to tell them apart
Between someone clicking Submit and an email landing in an inbox, there are four places it can die:
- Layer 1 — WordPress never called
wp_mail(). The form silently failed before sending anything. No plugin at the mail layer can help. - Layer 2 —
wp_mail()ran, but the server has no way to send. PHP’smail()hands off to a local MTA. If none is installed, it returns false and WordPress moves on quietly. - Layer 3 — The mail left your server and the recipient refused it. Missing SPF, DKIM or PTR records, a blacklisted IP, or port 25 blocked outbound.
- Layer 4 — It was accepted, and filed as spam. Delivered, technically. Useless, practically.
Work down from Layer 1. Each check takes a minute or two and tells you whether to keep going.
Layer 1 — Did WordPress even try?
Drop this in a must-use plugin file (wp-content/mu-plugins/mail-log.php) and submit the form again:
<?php
// Proves the logger itself is loaded and writable.
error_log( '[mail-probe] logger active ' . gmdate( 'c' ) );
// Two probes on pre_wp_mail. The early one records that we got here;
// the late one records what the whole filter chain decided.
add_filter( 'pre_wp_mail', function ( $short, $args ) {
error_log( '[mail-probe] entered wp_mail' );
return $short;
}, 1, 2 );
add_filter( 'pre_wp_mail', function ( $short, $args ) {
if ( null === $short ) {
error_log( '[mail-probe] core will handle the send' );
} elseif ( false === $short ) {
error_log( '[mail-probe] SHORT-CIRCUITED with false: a plugin blocked this send' );
} else {
error_log( '[mail-probe] short-circuited: a plugin took over the send' );
}
return $short;
}, PHP_INT_MAX, 2 );
add_filter( 'wp_mail', function ( $args ) {
$to = is_array( $args['to'] ) ? reset( $args['to'] ) : $args['to'];
// Log the domain only — never full recipient addresses or subjects.
error_log( '[mail-probe] wp_mail args; to-domain='
. substr( strrchr( (string) $to, '@' ), 1 )
. ' subject-len=' . strlen( (string) $args['subject'] ) );
return $args;
} );
add_action( 'wp_mail_succeeded', function () {
error_log( '[mail-probe] handler accepted the message' );
} );
add_action( 'wp_mail_failed', function ( $error ) {
error_log( '[mail-probe] FAILED: ' . $error->get_error_message() );
} );
Then check wp-content/debug.log (with WP_DEBUG_LOG enabled) or your PHP error log.
Read the first line before anything else. If [mail-probe] logger active is not there, your logger is not running and every conclusion below would be wrong — the file may not be in a loaded mu-plugins directory, the log path may not be writable, or WP_DEBUG_LOG may be off. Fix that before you interpret silence as evidence.
- Logger line present, nothing else — the form never reached
wp_mail(). Stay on Layer 1. - “a plugin took over the send” — something returned a non-null value from
pre_wp_mail, so core never ran. Usually an SMTP or API plugin; that plugin now owns the outcome and its own log is where the answer is. - “SHORT-CIRCUITED with false” — this is different and easy to misread.
falsemeans a plugin deliberately blocked the message. Nothing was sent and nothing failed; something decided not to send. Look for anti-spam, maintenance-mode or environment-based mail blockers. - “handler accepted the message” — the send function returned success. This means the local handler took it, not that a recipient server accepted it, and certainly not that it was delivered. Go to Layer 2.
- “FAILED” — you have an actual error message, which is more than most people troubleshooting this ever get.
When nothing is logged at all, the fault is in the form, not the mail. Two failure modes we hit repeatedly on client sites, both of which look like “email not sending”:
- A form field whose prefilled value fails its own validation pattern. A phone field with a placeholder or default value that doesn’t match the regex on the same field will fail validation server-side while looking perfectly fine in the browser. The submission dies before any action runs.
- A malformed redirect action. If the form is configured to redirect after submit and the redirect target is stored in the wrong shape — an array where a string is expected — the action chain throws before the email action is reached. The visitor sees a spinner, or a success message, and nothing is ever sent.
Both are invisible unless you look at the form plugin’s own logs or reproduce a real submission yourself. Testing with the plugin’s built-in “send test email” button will not catch either one, because that button skips the form entirely.
A third variant is worth knowing because it fails the same way and is even harder to see: a captcha whose site key does not cover the hostname visitors actually land on. The widget refuses to issue a token, the server rejects the submission for an empty captcha field, and nothing is logged anywhere you would look — we lost twelve days to exactly that.
Layer 2 — Can this server send mail at all?
Over SSH, ask PHP directly:
php -r 'var_dump( mail( "you@example.com", "layer2 test", "body" ) );'
which sendmail
ls -l /usr/sbin/sendmail
bool(false), or no sendmail binary, means there is no local mail transfer agent. A surprising number of VPS images ship without one — the server has never been able to send a single email, and every plugin-level fix you try will fail for the same reason.
This is the point where most articles tell you to sign up for a transactional email service. That is one valid option. It is not the only one.
Two ways to fix the transport
Option A — Give the server its own mail transfer agent
On any VPS you control, installing a send-only MTA takes about twenty minutes and costs nothing afterwards. No monthly quota, no API key to rotate, no third-party outage taking your contact form down with it. mail(), wp_mail(), and every plugin that relies on them simply start working — across every site on the box, not one at a time.
The shape of it:
- Postfix in send-only mode. Configured to relay nothing inbound, listening on loopback only.
- OpenDKIM signing outbound mail with a key per sending domain.
- DNS records — the part that actually decides whether anything is delivered. More on this below.
The trade-off is honest: you own deliverability. A shared IP at a big provider comes with a warmed-up reputation; a fresh VPS IP does not. If you send transactional volume — order confirmations, password resets, form notifications — a self-hosted MTA with correct DNS handles it fine. If you send marketing campaigns to thousands of addresses, use a service built for it.
Option B — Route through an SMTP service
The standard advice, and reasonable when you don’t control the server or don’t want to. An SMTP plugin plus SendGrid, Mailgun, Postmark, Amazon SES or your own mailbox provider.
Two things worth knowing before you assume this solves the problem:
- It does not exempt you from DNS setup. Send through SendGrid without adding their SPF and DKIM records to your domain and your mail still lands in spam. The service moves the sending IP; it does not fix your domain’s authentication.
- App passwords, not account passwords. Gmail and Outlook both require an app-specific password with 2FA enabled. Using the account password produces an authentication failure that most plugins report as a generic “could not send.”
Layer 3 — The three DNS records that decide everything
Whichever route you take, deliverability comes down to whether the receiving server can verify that your domain authorised the message. Three records do that work, and getting them right matters more than any other choice on this page.
- SPF — a TXT record listing who may send for your domain. One record only; multiple SPF records is itself a failure. Example:
v=spf1 ip4:203.0.113.10 include:_spf.google.com ~all - DKIM — a public key published at
selector._domainkey.example.com, matching the private key your MTA or provider signs with. Without it, a receiver has no cryptographic proof the message wasn’t altered. - PTR — reverse DNS mapping your sending IP back to a hostname. This one is set by whoever owns the IP, so it lives in your VPS provider’s control panel, not your DNS host. It is the record people most often skip, and several large receivers reject outright without it.
Verify before you conclude anything:
dig +short TXT example.com
dig +short TXT default._domainkey.example.com
dig +short -x 203.0.113.10
Then send one message to mail-tester.com and read the score. It tells you exactly which of the three is missing or malformed, which beats guessing.
A DMARC record (_dmarc.example.com) is worth adding once SPF and DKIM both pass. Start at p=none so you get reports without rejecting your own mail while you verify the setup.
The port 25 problem, and why it hits Chinese hosting hardest
Outbound port 25 is blocked by default on Alibaba Cloud, Tencent Cloud and most mainland providers, and unblocking it means filing a request that is frequently refused. AWS and several Western providers throttle it on new accounts for the same anti-abuse reasons.
This matters because a self-hosted MTA delivering directly to recipients needs port 25 outbound. If it is blocked, mail sits in the queue and nothing in WordPress will tell you — the send appeared to succeed at the PHP layer.
mailq
tail -50 /var/log/mail.log
A queue full of “Connection timed out” to port 25 is the signature. Two ways around it: relay through an SMTP service on submission ports (587 or 465), which are commonly reachable where 25 is not, or move the sending host elsewhere. Verify rather than assume — some networks restrict those too, and one command from the server settles it:
This is also one of the most common casualties of a host change, because shared hosting usually has a working mail agent and a fresh VPS usually does not — see what breaks quietly after a WordPress migration.
for port in 25 465 587; do
out=$(timeout 5 bash -c "</dev/tcp/smtp.example.com/$port" 2>&1)
case "$?" in
0) echo "$port open" ;;
124) echo "$port timed out (filtered, or the host is not answering)" ;;
*) echo "$port failed: ${out:-connection refused or DNS failure}" ;;
esac
done
Distinguish the failure modes rather than labelling them all “blocked”. A timeout suggests filtering; a refusal means something answered and said no; a DNS error means you never got as far as the port. They lead to different fixes.
Two more things specific to sites serving Chinese recipients:
- Domestic mailboxes may treat overseas sending IPs differently. In our own deliveries we have seen 163 and QQ reject or silently file messages from overseas VPS addresses that passed SPF and DKIM cleanly. We have not measured this across providers or over time, and these policies change, so treat it as a reason to test rather than as a rule. If your buyers or colleagues use those mailboxes, send to a real address at each one and check where it landed.
- Your recipients may be on both sides. An export business typically sends to overseas customers and to domestic colleagues from the same install. Test both directions — passing mail-tester.com says nothing about whether QQ accepted it.
If you are choosing where to host in the first place, this is one of the practical differences between mainland, Hong Kong and offshore hosting we cover in hosting a website in China.
Layer 4 — Delivered, but filed as spam
Once SPF, DKIM and PTR pass, the remaining causes are usually about the message rather than the infrastructure:
- The From address doesn’t belong to your domain. WordPress defaults to
wordpress@example.com, which often doesn’t exist as a real mailbox. Worse is a form plugin configured to send from the visitor’s address — that is a forged sender as far as SPF is concerned, and it fails by design. Send from your own domain, put the visitor’s address in Reply-To. - No plain-text part. HTML-only messages score worse with several filters.
- A brand new sending IP or domain. Reputation is built over weeks. Low volume from a fresh IP is normal to distrust.
- The receiving mailbox is a shared inbox with aggressive rules. Check the recipient’s own filters before rebuilding your mail stack.
The specific cases people search for
Most “not sending” reports come from one of four sources, and the layer differs:
- Contact Form 7 not sending. CF7 uses
wp_mail(), so it is a Layer 2/3 problem — unless the From address in the Mail tab is set to the visitor’s address, which puts you at Layer 4. Its own error messages are unhelpfully generic; use the Layer 1 log above instead. - Elementor form not sending. Check Layer 1 first. Elementor forms fail silently for form-configuration reasons far more often than for mail reasons, including the two failure modes described earlier.
- WooCommerce order emails not sending. Confirm the specific email is enabled under WooCommerce → Settings → Emails before touching anything else, and remember these are triggered on order status changes — a payment that never moved the order to Processing sends nothing, correctly.
- Password reset emails not arriving. Usually Layer 2 or 3, and worth fixing urgently, because it also means nobody can recover an account.
What each step proves, and what it does not
The reason to work in layers is only useful if each check actually rules something out. Here is the honest version of the chain, because most of the confusion in this topic comes from treating one step’s success as proof of the next:
| Step | Proves | Does not prove |
|---|---|---|
| Form submit accepted | The request reached WordPress | That any send action ran |
wp_mail() entered |
Something asked WordPress to send | That core handled it — pre_wp_mail may have handed it to a plugin |
| Handler returned success | The local sender accepted the job | That it left the machine |
| Queue is empty, log shows 250 | A recipient server accepted it | That a human will see it |
| Message found in the inbox | Delivery worked for that recipient, that day | That it works for other providers or next week |
The only test that proves delivery is opening the destination mailbox — including its spam folder — at each provider you actually send to.
A short order of operations
- Add the
wp_maillogging snippet and submit a real form. Establish whether WordPress tried at all. - If it tried: check for a local MTA with
php -r 'var_dump(mail(...));'. - Fix the transport — install a send-only MTA, or route through an SMTP service.
- Publish SPF, DKIM and PTR. Verify with
dig, then score with mail-tester.com. - Send to a real Gmail address, a real Outlook address, and — if you sell to China — a real QQ or 163 address.
- Set the From address to your own domain and the visitor’s address to Reply-To.
Steps 1 and 2 take ten minutes and determine which of the remaining steps you actually need. Skipping them is how people end up with three SMTP plugins installed and a form that still fails, because the form was broken and the mail stack never was — the same trap as chasing plugin conflicts on a white screen that turns out to be cached.
We set up send-only mail on client servers as part of ongoing WordPress support, usually for export businesses whose forms need to reach buyers overseas and staff inside China from the same install. If your forms are silently failing and you would rather not work through four layers yourself, send us the URL and we’ll tell you which layer it is.
