Deferring emails with Action Scheduler (Part 1)

Action Scheduler is a great library for taking non-essential tasks, moving them out of the current request, and placing them in a job queue. There are various reasons for wanting to do this:

  • If we can defer non-essential work until later (particularly work that comes with a large time cost), we can potentially make our handling of customer or user-initiated web requests faster and more reliable.
  • Moving some types of work to a job queue gives us back control over when or how quickly they are processed. This can be important whenever we're potentially at the mercy of rate-limiting.

In this tutorial, I'm going to explore the creation of a lightweight system that uses Action Scheduler to handle the dispatch of emails. Of course, emails are just a handy example: you can adapt this to handle pretty much any task you like.

If you're going to follow along and try using some of the code in the tutorial, please do so from within a test environment. By the time we complete the first part of the tutorial, you should end up with some working code ... but it will not be production-ready (keep following along, though, as I share more posts in the series, and we'll move toward that goal).

Our scenario

So let's suppose we've created an ecommerce site using WordPress and a plugin like WooCommerce. When a customer places an order, at least two emails are likely to be sent:

  1. One to thank the customer and summarize their order.
  2. One to alert the merchant to the new order.

There could of course be many more, depending on how complex the project is. In the classical WordPress/PHP model, those emails will be dispatched synchronously (one after the other), and so the time cost literally adds up—especially if we have a dicky SMTP server on our hands. Our initial goal, then, is to change this so that any time an email needs to be sent, it is moved to (and ultimately processed via) the 'job queue'.

Get tooled up

There are many ways to skin a cat, as the rather awful saying goes, and, especially if you are already experienced in WordPress development, you may wish to do this exercise a little differently from the way I'm going to suggest here—and that is of course perfectly fine—feel free to take the ideas presented here and make them work for you. Caveat done, here's what I suggest:

  • Start by downloading, installing and activating Action Scheduler. Since it's listed in the official plugin directory, you can do this directly from within the WordPress admin environment.
  • Create a new file called async-emails.php and place it in the wp-content/mu-plugins directory (you may need to create this if it does not already exist).

What does this give us? It gives us a home for the code we're about to add, and it means Action Scheduler is ready and available for us to use.

Instead of the approach outlined above, most plugins intended for public distribution tend to use Composer to add Action Scheduler as a dependency, rather that installing it as a standalone plugin.

Now, there may be occasions and situations where emails really do have to be sent immediately. I'd contend these are pretty rare—usually it can wait a few seconds, and very often a few minutes—but, of course, accommodating such special cases is entirely possible. For simplicity, though, I'll ignore that sort of thing here—because what I really want to demonstrate is the mechanics of using this library to streamline our handling of customer (or user)-initiated web requests, by moving these tasks to a work queue.

Safety first

So we have a home for our code and we know Action Scheduler is running and available. Or do we?

Theoretically, someone could accidentally deactivate it and then there is a risk of site-stopping fatal errors. There are a few ways to combat this, including function_exists() checks, but the approach I will use here is to hook into an event that is only fired when Action Scheduler has completed its initialization routine, something like this:

add_action( 'action_scheduler_init', 'do_action_scheduler_stuff' );

In my callback, I'll support this with a version check, just so I can be extra sure that I have safe access to all the latest Action Scheduler functionality:

if ( version_compare( ActionScheduler_Versions::instance()->latest_version(), '3.7.1', '<' ) ) {
	return;
}

So far, so good. Let's put all of this together into a class inside our async-emails.php file, as follows:

<?php

class My_Async_Emails {
	public static function setup(): void {
		add_action( 'action_scheduler_init', [ __CLASS__, 'register_async_tasks' ] );
	}

	public static function register_async_tasks(): void {
		if (version_compare(ActionScheduler_Versions::instance()->latest_version(), '3.7.1', '<')) {
			return;
		}
	}
}

My_Async_Emails::setup();

