The wp_mail_failed action is the first tool most WordPress developers reach for when a site stops sending mail, and often it’s the wrong one. The hook does what it says: it fires when wp_mail() catches a PHPMailer exception. It doesn’t fire on every failure a caller would call a “send failure,” and the gap between those two categories is what most add_action('wp_mail_failed', ...) snippets don’t tell you.
The behaviour below is verified against wp-includes/pluggable.php at current WordPress core (post-6.9), on 2026-07-07.
What wp_mail_failed actually fires on
wp_mail_failed was added in WordPress 4.4. It fires from exactly two points inside wp_mail(), and both catch the same specific exception type: PHPMailer\PHPMailer\Exception.
- Setting the From address.
$phpmailer->setFrom()throws if the From address is malformed or fails PHPMailer’s internal address validation. One pattern that hits this: a plugin or filter callback settingfrom_emailat runtime from an unsanitised input. - Sending.
$phpmailer->send()throws when the transport fails. For a default WordPress install using PHPmail()under the hood, that’s a local MTA failure. For an SMTP configuration, it covers SMTP authentication failure, TLS negotiation failure, or an SMTP-layer recipient rejection.
Both catches build the same WP_Error and fire the same action:
do_action( 'wp_mail_failed', new WP_Error( 'wp_mail_failed', $e->getMessage(), $mail_error_data ) );
Every other try/catch inside wp_mail() uses a different pattern: catch ( PHPMailer\PHPMailer\Exception $e ) { continue; }. Those failures are swallowed silently. This is the more surprising behaviour and worth reading closely.
- Recipient parsing.
addAddress,addCC,addBCC, andaddReplyToare wrapped individually. A malformed address on any one of them drops that recipient from the send and moves the loop on. Nothing is logged; the hook does not fire. - Custom headers.
addCustomHeaderis wrapped the same way. A bad header is silently dropped from the send. - Attachments.
addAttachmentis wrapped the same way. An unreadable file, a path that fails PHPMailer’s checks, or a missing file drops that attachment and moves on. The send proceeds with whatever attachments did stick. - Embedded images (WP 6.9+).
addEmbeddedImageis wrapped the same way. A failed embed drops silently and the send continues without it.
The practical consequence: a wp_mail call can proceed to send() with fewer recipients than it was asked to send to, a missing custom header, a missing attachment, or a missing embed, and only ever surface a wp_mail_failed event if the send itself throws. If the reduced payload happens to send successfully, wp_mail_succeeded fires with the successfully-sent version of the message, not the original arguments.
wp_mail_succeeded was added in 5.9 and fires when $phpmailer->send() returns without throwing. The core docblock is explicit that a wp_mail_succeeded event does not mean the recipient received the mail, only that send() returned without an error.
What it does NOT fire on
Beyond the silent-continue catches inside the function, four larger failure classes never touch the hook.
The pre_wp_mail short-circuit
Early in wp_mail(), before any PHPMailer setup, the function runs the pre_wp_mail filter:
$pre_wp_mail = apply_filters( 'pre_wp_mail', null, $atts );
if ( null !== $pre_wp_mail ) {
return $pre_wp_mail;
}
Any non-null return from a filter callback ends wp_mail‘s execution. The whole rest of the function, including both wp_mail_failed fire paths and the silent-continue catches, is skipped. Whatever the callback does with the message after that, the hook never hears about it. Queue plugins use it this way: Mail Queue returns from pre_wp_mail once the message is stored, and the real send happens later, when its cron job calls wp_mail() again.
The three big SMTP mailer plugins do not use this filter to take over the send. We read the source of the current wordpress.org releases on 2026-10-22, and each one hooks into the send a different way:
- WP Mail SMTP 4.10.3 doesn’t replace
wp_mail()at all. Onplugins_loadedit swaps the global$phpmailerfor its own PHPMailer subclass and configures it onphpmailer_init. Core’swp_mail()runs from top to bottom, provider-API mailers included. When a Postmark or SendGrid request fails, the subclass throws aPHPMailer\PHPMailer\Exception, and core’ssend()catch fireswp_mail_failedas usual. - FluentSMTP 2.4.1 replaces the pluggable
wp_mail()with its own copy of core’s function. The copy still appliespre_wp_mail, firesphpmailer_init, and fireswp_mail_failedfrom the same two points core does. A failed API call is turned into a PHPMailer exception so it lands in that catch. - Post SMTP 4.0.2, in its default mailer mode, also replaces
wp_mail(). Its replacement appliespre_wp_mailand fireswp_mail_failedwhen a send fails, but it never firesphpmailer_init. In its alternative “PHPMailer” mode it leaveswp_mail()alone and swaps the PHPMailer object, as WP Mail SMTP does.
So a wp_mail_failed handler still hears transport failures under all three. In the free releases, two settings change whether it hears them:
- A fallback connection. FluentSMTP and Post SMTP can retry a failed send on a second connection. They fire
wp_mail_failedonly if the fallback fails as well. A send the fallback rescues fireswp_mail_succeededand nowp_mail_failed, although both plugins record the first failure in their own log. - WP Mail SMTP’s Optimize Email Sending. With that setting on (WP Mail SMTP > Settings > Misc), the message goes into a queue and is sent later in the background. (Mail sent during a WP-Cron, WP-CLI or Action Scheduler run, and the plugin’s own test emails, skip the queue and go out at once.) Before the queued send runs, the plugin removes every callback on
wp_mail,pre_wp_mail,wp_mail_succeededandwp_mail_failed, so your handler isn’t attached when the send fails.
WP Mail SMTP Pro also offers a Backup Connection. The Pro code isn’t on wordpress.org and we haven’t read it, so we can’t say whether a send rescued by the backup fires wp_mail_failed.
Plugins calling PHP mail() directly
A subset of older contact-form and WooCommerce integrations call PHP’s mail() function outright, skipping wp_mail entirely. Nothing inside wp_mail runs. There is no PHPMailer instance to throw. The hook has nothing to fire on.
This class of failure is often invisible to WordPress-layer instrumentation. The MTA log or an operating-system trace is the only place the send appears. If the site is on shared hosting with mail() sandboxed, the call may return true and produce no log line at all. See Check that WordPress can send emails for the layer-by-layer diagnostic.
Non-PHPMailer exceptions
The catch blocks in wp_mail are specific: catch ( PHPMailer\PHPMailer\Exception $e ). They do not catch \Throwable, \Error, or arbitrary \Exception subclasses.
phpmailer_init fires outside any try/catch, so a \TypeError thrown by a callback on that action, or a \RuntimeException raised by a plugin that misconfigures PHPMailer, propagates out of wp_mail without triggering the hook. From the caller’s perspective, wp_mail failed; from the hook’s perspective, nothing happened. The failure shows up in the PHP error log if WP_DEBUG and WP_DEBUG_LOG are on, and nowhere else. Rare, but explains the “the hook is silent and the error log has a fatal on the same request” pattern.
wp_mail succeeds; the message never arrives
$phpmailer->send() returned without throwing. PHPMailer handed the message to its configured transport and the transport didn’t raise an immediate error. From wp_mail‘s point of view, nothing failed. The hook does not fire.
The message can still be lost after that point:
- A local MTA accepts the message and drops it (sandboxed
mail(), disabled relay). - The SMTP relay accepts the message and rejects it in an asynchronous bounce that never makes it back into WordPress.
- The receiving server accepts the message and files it in spam.
These are deliverability failures, not wp_mail failures, and no hook inside wp_mail will surface them. Inbox placement testing and the MTA log are the relevant signals.
Hook signature and WP_Error contents
Both fire paths construct the WP_Error the same way:
- Error code:
wp_mail_failed. - Error message:
$e->getMessage()from the PHPMailer exception. SMTP recipient rejection reasons appear here verbatim. - Error data: an array of the mail arguments plus a PHPMailer exception code. The send path carries
to,subject,message,headers,attachments,embeds, andphpmailer_exception_code. The setFrom path carries the same shape withoutembeds(it fires before the embeds loop runs). Those are the only two shapes; no other catch inwp_mailfires the hook.
Callback signature is a single argument, the WP_Error object. Retrieval on the callback side:
$error->get_error_message(); // The PHPMailer exception message
$error->get_error_data(); // The array above
A minimal handler
Enough to root-cause a typical failure without turning the log file into a mailbox:
add_action( 'wp_mail_failed', 'nanopost_log_wp_mail_failed' );
function nanopost_log_wp_mail_failed( WP_Error $error ) {
$data = $error->get_error_data();
$to = is_array( $data['to'] ?? null ) ? implode( ',', $data['to'] ) : (string) ( $data['to'] ?? '' );
error_log( sprintf(
'[wp_mail_failed] to=%s subject=%s code=%s msg=%s',
$to,
(string) ( $data['subject'] ?? '' ),
(string) ( $data['phpmailer_exception_code'] ?? '' ),
substr( $error->get_error_message(), 0, 200 )
) );
}
Two notes on this handler:
error_log()writes to PHP’s configured error log. On WordPress that iswp-content/debug.logwhen bothWP_DEBUGandWP_DEBUG_LOGaretrueinwp-config.php; settingWP_DEBUG_LOGalone does nothing. Otherwiseerror_log()writes to the server’s error log. Turn both on before assuming the handler is silent, because a hook that fires into a disabled log looks identical to a hook that never fires.- The handler intentionally does not log the message body or headers. Both routinely contain personally identifiable information and, in the case of headers, authentication tokens embedded by mailer plugins. If the exception code and recipient don’t diagnose the failure, add message and headers under a separate
WP_DEBUGguard and delete the log when the debugging session is over.
For a code-free equivalent: the Check & Log Email plugin logs every wp_mail call and every wp_mail_failed event with the full WP_Error payload rendered in the admin.
When wp_mail_failed is the wrong tool
Three redirects worth naming:
- The mailer plugin retries or defers the send. With a fallback connection on FluentSMTP or Post SMTP, or Optimize Email Sending on WP Mail SMTP, the handler misses failures the plugin handles itself. WP Mail SMTP Pro’s Backup Connection may do the same; we haven’t read the Pro code. Use the plugin’s own log: WP Mail SMTP Pro, FluentSMTP, and Post SMTP each ship one.
wp_mailreturns true, the inbox is empty. This is deliverability, notwp_mail. Walk the send layers with Check that WordPress can send emails and then run an inbox placement test.- Custom code that calls PHPMailer directly. Wrap the call site in
try/catch ( PHPMailer\PHPMailer\Exception $e )and log there. The core hook won’t see calls the core function didn’t make.
The hook is a scalpel, not a net. It cuts a specific slice of wp_mail‘s failure surface, and the slice it cuts is real. Reading a silent handler log as “the site is sending fine” is the most common false positive in WordPress email debugging.
Corrections
- 2026-10-22: an earlier version said WP Mail SMTP, FluentSMTP and Post SMTP take over the send through
pre_wp_mail, sowp_mail_failednever fires under them. None of the three plugins hookspre_wp_mail, and all three firewp_mail_failedwhen a send fails (checked against WP Mail SMTP 4.10.3, FluentSMTP 2.4.1 and Post SMTP 4.0.2).
