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
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
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 {
// ...
}
Here's what I coded in the retryFailedEmails()
function:
- Look for
*.json
files inMY_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.");
}
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.");
}