Deferring emails with Action Scheduler (part 2)

In Part 1 of this tutorial, we crafted a short piece of code (just 14 lines, depending on how one counts) that takes advantage of Action Scheduler to capture emails and send them out at some later time. Again, this same technique could be applied to various other tasks—but emails happen to provide us with a useful and familiar example.

Though our initial solution works, we also identified a key shortcoming—which is that it won't work with complex (or, to be a little more precise, very long) emails. That's because we are asking Action Scheduler to store all the information that makes up the email, including:

  • The destination email address.
  • The sender email address.
  • The subject.
  • The actual email content (which is potentially going to be pretty long).
  • Additional email headers.

When Action Scheduler stores information like this, it JSON encodes it and stashes it in the database ... but, at time of writing, it will reject anything that is over 8,000 characters in length (once JSON encoded).

Specifically, in this scenario, the call to as_enqueue_async_action() will lead to a runtime exception being thrown, informing you that the provided args exceeded this limit.

Where can we store this information?

So, we need to store our email information somewhere else. There are many ways we might handle this, but a traditionally WordPressy approach that doesn't require us to establish any new database tables would be to use a custom post type. Let's work up a fairly simple means of doing this, which we can add to our async-emails.php mu-plugin file.

class My_Stored_Emails {
	private const POST_TYPE = 'stored_email';

	public static function register_post_type(): void {
		register_post_type( self::POST_TYPE, [
			'public' => false,
		] );
	}

	public static function save( string $to, string $subject, string $message, string $headers = '', $attachments = [] ): int|false {
		$payload = wp_json_encode( [
			'to'          => $to,
			'subject'     => $subject,
			'message'     => $message,
			'headers'     => $headers,
			'attachments' => $attachments,
		] );

		$inserted_post_id = wp_insert_post( [
			'post_type'      => self::POST_TYPE,
			'post_content'   => base64_encode( $payload ),
			'post_mime_type' => 'application/data',
		] );

		return is_int( $inserted_post_id ) && $inserted_post_id > 0 ? $inserted_post_id : false;
	}

	public static function get( int $id ): array|false {
		$retrieved_email = get_posts( [
			'post__in'    => [ $id ],
			'post_status' => 'any',
			'post_type'   => self::POST_TYPE
		] );

		if ( ! is_array( $retrieved_email ) || count( $retrieved_email ) !== 1 ) {
			return false;
		}

		$email_array = json_decode( base64_decode( $retrieved_email[0]->post_content ), true );
		return is_array( $email_array ) ? $email_array : false;
	}
}

add_action( 'init', [ My_Stored_Emails::class, 'register_post_type' ] );

Let's review the above:

  • We are registering a custom post type, which will be private (not exposed to visitors).
  • It exposes a save() method (stores details of an email in a new post of this type, and returns the ID).
  • It also exposes a get( $id ) method (retrieves email details, given a valid ID).

To take advantage of this, we will also need to update our existing My_Async_Emails class, defined in Part 1:

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 ) {
			$stored_email_id = My_Stored_Emails::save( ...$attributes );

			if ( ! $stored_email_id ) {
				return false;
			}

			return (bool) as_enqueue_async_action( 'my_async_emails_send_email', [ 'id' => $stored_email_id ], 'deferred-emails' );
		}

		return $null;
	}

	public static function send_emails( int $id ): void {
		self::$within_scheduled_action = true;

		$email_array = My_Stored_Emails::get( $id );

		if ( $email_array ) {
			wp_mail( ...$email_array );
		}

		self::$within_scheduled_action = false;
	}
}

Summing up

If you've pieced everything together correctly, you should now have a working means for moving the dispatch of emails into an async process. We basically had this already at the end of Part 1, but now we can be confident that it will work even with very large emails.

That leaves one final problem to solve, which is controlling how frequently the emails are actually sent. At this stage, emails will potentially still be sent out pretty rapidly—one after another—as soon as the queue is processed. This might not be ideal and, to avoid various types of rate limiting, you may prefer to ensure there is at least a 10 second delay (or some other interval) between dispatches.

Originally I thought of covering that here, but I think it may be better to wait until Part 3. Thank you for following!

PS ... the code snippets I've shared so far are deliberately simple. They are uncommented, and miss various checks I might normally add. This is all done in the interests of keeping things simple and focusing on the underlying ideas. It probably goes without saying, but don't hesitate to tweak and add whatever you need to bring them up to your own standards and make them easier for you to work with in the future.

 

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...