Skip to content

Instantly share code, notes, and snippets.

@gboudreau
Last active March 23, 2024 17:50
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save gboudreau/0f4266db256ce8f4c94d9aba3f971876 to your computer and use it in GitHub Desktop.
Save gboudreau/0f4266db256ce8f4c94d9aba3f971876 to your computer and use it in GitHub Desktop.
Retry failed emails sent by Wordpress

Wordpress plugin to retry failed emails

When using Wordpress wp_mail() function, there is no easy way to schedule retries, when the email fails to be sent.
There is probably a few plugins available that could handle this, but I didn't find any that would just do that.
And so, I decided to code it myself.

Of note: I added this code to a custom (unpublished) plugin I maintain. If you don't have your own plugin to add this to, either Google how to create your own Wordpress plugin, or use one of the numerous available plugins that allows you to inject (PHP) code in your Wordpress installation. As a last resort, you could add this code in your functions.php

How to catch failed emails

Calling wp_mail() and checking its return value won't tell you when the email failed to be sent.
To do that, you need to implement the wp_mail_failed hook:

add_action('wp_mail_failed', 'wpMailFailed');
function wpMailFailed(WP_Error $wp_error) : void {
    // ...
}

I decided to spool failed emails into files, which could be processed later, when we want to retry.
So I created a new constant in wp-config.php:

define('MY_FAILED_EMAIL_STORAGE', __DIR__ . "/wp-content/cache/failed-emails");

Then I coded wpMailFailed() to log the error (in PHP error_log), save to file the necessary information to retry (later), and since I'd like to know ASAP when such errors occur, I poked HealthChecks.io (which in turn triggers a notification):

function wpMailFailed(WP_Error $wp_error) : void {
    foreach ($wp_error->errors as $code => $messages) {
        $data = (object) $wp_error->error_data[$code];

        // Create spool folder, if it doesn't exist
        if (!file_exists(MY_FAILED_EMAIL_STORAGE)) {
            mkdir(MY_FAILED_EMAIL_STORAGE, 0700, TRUE);
        }

        // Save email data to file
        $target_filename = MY_FAILED_EMAIL_STORAGE . '/' . date('Y-m-d H:i:s') . '_' . first($data->to) . '.json';
        @file_put_contents($target_filename, json_encode($data));

        // Log error
        $logged_data = ['to' => $data->to, 'subject' => $data->subject];
        error_log("[" . date('Y-m-d H:i:s') . "] Error sending email: Errors: " . json_encode($wp_error->errors) . "; Data: " . json_encode($logged_data) . "; Saved in $target_filename");

        // Send failure notification to https://healthchecks.io
        $hc_data = "Error: " . json_encode($wp_error->errors) . "\n";
        $hc_data .= "To: " . implode(', ', $data->to) . "\n";
        $hc_data .= "Subject: $data->subject\n";
        sendPOST('https://hc-ping.com/uuid_here/2', $hc_data, [], 'text/plain');
    }
}

Of note: sendPOST is a function that uses ext-curl to send an HTTP POST request. Google that, if you also want to poke healthchecks.io

How to schedule retries

I used a Wordpress schedule, which will repeat hourly, to check for emails that needs to be retried:

function wpMailFailed(WP_Error $wp_error) : void {
    foreach ($wp_error->errors as $code => $messages) {
        // ...
    }

    // If not yet scheduled, define an hourly retry
    if (!wp_next_scheduled('my_retry_failed_emails')) {
        $when = strtotime('+60 minutes');
        error_log("Next wp-cron for 'my_retry_failed_emails' (RetryFailedEmails) scheduled for " . date('Y-m-d H:i', $when));
        wp_schedule_event($when, 'hourly', 'my_retry_failed_emails');
    }
}

add_action('my_retry_failed_emails', 'retryFailedEmails');
function retryFailedEmails() : void {
    // ...
}

Re-try wp_mail()