With the above in place, try accessing your test site—nothing should break with this in place. If something does break, though, just remove the above code, breathe, and see if you can figure things out (see an error message? Take the time to read it ... they aren't as cryptic as they may initially look).

Capture the email 🚩

One of the reasons I love building on top of WordPress is just how ridiculously easy it makes tasks like capturing an email to send later. So long as other plugins are 'playing by the rules', and are using the WordPress Core API to send emails, this presents very few difficulties.

  • We are going to take advantage of the pre_wp_mail hook to capture the details of the email, and stop WordPress from actually sending it.
  • We will then use Action Scheduler to send the email later (later in this case meaning as soon as possible, just not in the current request).
  • I'm going to introduce a deliberate error here, so don't try using this code just yet!
<?php

/**
 * Remember! There is a deliberate error in this version of the code.
 */
class My_Async_Emails {
	public static function setup(): void {
		add_action( 'action_scheduler_init', [ __CLASS__, 'register_async_tasks' ] );
	}

	public static function register_async_tasks(): void {
		if (version_compare(ActionScheduler_Versions::instance()->latest_version(), '3.7.1', '<')) {
			return;
		}

		add_filter( 'pre_wp_mail', [ __CLASS__, 'defer_emails' ], 10, 2 );
		add_action( 'my_async_emails_send_email', [ __CLASS__, 'send_emails' ], 10, 5 );
	}

	public static function defer_emails( $null, array $attributes ): mixed {
		return (bool) as_enqueue_async_action( 'my_async_emails_send_email', $attributes, 'deferred-emails' );
	}

	public static function send_emails( string $to, string $subject, string $message, string $headers = '', $attachments = [] ): void {
		wp_mail( $to, $subject, $message, $headers, $attachments );
	}
}

My_Async_Emails::setup();

We're intercepting the dispatch of emails using the pre_wp_mail hook, queuing up a job containing details of the job, and then in the send_emails() method (which is the bit that executes within the context of the job queue) we send the email. 

Spotted the error? We are ourselves using WordPress to send the email, so our own code is just going to capture that a second time and create a sort of gigantic loop. Again, there are various ways to handle this—the strategy I will use here is simply setting (and checking for) a flag that indicates we should temporarily stop capturing new emails.

Let's take it for a spin

Here's the final revision of the code (at least ... final for now), complete with safety flags.

<?php

class My_Async_Emails {
	private static bool $within_scheduled_action = false;

	public static function setup(): void {
		add_action( 'action_scheduler_init', [ __CLASS__, 'register_async_tasks' ] );
	}

	public static function register_async_tasks(): void {
		if (version_compare(ActionScheduler_Versions::instance()->latest_version(), '3.7.1', '<')) {
			return;
		}

		add_filter( 'pre_wp_mail', [ __CLASS__, 'defer_emails' ], 10, 2 );
		add_action( 'my_async_emails_send_email', [ __CLASS__, 'send_emails' ], 10, 5 );
	}

	public static function defer_emails( $null, array $attributes ): mixed {
		if ( false === self::$within_scheduled_action ) {
			return (bool) as_enqueue_async_action( 'my_async_emails_send_email', $attributes, 'deferred-emails' );
		}

		return $null;
	}

	public static function send_emails( string $to, string $subject, string $message, string $headers = '', $attachments = [] ): void {
		self::$within_scheduled_action = true;
		wp_mail( $to, $subject, $message, $headers, $attachments );
		self::$within_scheduled_action = false;
	}
}

My_Async_Emails::setup();

As you can see, before we actually send the email for real we set a flag called within_scheduled_action (which we later clear). The defer_emails() method looks for this flag and, if it is set, it won't interfere with anything.

To test this out, let's install and activate a neat little plugin called WP Test Email. Navigate to Tools ‣ Test Email and supply an email address (make something up, if you like) then hit Save changes. Next, visit Tools ‣ Scheduled Actions and search for my_async_emails_send_email ... you should see something like this:

Shows the entry for the deferred email task, within the list of scheduled actions.

As you can see in the screenshot, the status is still pending. Eventually (or if you run it manually), that status should change to complete. However, there are other possibilities such as failure (if that happens, additional information about the failure will usually be supplied via this same view).

Summing up

If you've followed along so far, you'll basically have a functioning Action Scheduler-based mechanism for moving emails out of regular web requests and into a dedicated job queue. Huzzah. This is just part one of the tutorial, though, and more work is needed:

  • Hopefully, our little test email worked for you. It was, however, a very short and simple email and, actually, Action Scheduler might choke on something bigger (like an HTML-rich marketing email, or complex order summary). In the next part of the tutorial, we'll discuss why that happens and how we can work around it.
  • Thinking back to the start of this post, I noted that we might use scheduled actions to avoid rate limiting (in this case, that might mean avoiding the risk of sending too many emails within a short duration, and thereby triggering anti-spam measures). Though we've successfully moved the work of sending emails out of our customer or user-facing web requests, we have not yet done much to space-out their dispatch.

I'll cover both those items in our next instalment. Thank you for following along!

You may also like:

Going native with WooCommerce sessions

WooCommerce comes complete with its own database-backed session handler. Information about the shopper, the contents of their cart, any flash...