Commit f8462fdb11f for woocommerce
commit f8462fdb11ff88e9be5ef2f6f5d6e77959b79091
Author: Mike Jolley <mike.jolley@me.com>
Date: Tue Mar 31 16:00:38 2026 +0100
Emails: Migrate deferred email sending to Action Scheduler (#63832)
* Migrate deferred email sending from WC_Background_Emailer to Action Scheduler
Replace the legacy WC_Background_Emailer (WP_Background_Process) with a new
DeferredEmailQueue class that batches emails and dispatches via Action Scheduler.
Add a feature toggle under Settings > Advanced > Features.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add changefile(s) from automation for the following project(s): woocommerce
* Remove duplicate changelog entry
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Remove unnecessary final modifier on init method
The class is already final, so the method-level final is redundant.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Update deprecation notice to not reference internal class
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Isolate email send failures to prevent batch abortion
Wrap each email callback in try/catch so a single failure doesn't
prevent remaining emails in the batch from being sent.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add test for exception isolation in email batch processing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Restore final on init() with phpcs ignore for generic sniff
WooCommerce sniff requires DI init methods to be final, but the
generic sniff flags it as redundant on a final class. Suppress the
generic rule.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Address PR feedback: chunk dispatch and remove skip_compatibility_checks
- Split large email queues into configurable chunks (default 10) via
woocommerce_deferred_email_chunk_size filter to avoid overloading
single AS jobs during bulk operations
- Remove skip_compatibility_checks from feature toggle per review
feedback — default compatibility is already "compatible"
- Add test for chunk splitting behavior
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Clean up code quality issues from review
- Use $args instead of func_get_args() in queue_transactional_email()
for consistency with the variadic signature
- Reset $shutdown_registered in dispatch() so the class works
correctly in long-lived processes (CLI, test suites)
- Add comment explaining why the AS hook is registered unconditionally
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix equals sign alignment in DeferredEmailQueue
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Switch deferred emails from batched to per-email AS actions
Each email is now scheduled as an individual Action Scheduler action
for atomic processing. This simplifies the code (no chunking, batch
validation, or manual exception handling) and leverages AS built-in
retry for failure isolation.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/63832-wooplug-1935-the-wp_background_process-class-do-we-still-need-it b/plugins/woocommerce/changelog/63832-wooplug-1935-the-wp_background_process-class-do-we-still-need-it
new file mode 100644
index 00000000000..72166551ea4
--- /dev/null
+++ b/plugins/woocommerce/changelog/63832-wooplug-1935-the-wp_background_process-class-do-we-still-need-it
@@ -0,0 +1,4 @@
+Significance: patch
+Type: update
+
+Migrate deferred transactional email sending to Action Scheduler
\ No newline at end of file
diff --git a/plugins/woocommerce/includes/class-wc-background-emailer.php b/plugins/woocommerce/includes/class-wc-background-emailer.php
index 5c9d751e07f..98aeb4e67bf 100644
--- a/plugins/woocommerce/includes/class-wc-background-emailer.php
+++ b/plugins/woocommerce/includes/class-wc-background-emailer.php
@@ -16,6 +16,8 @@ if ( ! class_exists( 'WC_Background_Process', false ) ) {
/**
* WC_Background_Emailer Class.
+ *
+ * @deprecated 10.8.0 Deferred emails now use Action Scheduler. No stable public replacement exists.
*/
class WC_Background_Emailer extends WC_Background_Process {
diff --git a/plugins/woocommerce/includes/class-wc-emails.php b/plugins/woocommerce/includes/class-wc-emails.php
index 75dafef0813..48f173482cd 100644
--- a/plugins/woocommerce/includes/class-wc-emails.php
+++ b/plugins/woocommerce/includes/class-wc-emails.php
@@ -11,6 +11,7 @@
declare( strict_types = 1 );
use Automattic\Jetpack\Constants;
+use Automattic\WooCommerce\Internal\Email\DeferredEmailQueue;
use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields;
use Automattic\WooCommerce\Enums\ProductType;
@@ -39,11 +40,12 @@ class WC_Emails {
protected static $instance = null;
/**
- * Background emailer class.
+ * Deferred email queue instance.
*
- * @var WC_Background_Emailer
+ * @since 10.8.0
+ * @var DeferredEmailQueue|null
*/
- protected static $background_emailer = null;
+ protected static $deferred_queue = null;
/**
* Main WC_Emails Instance.
@@ -123,14 +125,16 @@ class WC_Emails {
)
);
+ $defer_default = FeaturesUtil::feature_is_enabled( 'deferred_transactional_emails' );
+
/**
* Filter whether to defer transactional emails.
*
* @since 3.0.0
* @param bool $defer Whether to defer transactional emails.
*/
- if ( apply_filters( 'woocommerce_defer_transactional_emails', false ) ) {
- self::$background_emailer = new WC_Background_Emailer();
+ if ( apply_filters( 'woocommerce_defer_transactional_emails', $defer_default ) ) {
+ self::$deferred_queue = wc_get_container()->get( DeferredEmailQueue::class );
foreach ( $email_actions as $action ) {
add_action( $action, array( __CLASS__, 'queue_transactional_email' ), 10, 10 );
@@ -150,13 +154,8 @@ class WC_Emails {
* @return void
*/
public static function queue_transactional_email( ...$args ) {
- if ( is_a( self::$background_emailer, 'WC_Background_Emailer' ) ) {
- self::$background_emailer->push_to_queue(
- array(
- 'filter' => current_filter(),
- 'args' => func_get_args(),
- )
- );
+ if ( self::$deferred_queue instanceof DeferredEmailQueue ) {
+ self::$deferred_queue->push( current_filter(), $args );
} else {
self::send_transactional_email( ...$args );
}
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index ed5057f8966..151caa536bd 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -29,6 +29,7 @@ use Automattic\WooCommerce\Internal\Settings\OptionSanitizer;
use Automattic\WooCommerce\Internal\Utilities\LegacyRestApiStub;
use Automattic\WooCommerce\Internal\Utilities\WebhookUtil;
use Automattic\WooCommerce\Internal\Admin\EmailImprovements\EmailImprovements;
+use Automattic\WooCommerce\Internal\Email\DeferredEmailQueue;
use Automattic\WooCommerce\Internal\Admin\Marketplace;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\{LoggingUtil, TimeUtil};
@@ -368,6 +369,7 @@ final class WooCommerce {
$container->get( ComingSoonRequestHandler::class );
$container->get( OrderCountCacheService::class );
$container->get( EmailImprovements::class );
+ $container->get( DeferredEmailQueue::class );
$container->get( AddressProviderController::class );
$container->get( AbilitiesRegistry::class );
$container->get( MCPAdapterProvider::class );
diff --git a/plugins/woocommerce/src/Internal/Email/DeferredEmailQueue.php b/plugins/woocommerce/src/Internal/Email/DeferredEmailQueue.php
new file mode 100644
index 00000000000..9a15b58c5ab
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/Email/DeferredEmailQueue.php
@@ -0,0 +1,107 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\Email;
+
+/**
+ * Handles deferred transactional email sending via Action Scheduler.
+ *
+ * Collects email callbacks during a request and dispatches each one as an
+ * individual Action Scheduler action on shutdown, replacing the legacy
+ * WC_Background_Emailer approach.
+ *
+ * @since 10.8.0
+ */
+final class DeferredEmailQueue {
+
+ /**
+ * Action Scheduler hook for processing a queued email.
+ */
+ private const AS_HOOK = 'woocommerce_send_queued_transactional_email';
+
+ /**
+ * Action Scheduler group for email actions.
+ */
+ private const AS_GROUP = 'woocommerce-emails';
+
+ /**
+ * Queue of email callbacks collected during the current request.
+ *
+ * @var array<int, array{filter: string, args: array}>
+ */
+ private array $queue = array();
+
+ /**
+ * Whether the shutdown hook has been registered.
+ *
+ * @var bool
+ */
+ private bool $shutdown_registered = false;
+
+ /**
+ * Initialize hooks.
+ *
+ * @internal
+ */
+ final public function init(): void { // phpcs:ignore Generic.CodeAnalysis.UnnecessaryFinalModifier.Found
+ // Registered unconditionally so previously-scheduled AS jobs can still
+ // be processed even if the feature is later disabled.
+ add_action( self::AS_HOOK, array( $this, 'send_queued_transactional_email' ), 10, 2 );
+ }
+
+ /**
+ * Add an email callback to the queue.
+ *
+ * @param string $filter The action hook name that triggered the email.
+ * @param array $args The arguments passed to the action hook.
+ */
+ public function push( string $filter, array $args ): void {
+ $this->queue[] = array(
+ 'filter' => $filter,
+ 'args' => $args,
+ );
+
+ if ( ! $this->shutdown_registered ) {
+ add_action( 'shutdown', array( $this, 'dispatch' ), 100 );
+ $this->shutdown_registered = true;
+ }
+ }
+
+ /**
+ * Dispatch queued emails via Action Scheduler on shutdown.
+ *
+ * Each email is scheduled as an individual AS action for atomic
+ * processing and per-email failure isolation.
+ *
+ * @internal
+ */
+ public function dispatch(): void {
+ if ( empty( $this->queue ) ) {
+ return;
+ }
+
+ foreach ( $this->queue as $item ) {
+ \WC()->queue()->add( self::AS_HOOK, array( $item['filter'], $item['args'] ), self::AS_GROUP );
+ }
+
+ $this->queue = array();
+ $this->shutdown_registered = false;
+ }
+
+ /**
+ * Process a single queued transactional email from Action Scheduler.
+ *
+ * @internal
+ *
+ * @param mixed $filter The action hook name.
+ * @param mixed $args The arguments for the email callback.
+ */
+ public function send_queued_transactional_email( $filter, $args ): void {
+ if ( ! is_string( $filter ) || ! is_array( $args ) ) {
+ return;
+ }
+
+ \WC_Emails::send_queued_transactional_email( $filter, $args );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Features/FeaturesController.php b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
index 2030facc4ff..3668b00e0b8 100644
--- a/plugins/woocommerce/src/Internal/Features/FeaturesController.php
+++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
@@ -429,6 +429,16 @@ class FeaturesController {
},
),
),
+ 'deferred_transactional_emails' => array(
+ 'name' => __( 'Deferred emails', 'woocommerce' ),
+ 'description' => __(
+ 'Send transactional emails asynchronously via Action Scheduler instead of during the current request.',
+ 'woocommerce'
+ ),
+ 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
+ 'enabled_by_default' => false,
+ 'is_experimental' => false,
+ ),
'email_improvements' => array(
'name' => __( 'Email improvements', 'woocommerce' ),
'description' => __(
diff --git a/plugins/woocommerce/tests/php/src/Internal/Email/DeferredEmailQueueTest.php b/plugins/woocommerce/tests/php/src/Internal/Email/DeferredEmailQueueTest.php
new file mode 100644
index 00000000000..ba6178bb308
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Email/DeferredEmailQueueTest.php
@@ -0,0 +1,198 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Email;
+
+use Automattic\WooCommerce\Internal\Email\DeferredEmailQueue;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the DeferredEmailQueue class.
+ */
+class DeferredEmailQueueTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var DeferredEmailQueue
+ */
+ private $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new DeferredEmailQueue();
+ $this->reset_queue_singleton();
+ add_filter(
+ 'woocommerce_queue_class',
+ function () {
+ return \WC_Admin_Test_Action_Queue::class;
+ }
+ );
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ remove_all_filters( 'woocommerce_queue_class' );
+ remove_all_filters( 'woocommerce_allow_send_queued_transactional_email' );
+ remove_all_actions( 'woocommerce_send_queued_transactional_email' );
+ $this->reset_queue_singleton();
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox Push and dispatch schedules one AS action per email.
+ */
+ public function test_push_and_dispatch_schedules_per_email(): void {
+ $this->sut->push( 'woocommerce_order_status_completed', array( 123 ) );
+ $this->sut->push( 'woocommerce_new_customer_note', array( 456, 'note' ) );
+
+ $this->sut->dispatch();
+
+ $queue = $this->get_test_queue();
+
+ $this->assertCount( 2, $queue->actions, 'Should schedule one AS action per email' );
+ $this->assertSame( 'woocommerce_send_queued_transactional_email', $queue->actions[0]['hook'] );
+ $this->assertSame( 'woocommerce_send_queued_transactional_email', $queue->actions[1]['hook'] );
+ }
+
+ /**
+ * @testdox Dispatch does nothing when the queue is empty.
+ */
+ public function test_dispatch_noop_when_empty(): void {
+ $this->sut->dispatch();
+
+ $queue = $this->get_test_queue();
+
+ $this->assertEmpty( $queue->actions, 'Should not schedule any AS action when queue is empty' );
+ }
+
+ /**
+ * @testdox Dispatch clears the queue after scheduling so a second dispatch is a no-op.
+ */
+ public function test_dispatch_clears_queue(): void {
+ $this->sut->push( 'woocommerce_order_status_completed', array( 123 ) );
+ $this->sut->dispatch();
+ $this->sut->dispatch();
+
+ $queue = $this->get_test_queue();
+
+ $this->assertCount( 1, $queue->actions, 'Second dispatch should not schedule another action' );
+ }
+
+ /**
+ * @testdox Dispatch preserves the filter name and args for each queued email.
+ */
+ public function test_dispatch_preserves_callback_data(): void {
+ $this->sut->push( 'woocommerce_order_status_pending_to_processing', array( 42, 'extra' ) );
+ $this->sut->dispatch();
+
+ $queue = $this->get_test_queue();
+ $action = $queue->actions[0];
+
+ $this->assertSame( 'woocommerce_order_status_pending_to_processing', $action['args'][0] );
+ $this->assertSame( array( 42, 'extra' ), $action['args'][1] );
+ }
+
+ /**
+ * @testdox Dispatch assigns the woocommerce-emails group to scheduled actions.
+ */
+ public function test_dispatch_uses_correct_group(): void {
+ $this->sut->push( 'woocommerce_order_status_completed', array( 1 ) );
+ $this->sut->dispatch();
+
+ $queue = $this->get_test_queue();
+
+ $this->assertSame( 'woocommerce-emails', $queue->actions[0]['group'] );
+ }
+
+ /**
+ * @testdox Processing calls WC_Emails::send_queued_transactional_email with the correct filter and args.
+ */
+ public function test_send_queued_transactional_email_processes_callback(): void {
+ $sent = array();
+
+ add_filter(
+ 'woocommerce_allow_send_queued_transactional_email',
+ function ( $allow, $filter, $args ) use ( &$sent ) {
+ unset( $allow );
+ $sent[] = array(
+ 'filter' => $filter,
+ 'args' => $args,
+ );
+ return false;
+ },
+ 10,
+ 3
+ );
+
+ $this->sut->send_queued_transactional_email( 'woocommerce_order_status_completed', array( 100 ) );
+
+ $this->assertCount( 1, $sent, 'Should process the email callback' );
+ $this->assertSame( 'woocommerce_order_status_completed', $sent[0]['filter'] );
+ $this->assertSame( array( 100 ), $sent[0]['args'] );
+ }
+
+ /**
+ * @testdox Processing skips invalid input types gracefully.
+ */
+ public function test_send_queued_transactional_email_skips_invalid_input(): void {
+ $sent = array();
+
+ add_filter(
+ 'woocommerce_allow_send_queued_transactional_email',
+ function ( $allow, $filter ) use ( &$sent ) {
+ unset( $allow );
+ $sent[] = $filter;
+ return false;
+ },
+ 10,
+ 2
+ );
+
+ $this->sut->send_queued_transactional_email( 123, array() );
+ $this->sut->send_queued_transactional_email( 'valid_hook', 'not-array' );
+
+ $this->assertEmpty( $sent, 'Should not process callbacks with invalid types' );
+ }
+
+ /**
+ * @testdox Push can be called again after dispatch to queue new emails.
+ */
+ public function test_push_after_dispatch_queues_new_emails(): void {
+ $this->sut->push( 'woocommerce_order_status_completed', array( 1 ) );
+ $this->sut->dispatch();
+
+ $this->sut->push( 'woocommerce_new_customer_note', array( 2 ) );
+ $this->sut->dispatch();
+
+ $queue = $this->get_test_queue();
+
+ $this->assertCount( 2, $queue->actions, 'Should schedule actions from both dispatch cycles' );
+ }
+
+ /**
+ * Reset the WC_Queue singleton so the test queue filter takes effect.
+ */
+ private function reset_queue_singleton(): void {
+ $reflection = new \ReflectionClass( \WC_Queue::class );
+ $instance = $reflection->getProperty( 'instance' );
+ $instance->setAccessible( true );
+ $instance->setValue( null, null );
+ }
+
+ /**
+ * Get the test action queue instance.
+ *
+ * @return \WC_Admin_Test_Action_Queue
+ */
+ private function get_test_queue(): \WC_Admin_Test_Action_Queue {
+ $queue = \WC_Queue::instance();
+ $this->assertInstanceOf( \WC_Admin_Test_Action_Queue::class, $queue );
+ return $queue;
+ }
+}