If WordPress is not sending email, the first job is not to install an SMTP plugin. It is to find out which of four layers the message died at, because the fix for each one is different and three of them are silent.
Nearly every guide on this skips that step and goes straight to "use an authenticated SMTP service". That is often the right answer, but installing it blind means you cannot tell afterwards whether it fixed the problem or just moved it.
Why a successful send proves nothing
wp_mail() returns a boolean, and that boolean is close to meaningless. Core's own docblock in wp-includes/pluggable.php says it plainly:
A true return value does not automatically mean that the user received the email successfully.
What true actually means is that the configured transport accepted the message without raising an exception. With PHP's mail() that is the local mail queue taking it, and the queue can bounce it a second later.
PHP's own manual makes the same point about mail(): acceptance for delivery is not delivery. So "WordPress says the password reset was sent" and "the password reset was sent" are two different claims.
This is why the problem feels invisible. Nothing in wp-admin reports a failed send, and most callers in core and in plugins ignore the return value entirely.
The four layers mail can die at
Work through them in order, because each one has a different test and a different fix.
- Before PHPMailer. Since WordPress 5.7, the
pre_wp_mailfilter can short-circuitwp_mail()by returning a non-null value, and whatever it returns is what the caller sees. A plugin returningtruehere produces a perfect, silent, total mail outage. - Inside PHPMailer, before the send. A malformed address or an unparseable From triggers a
PHPMailer\PHPMailer\Exception, which core catches. It fireswp_mail_failedwith aWP_Errorand returnsfalse. - At the transport.
mail()is disabled, there is no MTA, sendmail is not configured, or the host blocks the outbound port. Depending on where it breaks, this can surface as awp_mail_failedor as nothing at all. - After the receiving server accepted it. SPF, DKIM and DMARC failures, spam filing, and silent discards all happen here. Nothing on your site will ever know about this.
Layer 4 is the one worth naming early. wp_mail_failed only fires on a PHPMailer exception, so an empty failure log does not mean the mail arrived, and no amount of code on your own site can prove it did.
Log the one failure WordPress does report
Core gives you two hooks and almost nobody uses them. wp_mail_failed has existed since 4.4, and wp_mail_succeeded since 5.9, each carrying the full message data.
Drop this in wp-content/mu-plugins/mail-debug.php so it loads before every plugin:
<?php
add_action('wp_mail_failed', function ($error) {
error_log('wp_mail failed: ' . $error->get_error_message());
error_log('wp_mail data: ' . wp_json_encode($error->get_error_data()));
});
add_action('wp_mail_succeeded', function ($mail_data) {
error_log('wp_mail accepted for: ' . implode(', ', (array) $mail_data['to']));
});
The WP_Error data includes phpmailer_exception_code alongside the recipient, subject and headers, which is usually enough to tell a bad address from a dead transport. You need WP_DEBUG_LOG on to read any of it, and a sandbox is a reasonable place to turn that on without touching production.
An mu-plugin is the right home for this rather than functions.php, because it survives a theme switch and loads before the plugin that might be causing the problem. We wrote up how the mu-plugins directory actually loads separately.
Send a test message from the command line
Trigger a send yourself instead of waiting for a contact form, so you know exactly one message is in flight:
wp eval 'var_dump( wp_mail( "[email protected]", "test", "body" ) );'
Run it with your logging mu-plugin in place. Three outcomes, three different problems.
bool(true) with no wp_mail_succeeded line means something short-circuited pre_wp_mail, which is layer 1. bool(false) with a wp_mail_failed line gives you the exception message, which is layer 2 or 3.
bool(true) with a wp_mail_succeeded line means your site has done its job. The problem is layer 4, outside the install.
That third case is the one that saves time. It tells you to stop editing PHP and go look at DNS records and your provider's activity log.
Find out who owns wp_mail()
wp_mail() is pluggable: core wraps it in if ( ! function_exists( 'wp_mail' ) ), so any plugin loading first can replace it outright. When that happens, core's hooks, its From defaults and its exception handling are all gone, and you are debugging somebody else's function.
You can ask PHP where the function was actually defined:
wp eval '$f = new ReflectionFunction("wp_mail"); echo $f->getFileName(), ":", $f->getStartLine(), "\n";'
If that prints a path under wp-includes/pluggable.php, core still owns delivery. Anything else is the plugin you should be reading the logs of.
This is not a trick we invented. It is how FluentSMTP detects conflicts with other mail plugins, and it is the same check our own sandbox helper runs before deciding whether to stand aside and let a configured relay deliver.
Watch the SMTP conversation
If you are on an SMTP plugin and the send fails at layer 3, the useful detail is in the SMTP exchange itself. phpmailer_init passes the PHPMailer instance by reference, so you can turn its debug output on:
add_action('phpmailer_init', function ($phpmailer) {
$phpmailer->SMTPDebug = 2;
$phpmailer->Debugoutput = function ($str) { error_log('smtp: ' . $str); };
});
You get the server's actual response codes, which distinguishes "authentication rejected" from "relay access denied" from "connection timed out". Remove it afterwards, because it logs credentials-adjacent chatter on every send.
The default sender is wordpress@yourdomain, not your admin email
A top-ranking guide on this problem states that the default From address is the site's admin email. It is not, and the difference matters because a nonexistent sender is one of the most common causes of a silent discard.
Core builds the address in pluggable.php: wordpress@ plus the host from network_home_url(), with a leading www. stripped. So a site at https://www.example.com sends as [email protected], a mailbox that almost certainly does not exist.
Core knows this is awkward and says so in a comment next to the code:
Some hosts will block outgoing mail from this address if it doesn't exist, but there's no easy alternative.
The comment goes on to explain why admin_email was rejected as the default, with a pointer to Trac ticket 5007: some hosts refuse to relay mail from a domain they do not host. If you want a different sender, the wp_mail_from and wp_mail_from_name filters are the supported way, and they are the first thing an SMTP plugin overrides.
When an SMTP plugin is the right fix
For layers 3 and 4, it usually is. An authenticated relay replaces an unauthenticated mail() send with one that carries SPF and DKIM alignment for a domain you control, and it gives you a provider-side log, which is the only record of what happened after your site let go of the message.
For layers 1 and 2, it fixes nothing. A plugin short-circuiting pre_wp_mail, or a malformed recipient address in a form configuration, will do exactly the same thing with a relay in place.
Be equally honest about what a relay does not solve. If your messages arrive and land in spam, that is a reputation and DNS problem, and no plugin setting and no sandbox will tell you about it.
You need the provider's reporting and your DMARC aggregate reports for that.
Test the fix where it cannot reach a customer
Every step above involves sending real mail, which is the part that makes people cautious about debugging this on a live site. A password reset test that goes to a customer, or a form notification fired twenty times while you bisect plugins, is a real cost.
That is the job a throwaway copy does well. Clone the site with Cloner, reproduce the failure there, and run the logging mu-plugin, the wp eval sends and the ReflectionFunction check over SSH with WP-CLI preinstalled.
SandyWP sandboxes go a step further, because a copied site that can still mail is dangerous. PHP's sendmail_path is pinned to /bin/false in the sandbox image and there is no MTA to reach, so a default-mailer send cannot leave the worker at all.
Instead, every wp_mail() call is captured to a per-site Email Log at pre_wp_mail priority 1, before any plugin gets the chance to short-circuit it. You open the message in the dashboard with its headers, recipients and rendered HTML, which answers "did this fire, and with what content" without delivering anything to anyone.
The exception is deliberate: if you configure a real SMTP relay in the sandbox, the gate stands aside and the mail leaves under your own provider and credentials. That is the point at which you are testing deliverability rather than code, and you need a message that genuinely arrives.
If what you are debugging is a staging or cloned copy rather than a live site, the related question of where wp_mail actually goes on a test site covers the clone-specific traps, including why disabling mail on a copy is harder than it looks.
The short version
Add the wp_mail_failed and wp_mail_succeeded logging, send one message with wp eval, and read which of the three outcomes you got. That tells you whether to go looking in plugin code, in PHP configuration, or in DNS, and it takes about two minutes.
Only then decide whether an SMTP plugin is the fix. A diagnosis you can point at beats a plugin install you have to trust.
