Commit cbaa2f59e45 for woocommerce
commit cbaa2f59e45cdc0727576456fb25f3243a00be49
Author: Eduard “RwkY” Doloc <rwkyyy@users.noreply.github.com>
Date: Mon Oct 19 14:31:07 2026 +0300
Send deferred emails in the locale they were queued in (#69799)
* Send deferred emails in the locale they were queued in
Deferred emails are sent from an Action Scheduler action, so they were
rendered in the locale of whichever request ran the action. When the async
request runner processes the queue, it carries the cookies of the logged-in
admin whose page load dispatched it, so emails to the store came out in that
admin's profile language instead of the language they were triggered in.
DeferredEmailQueue now stores determine_locale() with each queued email,
passes it to the action as an optional third argument, and switches to it
while sending. Actions queued before this change keep working without a
locale switch.
* Restore the runner locale when reloading translations throws
* Switch only non-customer emails to the queued locale
* Update changelog entry for deferred email locale fix
diff --git a/plugins/woocommerce/changelog/fix-deferred-email-locale b/plugins/woocommerce/changelog/fix-deferred-email-locale
new file mode 100644
index 00000000000..781a147406b
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-deferred-email-locale
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Send deferred admin emails in the language they were triggered in, not the language of the admin whose session runs the queue.
diff --git a/plugins/woocommerce/src/Internal/Email/DeferredEmailQueue.php b/plugins/woocommerce/src/Internal/Email/DeferredEmailQueue.php
index 3e831ad3f0d..34c61113b11 100644
--- a/plugins/woocommerce/src/Internal/Email/DeferredEmailQueue.php
+++ b/plugins/woocommerce/src/Internal/Email/DeferredEmailQueue.php
@@ -36,7 +36,7 @@ final class DeferredEmailQueue {
/**
* Queue of email callbacks collected during the current request.
*
- * @var array<int, array{filter: string, args: array}>
+ * @var array<int, array{filter: string, args: array, locale: string}>
*/
private array $queue = array();
@@ -47,6 +47,20 @@ final class DeferredEmailQueue {
*/
private bool $shutdown_registered = false;
+ /**
+ * The locale of the queued email being sent, empty when it was queued without one.
+ *
+ * @var string
+ */
+ private string $sending_locale = '';
+
+ /**
+ * Object IDs of the emails switched to the queued locale, most recent last.
+ *
+ * @var int[]
+ */
+ private array $switched_emails = array();
+
/**
* Initialize hooks.
*
@@ -55,7 +69,7 @@ final class DeferredEmailQueue {
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_action( self::AS_HOOK, array( $this, 'send_queued_transactional_email' ), 10, 3 );
}
/**
@@ -78,6 +92,7 @@ final class DeferredEmailQueue {
$this->queue[] = array(
'filter' => $filter,
'args' => $args,
+ 'locale' => determine_locale(),
);
if ( ! $this->shutdown_registered ) {
@@ -104,7 +119,7 @@ final class DeferredEmailQueue {
foreach ( $this->queue as $item ) {
\WC()->queue()->add(
self::AS_HOOK,
- array( $item['filter'], $item['args'] ),
+ array( $item['filter'], $item['args'], $item['locale'] ),
self::AS_GROUP
);
}
@@ -116,12 +131,17 @@ final class DeferredEmailQueue {
/**
* Process a single queued transactional email from Action Scheduler.
*
+ * Emails that are not sent to the customer use the locale of the request that queued them, as
+ * they would without deferral. The request running the queue can be in another locale, e.g. an
+ * async runner started from an admin's dashboard runs with that admin's profile language.
+ *
* @internal
*
* @param mixed $filter The action hook name.
* @param mixed $args The arguments for the email callback.
+ * @param mixed $locale The locale the email was queued in (since 11.3.0); empty for emails queued before.
*/
- public function send_queued_transactional_email( $filter, $args ): void {
+ public function send_queued_transactional_email( $filter, $args, $locale = '' ): void {
if ( ! is_string( $filter ) || ! is_array( $args ) ) {
return;
}
@@ -131,7 +151,80 @@ final class DeferredEmailQueue {
return;
}
- \WC_Emails::send_queued_transactional_email( $filter, $args );
+ if ( ! is_string( $locale ) || '' === $locale ) {
+ \WC_Emails::send_queued_transactional_email( $filter, $args );
+ return;
+ }
+
+ // Switch per email rather than for the whole action: customer emails switch to the site locale
+ // through get_locale(), which would return the queued locale while it is switched in.
+ $this->sending_locale = $locale;
+ add_filter( 'woocommerce_allow_switching_email_locale', array( $this, 'switch_email_to_queued_locale' ), PHP_INT_MAX, 2 );
+ add_filter( 'woocommerce_allow_restoring_email_locale', array( $this, 'restore_email_from_queued_locale' ), PHP_INT_MAX, 2 );
+
+ try {
+ \WC_Emails::send_queued_transactional_email( $filter, $args );
+ } finally {
+ remove_filter( 'woocommerce_allow_switching_email_locale', array( $this, 'switch_email_to_queued_locale' ), PHP_INT_MAX );
+ remove_filter( 'woocommerce_allow_restoring_email_locale', array( $this, 'restore_email_from_queued_locale' ), PHP_INT_MAX );
+
+ // An email that threw while sending never reached restore_locale().
+ while ( array() !== $this->switched_emails ) {
+ array_pop( $this->switched_emails );
+ $this->restore_runner_locale();
+ }
+ $this->sending_locale = '';
+ }
+ }
+
+ /**
+ * Switch an email that is not sent to the customer to the queued locale, from WC_Email::setup_locale().
+ *
+ * Hooked last, so an email whose locale switching was turned off, e.g. by a translation plugin, is left alone.
+ *
+ * @internal
+ *
+ * @param mixed $allow Whether the email may switch its locale.
+ * @param mixed $email The email being set up.
+ * @return mixed The unchanged $allow.
+ */
+ public function switch_email_to_queued_locale( $allow, $email ) {
+ if ( ! $allow || ! $email instanceof \WC_Email || $email->is_customer_email() ) {
+ return $allow;
+ }
+
+ if ( switch_to_locale( $this->sending_locale ) ) {
+ $this->switched_emails[] = spl_object_id( $email );
+ \WC()->load_plugin_textdomain();
+ }
+
+ return $allow;
+ }
+
+ /**
+ * Restore the runner's locale after an email switched by switch_email_to_queued_locale(), from WC_Email::restore_locale().
+ *
+ * @internal
+ *
+ * @param mixed $allow Whether the email may restore its locale.
+ * @param mixed $email The email being restored.
+ * @return mixed The unchanged $allow.
+ */
+ public function restore_email_from_queued_locale( $allow, $email ) {
+ if ( is_object( $email ) && end( $this->switched_emails ) === spl_object_id( $email ) ) {
+ array_pop( $this->switched_emails );
+ $this->restore_runner_locale();
+ }
+
+ return $allow;
+ }
+
+ /**
+ * Undo one switch to the queued locale and reload WooCommerce translations for the runner's locale.
+ */
+ private function restore_runner_locale(): void {
+ restore_previous_locale();
+ \WC()->load_plugin_textdomain();
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/Email/DeferredEmailQueueTest.php b/plugins/woocommerce/tests/php/src/Internal/Email/DeferredEmailQueueTest.php
index 136b2651c4e..c4c423366fd 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Email/DeferredEmailQueueTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Email/DeferredEmailQueueTest.php
@@ -29,6 +29,13 @@ class DeferredEmailQueueTest extends WC_Unit_Test_Case {
*/
private $created_stock_notification_ids = array();
+ /**
+ * Locale filters added by a test, as hook name, callback and priority.
+ *
+ * @var array<int, array{0: string, 1: callable, 2: int}>
+ */
+ private $locale_filters = array();
+
/**
* Set up test fixtures.
*/
@@ -56,6 +63,15 @@ class DeferredEmailQueueTest extends WC_Unit_Test_Case {
remove_all_actions( 'woocommerce_deferred_email_test_unknown_object_notification' );
remove_all_actions( 'woocommerce_deferred_email_test_unsaved_product' );
remove_all_actions( 'woocommerce_deferred_email_test_unsaved_product_notification' );
+ remove_all_filters( 'woocommerce_mail_callback' );
+ remove_all_filters( 'woocommerce_allow_switching_email_locale' );
+ foreach ( $this->locale_filters as list( $hook, $callback, $priority ) ) {
+ remove_filter( $hook, $callback, $priority );
+ }
+ $this->locale_filters = array();
+ if ( is_locale_switched() ) {
+ restore_current_locale();
+ }
$this->set_wc_emails_deferred_queue( null );
$this->delete_stock_notifications();
$this->restore_stock_notifications_feature_option();
@@ -410,6 +426,103 @@ class DeferredEmailQueueTest extends WC_Unit_Test_Case {
$this->assertEmpty( $sent, 'Should not process callbacks with invalid types' );
}
+ /**
+ * @testdox Dispatch stores the locale the email was queued in.
+ */
+ public function test_dispatch_stores_the_queued_locale(): void {
+ $this->force_locale_filter( 'determine_locale', 'de_DE' );
+
+ $this->sut->push( 'woocommerce_order_status_pending_to_processing', array( 42 ) );
+ $this->sut->dispatch();
+
+ $action = $this->get_scheduled_email_action();
+
+ $this->assertArrayHasKey( 2, $action['args'], 'The queued locale should be scheduled with the email' );
+ $this->assertSame( 'de_DE', $action['args'][2] );
+ }
+
+ /**
+ * @testdox Processing sends an admin email in the queued locale and restores the runner's locale afterwards.
+ */
+ public function test_send_queued_transactional_email_sends_admin_email_in_the_queued_locale(): void {
+ $sent = $this->send_queued_order_emails( 'en_US' );
+
+ $this->assertArrayHasKey( 'new_order', $sent, 'The admin email should be sent' );
+ $this->assertTrue( $sent['new_order']['switched'], 'The admin email should be sent with the queued locale switched in' );
+ $this->assertSame( 'en_US', $sent['new_order']['locale'] );
+ $this->assertFalse( is_locale_switched(), 'The runner locale should be restored after sending' );
+ }
+
+ /**
+ * @testdox Processing sends a customer email in the site locale, whatever locale it was queued in.
+ */
+ public function test_send_queued_transactional_email_sends_customer_email_in_the_site_locale(): void {
+ $sent = $this->send_queued_order_emails( 'en_US' );
+
+ $this->assertArrayHasKey( 'customer_processing_order', $sent, 'The customer email should be sent' );
+ $this->assertSame( 'fr_FR', $sent['customer_processing_order']['locale'] );
+ }
+
+ /**
+ * @testdox Processing sends an admin email hooked after a customer email in the queued locale.
+ */
+ public function test_send_queued_transactional_email_sends_admin_email_after_customer_email_in_the_queued_locale(): void {
+ $sent = $this->send_queued_order_emails( 'en_US', true );
+
+ $this->assertArrayHasKey( 'new_order', $sent, 'The admin email should be sent' );
+ $this->assertSame( 'en_US', $sent['new_order']['locale'] );
+ }
+
+ /**
+ * @testdox Processing restores the runner's locale when sending throws.
+ */
+ public function test_send_queued_transactional_email_restores_locale_when_sending_throws(): void {
+ $order_id = $this->prepare_processing_order_emails();
+
+ add_filter(
+ 'woocommerce_mail_callback',
+ function () {
+ return static function () {
+ throw new \RuntimeException( 'Sending failed.' );
+ };
+ }
+ );
+
+ $thrown = null;
+ try {
+ $this->sut->send_queued_transactional_email( 'woocommerce_order_status_pending_to_processing', array( $order_id ), 'en_US' );
+ } catch ( \RuntimeException $e ) {
+ $thrown = $e;
+ }
+
+ $this->assertInstanceOf( \RuntimeException::class, $thrown, 'The sending exception should propagate' );
+ $this->assertFalse( is_locale_switched(), 'The runner locale should be restored even when sending throws' );
+ }
+
+ /**
+ * @testdox Processing does not switch locale for emails queued without one.
+ */
+ public function test_send_queued_transactional_email_without_queued_locale_does_not_switch(): void {
+ $sent = $this->send_queued_order_emails( '' );
+
+ $this->assertArrayHasKey( 'new_order', $sent, 'The admin email should be sent' );
+ $this->assertFalse( $sent['new_order']['switched'], 'Emails queued without a locale should not switch locale' );
+ $this->assertSame( 'fr_FR', $sent['new_order']['locale'] );
+ }
+
+ /**
+ * @testdox Processing leaves the email locale alone when email locale switching is turned off, as translation plugins do.
+ */
+ public function test_send_queued_transactional_email_respects_disabled_email_locale_switching(): void {
+ add_filter( 'woocommerce_allow_switching_email_locale', '__return_false' );
+
+ $sent = $this->send_queued_order_emails( 'en_US' );
+
+ $this->assertArrayHasKey( 'new_order', $sent, 'The admin email should be sent' );
+ $this->assertFalse( $sent['new_order']['switched'], 'The admin email should not switch locale when switching is turned off' );
+ $this->assertFalse( is_locale_switched(), 'No locale switch should be left behind' );
+ }
+
/**
* @testdox Push can be called again after dispatch to queue new emails.
*/
@@ -528,6 +641,91 @@ class DeferredEmailQueueTest extends WC_Unit_Test_Case {
);
}
+ /**
+ * Send the real "processing order" emails (new_order to the admin, customer_processing_order to the
+ * customer) through the queue, with fr_FR as the site locale. Instead of mailing, record the locale
+ * active while each email is sent.
+ *
+ * @param string $queued_locale The locale the emails were queued in.
+ * @param bool $admin_email_last Whether to send the admin email after the customer email.
+ * @return array<string, array{locale: string, switched: bool}> Keyed by email ID.
+ */
+ private function send_queued_order_emails( string $queued_locale, bool $admin_email_last = false ): array {
+ $order_id = $this->prepare_processing_order_emails( $admin_email_last );
+
+ $sent = array();
+ add_filter(
+ 'woocommerce_mail_callback',
+ function ( $callback, $email ) use ( &$sent ) {
+ unset( $callback );
+ return static function () use ( &$sent, $email ) {
+ $sent[ $email->id ] = array(
+ 'locale' => get_locale(),
+ 'switched' => is_locale_switched(),
+ );
+ return true;
+ };
+ },
+ 10,
+ 2
+ );
+
+ $this->sut->send_queued_transactional_email( 'woocommerce_order_status_pending_to_processing', array( $order_id ), $queued_locale );
+
+ return $sent;
+ }
+
+ /**
+ * Create an order and set fr_FR as the site locale, ready to send the "processing order" emails.
+ *
+ * @param bool $admin_email_last Whether to send the admin email after the customer email.
+ * @return int The order ID.
+ */
+ private function prepare_processing_order_emails( bool $admin_email_last = false ): int {
+ $order = \WC_Helper_Order::create_order();
+ $this->set_site_locale( 'fr_FR' );
+
+ // Emails hook their triggers once, when the mailer is created, and hooks are reset between tests.
+ $emails = \WC_Emails::instance()->get_emails();
+ $hooks = array(
+ array( $emails['WC_Email_New_Order'], $admin_email_last ? 20 : 10 ),
+ array( $emails['WC_Email_Customer_Processing_Order'], 10 ),
+ );
+ foreach ( $hooks as list( $email, $priority ) ) {
+ remove_action( 'woocommerce_order_status_pending_to_processing_notification', array( $email, 'trigger' ), 10 );
+ add_action( 'woocommerce_order_status_pending_to_processing_notification', array( $email, 'trigger' ), $priority, 2 );
+ }
+
+ return $order->get_id();
+ }
+
+ /**
+ * Set the site locale until tear down. Only en_US can be switched to in the test environment,
+ * so a different site locale shows which emails were switched away from it.
+ *
+ * @param string $locale The site locale.
+ */
+ private function set_site_locale( string $locale ): void {
+ // Below WP_Locale_Switcher's priority 10, so a switched locale still wins as it does on a real site.
+ $this->force_locale_filter( 'locale', $locale, 5 );
+ }
+
+ /**
+ * Force a locale filter to return the given locale until tear down.
+ *
+ * @param string $hook The filter name (locale or determine_locale).
+ * @param string $locale The locale to return.
+ * @param int $priority The filter priority.
+ */
+ private function force_locale_filter( string $hook, string $locale, int $priority = 20 ): void {
+ $callback = static function () use ( $locale ) {
+ return $locale;
+ };
+
+ add_filter( $hook, $callback, $priority );
+ $this->locale_filters[] = array( $hook, $callback, $priority );
+ }
+
/**
* Get the scheduled Action Scheduler email action from the test queue.
*