The password reset form says the email is on its way. The user checks the inbox, the spam folder, the promotions tab, and then opens a support ticket. You check the debug log. Nothing. You check the database. The reset key exists. You check wp_mail() in a scratch plugin and it returns true. The mail server logs show no connection attempt. This is not a mystery. It is a contract you have not read literally enough.
Symptom: success on screen, silence in the inbox
The failure mode is consistent enough to describe as a pattern. A user requests a password reset. WordPress renders the confirmation screen. No email arrives. If you instrument the reset path, retrieve_password() returns true. If you call wp_mail() directly with the same arguments, it also returns true. The site reports no error because, from WordPress’s perspective, no error occurred.
The reason is in the function’s own documentation, which is unusually blunt about this:
A true return value does not automatically mean that the user received the email successfully. It just only means that the method used was able to process the request without any errors.
That sentence is the whole article compressed into two lines. The return value is a request-processing signal, not a delivery receipt. Everything below is the trace of why that distinction matters in production, and what to instrument before you reach for a plugin.
Trace: what wp_mail() actually does before it returns
Read the source. The function is in wp-includes/pluggable.php, and the shape of it is worth walking through because the return value is determined at a specific point in the sequence, not at the end of the send.
First, wp_mail() compacts its arguments and runs them through the wp_mail filter. Then it checks pre_wp_mail. If any callback returns a non-null value from that filter, wp_mail() returns that value immediately and never touches PHPMailer. This is the first place a true can come from without a single byte leaving the server.
$pre_wp_mail = apply_filters( 'pre_wp_mail', null, $atts );
if ( null !== $pre_wp_mail ) {
return $pre_wp_mail;
}
If nothing short-circuits, the function reaches for the global $phpmailer. If the global is not already an instance of PHPMailer\PHPMailer\PHPMailer, it loads the bundled PHPMailer files and instantiates WP_PHPMailer. That instance is reused across calls in the same request, which is why the function clears recipients, attachments, custom headers, reply-tos, body, and alt-body before configuring the next message.
Then it sets the From address inside a try/catch:
try {
$phpmailer->setFrom( $from_email, $from_name, false );
} catch ( PHPMailer\PHPMailer\Exception $e ) {
$mail_error_data = compact( 'to', 'subject', 'message', 'headers', 'attachments' );
$mail_error_data['phpmailer_exception_code'] = $e->getCode();
do_action( 'wp_mail_failed', new WP_Error( 'wp_mail_failed', $e->getMessage(), $mail_error_data ) );
return false;
}
This is the only place in the retrieved source where wp_mail_failed fires. It fires when setFrom() throws. It does not fire when the later send step fails, because the later send step is not inside this catch. That asymmetry is the root of the bug report.
After the From address is set, wp_mail() assigns Subject and Body, iterates the recipient headers, and then calls:
// Set to use PHP's mail().
$phpmailer->isMail();
That line is the default transport decision. Unless something on phpmailer_init changes it, the message is handed to PHP’s mail() function. The documentation for phpmailer_init states the relationship plainly: wp_mail() relies on the PHPMailer class to send email through PHP’s mail function, and the hook lets you pass your own arguments to the PHPMailer object.
Content type, charset, custom headers, attachments, and embeds are configured next. Then the function reaches the send step. The retrieved source excerpt ends before that step, but the structure is enough to state the important fact: the true return is produced by the send call’s result, and the send call’s result reflects whether PHPMailer could process the request, not whether a remote MTA accepted the message.
Root cause: the return value is not a delivery signal
Three things are true at once, and they explain the whole failure mode.
One. wp_mail() returns true when the method used processed the request without errors. With the default transport, that method is PHP’s mail(). On many hosts, mail() returns true when the message has been handed to the local MTA or sendmail binary, not when it has been delivered. If the local MTA then drops the message, bounces it, or routes it to a queue that never drains, wp_mail() has already returned true.
Two. wp_mail_failed fires from the setFrom() catch. It is not a general delivery-failure channel. If you have not hooked it, you have no log of the one failure class it does cover. If you have hooked it, you still do not get a signal for failures that occur after the From address is set.
Three. The password reset flow inherits this ambiguity directly. retrieve_password() calls wp_mail() and checks the return value:
if ( ! wp_mail( $to, $subject, $message, $headers ) ) {
$errors->add( 'retrieve_password_email_failure', sprintf(
__( 'Error: The email could not be sent. Your site may not be correctly configured to send emails. Get support for resetting your password.' ),
esc_url( __( 'https://wordpress.org/documentation/article/reset-your-password/' ) )
) );
return $errors;
}
return true;
When wp_mail() returns false, the user sees an error and the function returns a WP_Error. When wp_mail() returns true, retrieve_password() returns true and the user sees the confirmation screen. There is no third branch. There is no delivery confirmation. The reset key is in the database either way, because get_password_reset_key() runs before the mail call.
So the user gets a success screen, the key exists, and the email is somewhere between the PHP process and the recipient’s inbox. The site has no mechanism to tell you which.
Fix: instrument the handoff and the failure action
The fix is not a plugin. The fix is instrumentation that makes the invisible handoff visible. Two hooks do most of the work.
Hook wp_mail_failed and log the WP_Error
The hook documentation describes it as firing after a PHPMailer exception is caught, and the $error parameter as a WP_Error containing the PHPMailer exception message plus an array with recipient, subject, message, headers, attachments, and embeds. It was introduced in WordPress 4.4.0. A minimal logger looks like this:
add_action( 'wp_mail_failed', function ( WP_Error $error ) {
$data = $error->get_error_data();
error_log( sprintf(
'wp_mail_failed: %s | to=%s | subject=%s | code=%s',
$error->get_error_message(),
is_array( $data['to'] ?? null ) ? implode( ',', $data['to'] ) : ( $data['to'] ?? '' ),
$data['subject'] ?? '',
$data['phpmailer_exception_code'] ?? ''
) );
} );
This will not catch every silent failure. It will catch the setFrom() class of failure, which is the one the core source actually dispatches. If your log stays empty while mail still disappears, you have learned something useful: the failure is downstream of the From address, and you need to look at the transport.
Inspect the transport on phpmailer_init
The phpmailer_init action fires after PHPMailer is initialized and passes the instance by reference. The hook page notes that it is initiated with do_action_ref_array rather than do_action, which matters if you write a callback that expects a single array argument. You still register it with add_action, but the callback receives the PHPMailer instance as its first parameter.
A diagnostic callback that logs the transport configuration without changing it:
add_action( 'phpmailer_init', function ( $phpmailer ) {
error_log( sprintf(
'phpmailer_init: Mailer=%s Host=%s Port=%s SMTPAuth=%s',
$phpmailer->Mailer ?? 'unknown',
$phpmailer->Host ?? '',
$phpmailer->Port ?? '',
isset( $phpmailer->SMTPAuth ) ? ( $phpmailer->SMTPAuth ? 'true' : 'false' ) : 'unset'
) );
} );
If Mailer is mail, you are on PHP’s mail() and the return value tells you almost nothing about delivery. If Mailer is smtp, you have a transport that can report connection and authentication failures, and the return value becomes more meaningful — though still not a delivery receipt.
The hook page’s own example shows the SMTP configuration pattern: call isSMTP(), set Host, SMTPAuth, Port, Username, Password, and optionally SMTPSecure. That is the documented way to move off PHP’s mail() without installing a plugin.
Watch the content-type filter
One more piece of shared state is worth flagging because it produces unrelated failures that look like this one. The wp_mail_content_type filter is global to the request. The documentation warns that failing to reset it back to text/plain after sending can lead to unexpected problems with emails from WordPress, plugins, and themes. If you set it to text/html for one message and do not restore it, subsequent messages in the same request inherit the HTML content type. That is a different bug, but it lands in the same inbox.
Verification: distinguish a false positive from a genuine send
You cannot verify delivery from inside WordPress with the default transport. You can verify the following, in order, and each step narrows the failure domain.
Step 1: Confirm the reset key exists. The key is created before wp_mail() is called, so its presence tells you the reset flow reached the mail step.
wp db query "SELECT user_login, user_activation_key FROM $(wp db prefix)users WHERE user_activation_key != '' ORDER BY user_registered DESC LIMIT 5;"
If the key is there and the email is not, the failure is in the mail path, not the reset logic.
Step 2: Confirm the transport. Use the phpmailer_init logger above. If Mailer is mail, you are relying on the host’s local mail configuration. The wp_mail() documentation notes that for the function to work, the SMTP and smtp_port settings (default: 25) need to be set in php.ini. That is a host-level dependency, not a WordPress one.
Step 3: Confirm the failure action is wired. If wp_mail_failed is not hooked, you have no log of the one failure class core dispatches. Hook it and re-run the reset. An empty log after a failed reset is itself evidence: the failure is downstream of setFrom().
Step 4: Check the host’s mail log. This is outside WordPress and outside the scope of the retrieved sources, but it is the only place that can tell you whether the local MTA accepted the message. If the MTA log shows no connection, the message never left PHP. If it shows a connection and a queue ID, the message left the server and the failure is downstream.
Step 5: Test with a known-good recipient. Send a test message to an address you control on a different domain. If it arrives, the transport works and the problem is filtering or routing. If it does not, the transport is the problem.
What this means for the password reset flow specifically
The reset flow is the highest-stakes consumer of wp_mail() because it is the only path a locked-out user has. The dependency is direct and documented: retrieve_password() returns true when finished and a WP_Error object on error, and the only mail-related error it adds is retrieve_password_email_failure, which is gated on wp_mail() returning false.
That means the user-facing behavior is binary in a way that does not match reality. Either the site says the email could not be sent, or it says nothing and the user waits. There is no state for “the site thinks it sent the email but the email did not arrive.” If you maintain a production install, that state is yours to instrument, because core does not have it.
The practical posture is to treat wp_mail()‘s return value as a request-processing signal and nothing more. Log the failure action. Log the transport. Verify the reset key. Check the host’s mail log. Only after those steps should you consider whether a plugin is warranted — and if you do, the plugin is replacing the transport, not fixing the return value.
FAQ
Does wp_mail_failed fire for every failed send?
No. In the retrieved source, it fires from the catch around setFrom(). Failures that occur after the From address is set are not dispatched through that catch. The hook documentation describes it as firing after a PHPMailer exception is caught, which is consistent with the source but does not extend it to the later send step.
Why does retrieve_password() return true when the email never arrives?
Because it only checks wp_mail()‘s return value. When wp_mail() returns true, retrieve_password() returns true. The return value reflects request processing, not delivery, so the reset flow inherits that ambiguity.
Is the default transport PHP’s mail()?
Yes, unless something changes it. The wp_mail() source calls $phpmailer->isMail(), and the phpmailer_init documentation states that wp_mail() relies on PHPMailer to send email through PHP’s mail function. The phpmailer_init hook is the documented place to switch to SMTP.
Why does my phpmailer_init callback not receive the PHPMailer instance?
Because the hook is initiated with do_action_ref_array rather than do_action. The hook documentation notes this distinction. You still register with add_action, but the callback receives the PHPMailer instance as its first parameter, not an array.
Can I verify delivery from inside WordPress?
Not with the default transport. You can verify that the reset key was created, that the transport is configured as expected, and that the failure action is wired. Delivery verification requires the host’s mail log or an external delivery service. The retrieved sources do not support any claim about specific delivery rates or host policies.