Here's what I coded in the retryFailedEmails() function:

  • Look for *.json files in MY_FAILED_EMAIL_STORAGE
  • For each of those, per the timestamp found in the filename, decide if it is time to retry, or if I want to wait until later.
  • When I want to rettry, I will read the file, convert its content to call wp_mail with the correct information, and delete the JSON file. (A new file will be created, if the email still fails.)

Had I wanted to code something fancy, like increasing wait-time between each try, I would have added some values in the object I saved in the JSON file, and I could use that to count retries, and calculate when I want to retry.
But I just coded a retry hourly, because I didn't need anything fancy.

Here's the function I coded:

public function retryFailedEmails() : void {
    error_log("[RetryFailedEmails] Looking for unsent emails to retry...");
    foreach (glob(MY_FAILED_EMAIL_STORAGE . '/*.json') as $email_data_file) {
        $filename = basename($email_data_file);
        $date = substr($filename, 0, 19);
        if (strtotime($date) > strtotime('-60 minute')) {
            error_log("[RetryFailedEmails] - Skipping $filename, too recent.");
            continue;
        }
        error_log("[RetryFailedEmails] - $filename failed on $date. Retrying...");
        $data = json_decode(file_get_contents($email_data_file));

        // Here, I'll do something regarding headers. Read further below for details.

        $logged_data = (object) (array) $data;
        $logged_data->message = '...';
        error_log("[RetryFailedEmails]   Data: " . json_encode($logged_data));

        unlink($email_data_file);
        wp_mail($data->to, $data->subject, $data->message, $data->headers, $data->attachments ?? []);
    }
    error_log("[RetryFailedEmails] Done.");
}

Handle missing (mail) headers

At this point, I noticed that the headers sent to the wp_mail_failed were missing some values.
Looking at the Wordpress code for the wp_mail function, I noticed that it removes some values from the $headers variable, things like Content-Type, Mail-From, etc. And since $headers it what is sent to wp_mail_failed on failure, we end up with missing headers.

I worked around this problem by adding another hook, on wp_mail this time, that would duplicate all received headers into values that will not be removed by wp_mail(), and thus would be received by wp_mail_failed:

add_action('wp_mail', 'myWPMail');
    public function myWPMail(array $atts) : array {
        // wp_mail() will transform some headers it receives into params (eg. Content-Type, Reply-To, etc.), and will NOT include those headers in the $mail_data['headers'] array sent to the wp_mail_failed action (wpMailFailed function above)
        // So in order to not lose those headers, we'll copy them into custom headers:
        if (!empty($atts['headers'])) {
            if (!is_array($atts['headers'])) {
                $atts['headers'] = explode("\n", str_replace("\r\n", "\n", $atts['headers']));
            }
            foreach ($atts['headers'] as $header) {
                $atts['headers'][] = "My-$header";
            }
        }
        return $atts;
    }

Finally, we just need to add code in retryFailedEmails() to convert those headers into their original values:

public function retryFailedEmails() : void {
    error_log("[RetryFailedEmails] Looking for unsent emails to retry...");
    foreach (glob(MY_FAILED_EMAIL_STORAGE . '/*.json') as $email_data_file) {
        $filename = basename($email_data_file);
        $date = substr($filename, 0, 19);
        if (strtotime($date) > strtotime('-60 minute')) {
            error_log("[RetryFailedEmails] - Skipping $filename, too recent.");
            continue;
        }
        error_log("[RetryFailedEmails] - $filename failed on $date. Retrying...");
        $data = json_decode(file_get_contents($email_data_file));

        // Inject missing headers (see myWPMail function above for details)
        $new_headers = [];
        foreach ($data->headers as $name => $value) {
            if (string_begins_with($name, 'My-')) {
                $name = substr($name, 3);
            }
            $new_headers[] = "$name: $value";
        }
        $data->headers = $new_headers;

        $logged_data = (object) (array) $data;
        $logged_data->message = '...';
        error_log("[RetryFailedEmails]   Data: " . json_encode($logged_data));

        unlink($email_data_file);
        wp_mail($data->to, $data->subject, $data->message, $data->headers, $data->attachments ?? []);
    }
    error_log("[RetryFailedEmails] Done.");
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment