The wp_mail_failed hook, what it catches, and what it doesn’t

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.

  1. 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 setting from_email at runtime from an unsanitised input.
  2. Sending. $phpmailer->send() throws when the transport fails. For a default WordPress install using PHP mail() 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, and addReplyTo are 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. addCustomHeader is wrapped the same way. A bad header is silently dropped from the send.
  • Attachments. addAttachment is 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+). addEmbeddedImage is 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. This is how most SMTP mailer plugins route mail through their own transports: WP Mail SMTP, FluentSMTP, and Post SMTP all hook pre_wp_mail and take over from there.

The consequence: if any of those plugins are active, wp_mail_failed is watching a code path the mail no longer takes. A failure inside the plugin’s own transport produces no wp_mail_failed event, ever. The signals that catch these failures are the plugin’s own log (each of the three ships one), or a custom filter on pre_wp_mail at earlier priority.

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, and phpmailer_exception_code. The setFrom path carries the same shape without embeds (it fires before the embeds loop runs). Those are the only two shapes; no other catch in wp_mail fires 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 is wp-content/debug.log when both WP_DEBUG and WP_DEBUG_LOG are true in wp-config.php; setting WP_DEBUG_LOG alone does nothing. Otherwise error_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_DEBUG guard 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 site uses an SMTP mailer plugin. wp_mail_failed is watching a code path the mail no longer takes. Use the plugin’s own log: WP Mail SMTP Pro, FluentSMTP, and Post SMTP each ship one. For plugins without a log, hook pre_wp_mail at an early priority and observe what the plugin receives.
  • wp_mail returns true, the inbox is empty. This is deliverability, not wp_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.