Action Scheduler Deferred Emails Snippet

This code can be used as a standalone plugin, or mu-plugin file. It's an accompaniment to a set of tutorials about using Action Scheduler to defer the dispatch of emails (the first part of that tutorial can be found here).

<?php
/**
 * Plugin name: Async Emails with Action Scheduler.
 * Description: Uses Action Scheduler to send emails asynchronously, with a minimum 10 second gap between dispatches.
 * Plugin URI:  https://codingkills.me/action-scheduler-deferred-emails-snippet
 * Author URI:  https://codingkills.me
 * Version:     2024.02.01
 * License:     GPL-3.0
 *
 * This plugin/snippet can live as a top-level plugin, or a mu-plugin. It is an
 * accompaniment to this 3-part tutorial, written for Action Scheduler 3.7.1:
 *
 *     1. https://codingkills.me/deferring-emails-with-action-scheduler-1
 *     2. https://codingkills.me/deferring-emails-with-action-scheduler-2
 *     3. https://codingkills.me/deferring-emails-with-action-scheduler-3
 *
 * Take it, test it, change it, and improve it! It is a sparsely commented
 * starting point, not a highly polished piece of production-ready code. Pay
 * attention to the `todo` items sprinkled throughout :-)
 */

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 ) {
			// @todo $attributes is a filtered value: consider verifying it has the expected shape.
			if ( ! My_Stored_Emails::save( ...$attributes ) ) {
				return false;
			}

			return (bool) as_schedule_recurring_action(
				timestamp: time(),
				interval_in_seconds: 10,
				hook: 'my_async_emails_send_email',
				group: 'deferred-emails',
				unique: true,
			);
		}

		return $null;
	}

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

		$email_id = My_Stored_Emails::get_next_id();

		if ( ! $email_id ) {
			self::unhook_self();
			return;
		}

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

		if ( $email_array ) {
			// @todo consider checking if wp_mail() succeeds: should we log failures?
			wp_mail( ...$email_array );
		}

		My_Stored_Emails::delete( $email_id );
		self::$within_scheduled_action = false;
	}

	private static function unhook_self(): void {
		add_action( 'action_scheduler_after_process_queue', function () {
			as_unschedule_all_actions(
				hook: 'my_async_emails_send_email',
				group: 'deferred-emails',
			);
		} );
	}
}

class My_Stored_Emails {
	private const POST_TYPE = 'My_Stored_Emails';

	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_next_id(): int|false {
		$email_posts = get_posts( [
			'fields'         => 'ids',
			'order'          => 'ASC',
			'orderby'        => 'date',
			'post_status'    => 'any',
			'post_type'      => self::POST_TYPE,
			'posts_per_page' => 1
		] );

		return is_array( $email_posts ) && count( $email_posts ) === 1 ? (int) current( $email_posts ) : 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;
	}

	public static function delete( int $email_id ): void {
		// @todo instead of deleting, we could consider making better use of post statuses.
		wp_delete_post( $email_id, true );
	}
}

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

You may also like:

Custom (PHP) snippets in WordPress

Often you will see tutorials that describe how to customize WordPress and, frequently, they will suggest you do this by...