How to intercept wp_mail for logs and debugging in PHP in WordPress

Contents

Introduction

This tutorial explains, in exhaustive detail, how to intercept wp_mail() in WordPress for logging, debugging and development purposes. It covers WordPress internals relevant to email delivery, the available hooks and actions, practical examples (lightweight logger, raw MIME capture, database storage, admin viewer), ways to safely short-circuit sending in development, and operational considerations (performance, security, GDPR, rotation).

Quick summary of the approaches

  • Use the wp_mail filter to capture and modify the email parameters (to, subject, body, headers, attachments) just before WordPress builds and sends the message.
  • Use the wp_mail_failed action to capture failures (WP_Error) when the PHPMailer send fails.
  • Use the phpmailer_init action to inspect the PHPMailer object — you can capture the raw MIME message, SMTP debug output, or adjust the transport.
  • Override the pluggable wp_mail() function in a plugin (advanced) to completely replace the sending logic — useful for dev environments where you want to suppress sending entirely.

How wp_mail() works (internals relevant to interception)

The wp_mail() function is a pluggable function that builds email parameters then uses an instance of PHPMailer to send. Internally it:

  1. Collects inputs (to, subject, message, headers, attachments) and normalizes them into an array.
  2. Applies the wp_mail filter to that array — this is the earliest clear interception point that receives all send parameters.
  3. Creates and configures a PHPMailer instance and then calls its send() method.
  4. If send() fails, WordPress triggers the wp_mail_failed action with a WP_Error object describing the failure.

Additionally, WordPress exposes a phpmailer_init action which hands you the PHPMailer instance and lets you modify it or inspect it before sending. PHPMailer itself can produce a raw MIME message with preSend() / getSentMIMEMessage() and has SMTP debug output.

Basic interception: logging with the wp_mail filter

This is the simplest, least-invasive approach. You receive the canonical parameters used by wp_mail() and can log them to debug.log, a custom file, the database, or an external service.

Example: simple logger plugin

lt?php
/
Plugin Name: WP Mail Intercept - Simple Logger
Description: Logs wp_mail() parameters to the debug log for debugging.
Version: 1.0
Author: Example
/

add_filter( wp_mail, wpmi_log_wp_mail, 10, 1 )

function wpmi_log_wp_mail( atts ) {
    // atts contains: to, subject, message, headers, attachments
    log = array(
        time        =gt current_time( mysql ),
        to          =gt atts[to],
        subject     =gt atts[subject],
        message     =gt atts[message],
        headers     =gt atts[headers],
        attachments =gt atts[attachments],
        url         =gt ( is_admin() ? admin : ( isset(_SERVER[REQUEST_URI]) ? _SERVER[REQUEST_URI] :  ) ),
    )

    // Convert to string for php error_log (or use error_log(print_r(log, true)))
    error_log( [wp_mail]  . print_r( log, true ) )

    // Always return atts back to WordPress
    return atts
}

Notes on the simple logger

  • The logger above writes to the PHP error log (WP_DEBUG_LOG = true writes to wp-content/debug.log if configured).
  • This hook runs every time wp_mail() is called — be careful on high-traffic sites.
  • You can serialize the atts and write to a custom file using file_put_contents() with file locking for reliability.

Capture failures with wp_mail_failed

When PHPMailer fails to send, WordPress triggers wp_mail_failed and passes a WP_Error object. Use it to record failure reasons and full stack or context.

add_action( wp_mail_failed, wpmi_log_wp_mail_failed, 10, 1 )

function wpmi_log_wp_mail_failed( wp_error ) {
    if ( is_wp_error( wp_error ) ) {
        msg = sprintf( [wp_mail_failed] %s, wp_error->get_error_message() )
        // If there are error_data entries, include them
        if ( data = wp_error->get_error_data() ) {
            msg .=   data:  . print_r( data, true )
        }
        error_log( msg )
    }
}

Capture the raw MIME message and PHPMailer debug output (phpmailer_init)

To get the full raw email (headers and body exactly as PHPMailer will send), intercept the PHPMailer object with phpmailer_init. The safest approach is to clone the PHPMailer instance and call preSend() on the clone so you do not alter the original object state.

Example: capture raw MIME message without changing send behavior

add_action( phpmailer_init, wpmi_capture_phpmailer_mime )

function wpmi_capture_phpmailer_mime( phpmailer ) {
    try {
        // Clone so we dont alter the original PHPMailer instance
        clone = clone phpmailer

        // Build the message structures (preSend prepares headers and body)
        if ( method_exists( clone, preSend ) ) {
            clone->preSend()
            // getSentMIMEMessage exists in newer PHPMailer versions
            if ( method_exists( clone, getSentMIMEMessage ) ) {
                mime = clone->getSentMIMEMessage()
            } else {
                // Fallback: construct raw message from headers   body
                mime = clone->MIMEHeader . rnrn . clone->MIMEBody
            }

            // Write the MIME to the debug log (or a custom log)
            error_log( [wp_mail_mime]  . substr( mime, 0, 5000 ) ) // limit size in logs
            // Optionally store the full MIME to a file:
            // file_put_contents( WP_CONTENT_DIR . /mail-mime- . time() . .eml, mime )
        }
    } catch ( Exception e ) {
        error_log( [wp_mail_mime_error]  . e->getMessage() )
    }
}

