Halloween Sale 15% off Ends in See the plans
PHP

Full Email Log With Search, Viewer and Resend

Records every outgoing email in its own database table and adds Tools > Mail Log, where you can search, read the full message, resend it, and clear old entries automatically.

Don’t have WPIDE yet? Install it on your site

Code

$wpide_mlog_days  = 30; // Days to keep entries.
$wpide_mlog_state = new stdClass();
$wpide_mlog_state->id = 0;

add_action( 'admin_init', function () {
    global $wpdb;

    if ( '1' === get_option( 'wpide_mail_log_db' ) || ! current_user_can( 'manage_options' ) ) {
        return;
    }

    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
    dbDelta( "CREATE TABLE {$wpdb->prefix}wpide_mail_log (
        id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
        sent_at datetime NOT NULL,
        to_addr text NOT NULL,
        subject text NOT NULL,
        headers text NOT NULL,
        message longtext NOT NULL,
        status varchar(20) NOT NULL DEFAULT 'queued',
        error text NOT NULL,
        PRIMARY KEY  (id),
        KEY sent_at (sent_at)
    ) {$wpdb->get_charset_collate()};" );

    update_option( 'wpide_mail_log_db', '1', true );
} );

add_filter( 'wp_mail', function ( $atts ) use ( $wpide_mlog_state ) {
    global $wpdb;

    if ( '1' !== get_option( 'wpide_mail_log_db' ) ) {
        return $atts;
    }

    $wpdb->insert( $wpdb->prefix . 'wpide_mail_log', array(
        'sent_at' => current_time( 'mysql', true ),
        'to_addr' => implode( ', ', (array) $atts['to'] ),
        'subject' => (string) $atts['subject'],
        'headers' => is_array( $atts['headers'] ) ? implode( "\n", $atts['headers'] ) : (string) $atts['headers'],
        'message' => (string) $atts['message'],
        'status'  => 'queued',
        'error'   => '',
    ) );
    $wpide_mlog_state->id = (int) $wpdb->insert_id;

    return $atts;
}, PHP_INT_MAX );

add_action( 'wp_mail_succeeded', function () use ( $wpide_mlog_state ) {
    global $wpdb;
    if ( $wpide_mlog_state->id ) {
        $wpdb->update( $wpdb->prefix . 'wpide_mail_log', array( 'status' => 'sent' ), array( 'id' => $wpide_mlog_state->id ) );
        $wpide_mlog_state->id = 0;
    }
} );

add_action( 'wp_mail_failed', function ( $error ) use ( $wpide_mlog_state ) {
    global $wpdb;
    if ( $wpide_mlog_state->id ) {
        $wpdb->update( $wpdb->prefix . 'wpide_mail_log', array( 'status' => 'failed', 'error' => $error->get_error_message() ), array( 'id' => $wpide_mlog_state->id ) );
        $wpide_mlog_state->id = 0;
    }
} );

add_action( 'init', function () {
    if ( ! wp_next_scheduled( 'wpide_mail_log_purge' ) ) {
        wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'wpide_mail_log_purge' );
    }
} );

add_action( 'wpide_mail_log_purge', function () use ( $wpide_mlog_days ) {
    global $wpdb;
    if ( '1' === get_option( 'wpide_mail_log_db' ) ) {
        $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}wpide_mail_log WHERE sent_at < %s", gmdate( 'Y-m-d H:i:s', time() - $wpide_mlog_days * DAY_IN_SECONDS ) ) );
    }
} );

add_action( 'admin_menu', function () {
    add_management_page( 'Mail Log', 'Mail Log', 'manage_options', 'wpide-mail-log', function () {
        global $wpdb;
        $table = $wpdb->prefix . 'wpide_mail_log';
        $base  = admin_url( 'tools.php?page=wpide-mail-log' );

        echo '<div class="wrap"><h1>Mail Log</h1>';

        if ( '1' !== get_option( 'wpide_mail_log_db' ) ) {
            echo '<p>The log table is being created. Reload this page.</p></div>';
            return;
        }

        // Actions.
        if ( isset( $_GET['resend'] ) && check_admin_referer( 'wpide_mlog_resend' ) ) {
            $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", absint( $_GET['resend'] ) ) );
            if ( $row ) {
                $ok = wp_mail( array_map( 'trim', explode( ',', $row->to_addr ) ), $row->subject, $row->message, array_filter( explode( "\n", $row->headers ) ) );
                printf( '<div class="notice notice-%s"><p>%s</p></div>', $ok ? 'success' : 'error', $ok ? 'Email resent.' : 'The email could not be resent.' );
            }
        }
        if ( isset( $_POST['wpide_mlog_clear'] ) && check_admin_referer( 'wpide_mlog_clear' ) ) {
            $wpdb->query( "DELETE FROM {$table}" ); // phpcs:ignore
            echo '<div class="notice notice-success"><p>Log cleared.</p></div>';
        }

        // Single message view.
        if ( isset( $_GET['view'] ) ) {
            $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", absint( $_GET['view'] ) ) );
            if ( $row ) {
                printf( '<p><a href="%s">← Back to the log</a></p>', esc_url( $base ) );
                printf( '<table class="widefat striped"><tbody><tr><th>To</th><td>%s</td></tr><tr><th>Subject</th><td>%s</td></tr><tr><th>Date (UTC)</th><td>%s</td></tr><tr><th>Status</th><td>%s %s</td></tr><tr><th>Headers</th><td><pre style="white-space:pre-wrap">%s</pre></td></tr></tbody></table>', esc_html( $row->to_addr ), esc_html( $row->subject ), esc_html( $row->sent_at ), esc_html( $row->status ), esc_html( $row->error ), esc_html( $row->headers ) );
                echo '<h2>Message</h2><iframe sandbox="" style="width:100%;min-height:420px;background:#fff;border:1px solid #c3c4c7" srcdoc="' . esc_attr( false !== stripos( $row->message, '<html' ) || false !== stripos( $row->headers, 'text/html' ) ? $row->message : '<pre style="white-space:pre-wrap;font:14px/1.5 sans-serif">' . esc_html( $row->message ) . '</pre>' ) . '"></iframe>';
                printf( '<p><a class="button" href="%s">Resend this email</a></p></div>', esc_url( wp_nonce_url( add_query_arg( 'resend', (int) $row->id, $base ), 'wpide_mlog_resend' ) ) );
                return;
            }
        }

        $search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : '';
        $paged  = max( 1, isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1 );
        $where  = $search ? $wpdb->prepare( 'WHERE to_addr LIKE %s OR subject LIKE %s', '%' . $wpdb->esc_like( $search ) . '%', '%' . $wpdb->esc_like( $search ) . '%' ) : '';
        $rows   = $wpdb->get_results( "SELECT id, sent_at, to_addr, subject, status FROM {$table} {$where} ORDER BY id DESC LIMIT 50 OFFSET " . ( ( $paged - 1 ) * 50 ) ); // phpcs:ignore

        printf( '<form method="get"><input type="hidden" name="page" value="wpide-mail-log"><p class="search-box"><input type="search" name="s" value="%s" placeholder="Recipient or subject"> <button class="button">Search</button></p></form>', esc_attr( $search ) );
        echo '<table class="widefat striped"><thead><tr><th>Date (UTC)</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr></thead><tbody>';
        if ( ! $rows ) {
            echo '<tr><td colspan="5">No emails logged yet.</td></tr>';
        }
        foreach ( $rows as $row ) {
            printf( '<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td><a href="%s">View</a></td></tr>', esc_html( $row->sent_at ), esc_html( $row->to_addr ), esc_html( $row->subject ), esc_html( $row->status ), esc_url( add_query_arg( 'view', (int) $row->id, $base ) ) );
        }
        echo '</tbody></table>';
        if ( 50 === count( $rows ) ) {
            printf( '<p><a class="button" href="%s">Older emails →</a></p>', esc_url( add_query_arg( array( 'paged' => $paged + 1, 's' => $search ), $base ) ) );
        }
        echo '<form method="post" style="margin-top:1em">';
        wp_nonce_field( 'wpide_mlog_clear' );
        echo '<button class="button button-link-delete" name="wpide_mlog_clear" value="1" onclick="return confirm(\'Delete all log entries?\')">Clear the log</button></form></div>';
    } );
} );

Notes

Creates the {prefix}wpide_mail_log table the first time an administrator opens the admin and purges old entries daily; message bodies can hold personal data, so keep the retention short.

How to add this snippet

  1. Install WPIDE on your WordPress site (it is free) and open Code Snippets.
  2. Use snippet above adds it from the library in one click. Or copy the code into a new snippet with the same code type.
  3. It is added inactive: review the code and settings, then switch it on.

Have a snippet worth sharing?

Publish it to the library from WPIDE or right here. Sign in with any WordPress site that runs WPIDE, free or Pro. Public snippets are reviewed by the WPIDE team before they appear.

Which site should get this snippet?

Enter the address of your WordPress site. WPIDE opens there in a new tab with the snippet ready to review and save.

Remembered in this browser only, so you are asked once.

Report this snippet

Tell the WPIDE team what is wrong: broken code, something harmful, spam or copied work.