Capture SMTP debug output

If WordPress is configured to use SMTP (isSMTP() is true), PHPMailer can emit verbose debug output. You can capture that with Debugoutput set to a callable.

add_action( phpmailer_init, wpmi_capture_smtp_debug )

function wpmi_capture_smtp_debug( phpmailer ) {
    // Only for SMTP deliveries
    if ( method_exists( phpmailer, isSMTP )  phpmailer-gtMailer === smtp ) {
        // Capture debug output in a variable
        debug = 
        phpmailer-gtDebugoutput = function( str, level ) use ( debug ) {
            debug .= [ . level . ]  . trim( str ) . PHP_EOL
        }
        phpmailer-gtSMTPDebug = 2 // 1 = commands, 2 = commands   data

        // You could log debug after send or when preSend() was called by cloning (similar approach as above)
    }
}

Short-circuit sending in development

On development or staging, you often want to prevent any outgoing mail from being actually delivered. There are several safe strategies:

  • Use the wp_mail filter to redirect recipients to a developer address or to add a header marking the message as intercepted.
  • Override the pluggable wp_mail() function in your plugin to completely replace sending logic (this prevents the core wp_mail from being declared and used). This is powerful but must be used carefully.
  • Use phpmailer_init and detect a header (e.g., X-Dev-Intercept: true) and then prevent sending by substituting the transport or by returning without calling send (requires overriding wp_mail or pluggable behavior if you want to short-circuit entirely inside PHPMailer lifecycle).

Example: developer interceptor — redirect all recipients to a single address

add_filter( wp_mail, wpmi_redirect_all_mail_to_dev, 10, 1 )

function wpmi_redirect_all_mail_to_dev( atts ) {
    // Replace dev@example.test with the address you want to receive all emails
    dev_address = dev@example.test

    // Keep original recipient info in a header for debugging
    orig_to = atts[to]
    header = X-Original-To:  . ( is_array( orig_to ) ? implode( ,, orig_to ) : orig_to )

    // Normalize headers to array
    headers = is_array( atts[headers] ) ? atts[headers] : explode( rn, (string) atts[headers] )
    headers[] = header
    headers[] = X-Intercepted-By: dev-interceptor

    atts[headers] = headers
    atts[to] = dev_address // send everything to the dev box

    return atts
}

Example: override the pluggable wp_mail() to suppress delivery (dev-only)

Pluggable functions are declared only if they are not already defined. Plugins load before pluggable functions, so a plugin can define wp_mail() to replace the core function. This completely prevents WordPress core from defining its version. Use this only in development and keep it guarded by an environment flag.

lt?php
/
Plugin Name: WP Mail Intercept - Suppress Sender (DEV ONLY)
Description: Overrides wp_mail() to suppress sending and log messages. DO NOT USE IN PRODUCTION.
Version: 1.0
Author: Example
/

if ( ! function_exists( wp_mail ) ) {
    function wp_mail( to, subject, message, headers = , attachments = array() ) {
        // Simple log of what would have been sent
        log = compact( to, subject, message, headers, attachments )
        error_log( [wp_mail_override]  . print_r( log, true ) )

        // Simulate success (do not actually send)
        return true
    }
}

Warning

Overriding wp_mail() is intrusive. Keep this in code that is only active in safe environments (do NOT activate on production). Use environment checks (WP_ENV constant, server IP checks) to avoid accidental deploys.

Store email logs in the database and build an admin viewer

If you need a persistent searchable log and want centralized access for support, store the email log to a custom DB table and create an admin page to browse entries. Use dbDelta for schema creation in activation hook, and sanitize/limit stored content to avoid storing large attachments inline.

Example: create a DB table and save basic logs on send

lt?php
/
Plugin Name: WP Mail Intercept - DB Logger
Description: Stores wp_mail() logs in a custom table and exposes a basic admin page.
Version: 1.0
Author: Example
/

register_activation_hook( __FILE__, wpmi_db_logger_activate )

function wpmi_db_logger_activate() {
    global wpdb
    table = wpdb-gtprefix . mail_logs
    charset_collate = wpdb-gtget_charset_collate()

    sql = CREATE TABLE table (
        id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
        sent_at datetime NOT NULL,
        to text NOT NULL,
        subject text NOT NULL,
        message longtext NOT NULL,
        headers longtext NOT NULL,
        attachments text NULL,
        PRIMARY KEY  (id)
    ) charset_collate

    require_once( ABSPATH . wp-admin/includes/upgrade.php )
    dbDelta( sql )
}

add_filter( wp_mail, wpmi_db_log_mail )

function wpmi_db_log_mail( atts ) {
    global wpdb
    table = wpdb-gtprefix . mail_logs
    wpdb-gtinsert(
        table,
        array(
            sent_at     =gt current_time( mysql ),
            to          =gt maybe_serialize( atts[to] ),
            subject     =gt atts[subject],
            message     =gt atts[message],
            headers     =gt maybe_serialize( atts[headers] ),
            attachments =gt maybe_serialize( atts[attachments] ),
        ),
        array( %s, %s, %s, %s, %s, %s )
    )

    return atts
}

// Example: add admin menu page to view logs (simplified)
add_action( admin_menu, wpmi_mail_logs_menu )
function wpmi_mail_logs_menu() {
    add_menu_page( Mail Logs, Mail Logs, manage_options, wpmi-mail-logs, wpmi_render_mail_logs )
}

function wpmi_render_mail_logs() {
    global wpdb
    table = wpdb-gtprefix . mail_logs
    rows = wpdb-gtget_results( SELECT  FROM table ORDER BY id DESC LIMIT 100 )

    echo ltdiv class=wrapgtlth1gtMail Logslt/h1gt
    echo lttable class=widefatgtlttheadgtlttrgtltthgtIDlt/thgtltthgtSentlt/thgtltthgtTolt/thgtltthgtSubjectlt/thgtlt/trgtlt/theadgtlttbodygt
    foreach ( rows as row ) {
        echo lttrgt
        echo lttdgt . esc_html( row-gtid ) . lt/tdgt
        echo lttdgt . esc_html( row-gtsent_at ) . lt/tdgt
        echo lttdgt . esc_html( maybe_unserialize( row-gtto ) ) . lt/tdgt
        echo lttdgt . esc_html( row-gtsubject ) . lt/tdgt
        echo lt/trgt
    }
    echo lt/tbodygtlt/tablegtlt/divgt
}

Notes on DB logging

  • Sanitize and possibly truncate large messages and attachments metadata to avoid huge DB rows.
  • Consider storing attachments as file references, not raw file contents.
  • Implement retention and rotation (e.g., delete logs older than X days) to avoid unbounded growth.

Best practices and operational considerations

  • Avoid logging sensitive info: Emails can contain personal data, tokens, or passwords. Mask or avoid storing sensitive fields for GDPR / privacy compliance.
  • Performance: Logging synchronously on every wp_mail call may slow page loads. Consider offloading to a background job (WP-Cron, Action Scheduler) or write to an append-only file quickly and process later.
  • File permissions: If you write files, ensure the destination directory is writable by the web server user and not web-accessible (store in wp-content/uploads with .htaccess to block listing or outside web root).
  • Rotation: Implement log rotation or retention rules. Old email logs can accumulate quickly.
  • Debug verbosity: Limit the amount of data you output into logs (truncate raw MIME or do sample captures) to avoid logs exhausting disk.
  • Compatibility: PHPMailer methods like getSentMIMEMessage() exist in certain versions. Use method_exists() guards as shown above.
  • Testing: Use a staging environment and real SMTP dev mailboxes (MailHog, Mailtrap, MailCatcher) for safe testing without reaching real recipients.

Testing and verifying your intercepts

  1. Install or activate your interceptor plugin in a staging site (do not activate on production unless intended).
  2. Send a test email from code, theme, or a plugin:
    // Simple test send
    wp_mail( recipient@example.test, Test subject, This is a test message. Sent at:  . current_time( mysql ) )
        
  3. Verify your debug log, DB table, or saved file contains the expected entry. If using SMTP debug capture, review the captured SMTP trace to diagnose auth/connection issues.
  4. If using pluggable override to suppress sending, confirm no emails leave the system (use a network inspector or MailHog).
  5. Test failure handling: configure wrong SMTP credentials (in a non-production environment) and confirm wp_mail_failed logs errors correctly.

Security, privacy and compliance checklist

  • Do not log full user passwords, API keys, or tokens.
  • Limit access to saved logs — restrict to administrators only and secure admin pages.
  • If storing personal data, document processing purposes and retention periods to comply with GDPR or other privacy regulations.
  • When using third-party services for logs (Sentry, external logging), ensure encrypted transport and appropriate access controls.

Troubleshooting common issues

  • No hook firing: Make sure the plugin is active. Plugins load before pluggable.php so plugin-defined wp_mail will replace core if you expect the default wp_mail hook and replaced it by accident, adjust your plugin.
  • PHPMailer methods not available: Guard with method_exists() because different WP/PHPMailer versions may vary.
  • Raw MIME missing attachments: If you capture MIME before attachments are processed, clone and preSend should include attachments. Verify cloning strategy and PHPMailer version.
  • Performance hit: If logging noticeably slows requests, write logs to an async queue (Action Scheduler) or a lightweight append-only file and process later.

Useful links

Final notes

By combining the wp_mail filter, wp_mail_failed action and phpmailer_init action you can capture all relevant information needed to debug email delivery in WordPress: the normalized parameters, PHPMailer internals, raw MIME, and SMTP-level traces. For non-invasive logging use the wp_mail filter for raw MIME and transport details use phpmailer_init and clone preSend. For development-only suppression, overriding the pluggable wp_mail function is pragmatic but must be used with extreme caution.



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *