Commit 95179198bf2 for woocommerce
commit 95179198bf2e9601e2a41fe17974806a4bba0660
Author: Hicham Boushaba <hicham.boushaba@gmail.com>
Date: Fri Oct 23 11:57:09 2026 +0100
Fix duplicate emails for QR mobile app login on WordPress 7.2 (#70161)
* Fix duplicate emails for QR mobile app login on WordPress 7.2
* Add changelog entry for QR mobile app login email fix
* Remove the WordPress email skip filter even if creation throws
diff --git a/plugins/woocommerce/changelog/fix-70117-qr-login-duplicate-emails b/plugins/woocommerce/changelog/fix-70117-qr-login-duplicate-emails
new file mode 100644
index 00000000000..fe460dc3d87
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-70117-qr-login-duplicate-emails
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Send one email instead of two when signing in to the Woo mobile app with a QR code on WordPress 7.2 or later.
diff --git a/plugins/woocommerce/src/Admin/API/MobileAppQRLogin.php b/plugins/woocommerce/src/Admin/API/MobileAppQRLogin.php
index c5bf19ca385..31e14a773dd 100644
--- a/plugins/woocommerce/src/Admin/API/MobileAppQRLogin.php
+++ b/plugins/woocommerce/src/Admin/API/MobileAppQRLogin.php
@@ -1157,16 +1157,30 @@ class MobileAppQRLogin extends \WC_REST_Data_Controller {
: array();
$device = $this->sanitize_device_payload( $device_source );
+ // WordPress 7.2+ emails the user when an application password is
+ // created. Skip that email for this password only: the sign-in email
+ // sent below replaces it, or, when a site turns the sign-in email off,
+ // maybe_send_sign_in_notification_email() sends WordPress's instead.
+ $skip_core_email = static function ( $send, $user, $new_item ) {
+ unset( $user );
+ return self::APP_ID === ( $new_item['app_id'] ?? null ) ? false : $send;
+ };
+ add_filter( 'wp_send_application_password_created_email', $skip_core_email, PHP_INT_MAX, 3 );
+
// Create an Application Password for the mobile app. The name is
// descriptive (e.g. "Woo Mobile · iPhone 15 · 2026-04-28") so the user
// can identify it later in Users → Profile → Application Passwords.
- $app_password_result = \WP_Application_Passwords::create_new_application_password(
- $user_id,
- array(
- 'name' => $this->format_application_password_name( $device ),
- 'app_id' => self::APP_ID,
- )
- );
+ try {
+ $app_password_result = \WP_Application_Passwords::create_new_application_password(
+ $user_id,
+ array(
+ 'name' => $this->format_application_password_name( $device ),
+ 'app_id' => self::APP_ID,
+ )
+ );
+ } finally {
+ remove_filter( 'wp_send_application_password_created_email', $skip_core_email, PHP_INT_MAX );
+ }
if ( is_wp_error( $app_password_result ) ) {
wc_get_logger()->error(
@@ -1215,7 +1229,7 @@ class MobileAppQRLogin extends \WC_REST_Data_Controller {
// even when they aren't currently looking at wc-admin. Wrapped in a
// try/catch + filter to keep the exchange path uninterrupted if the
// site's mailer is misconfigured.
- $this->maybe_send_sign_in_notification_email( $user, $consumed_record );
+ $this->maybe_send_sign_in_notification_email( $user, $consumed_record, $item );
return rest_ensure_response(
array(
@@ -1994,19 +2008,19 @@ class MobileAppQRLogin extends \WC_REST_Data_Controller {
}
/**
- * Send the merchant a transactional email summarizing a successful QR
- * sign-in, unless they (or a site owner) opt out via the
- * `woocommerce_qr_login_should_send_signin_email` filter.
+ * Send a sign-in notification email, or fall back to WordPress's application
+ * password email when the WooCommerce email is disabled.
*
* Wrapped so a misconfigured mailer cannot break the exchange path. Mailer
- * false returns and exceptions are logged, but delivery never blocks the API
- * response.
+ * exceptions, and a false return for the WooCommerce email, are logged, but
+ * delivery never blocks the API response.
*
- * @param \WP_User $user The user who minted the token (recipient).
- * @param array<string, mixed> $consumed_record The record persisted to the consumed transient (keys: consumed_at, user_id, ap_uuid, ap_name, device).
+ * @param \WP_User $user The user who minted the token (recipient).
+ * @param array<string, mixed> $consumed_record The record persisted to the consumed transient (keys: consumed_at, user_id, ap_uuid, ap_name, device).
+ * @param array<string, mixed> $application_password The created application password's details, as returned by WP_Application_Passwords::create_new_application_password().
* @return void
*/
- private function maybe_send_sign_in_notification_email( \WP_User $user, array $consumed_record ): void {
+ private function maybe_send_sign_in_notification_email( \WP_User $user, array $consumed_record, array $application_password ): void {
/**
* Filter whether to send the QR sign-in notification email.
*
@@ -2014,6 +2028,7 @@ class MobileAppQRLogin extends \WC_REST_Data_Controller {
* user, environment (e.g. staging), or test run.
*
* @since 10.9.0
+ * @since 11.4.0 On WordPress 7.2+, returning false sends WordPress's application password email instead, unless the site has turned it off.
*
* @param bool $should_send Whether to send the email.
* @param \WP_User $user The user who minted the QR token.
@@ -2026,11 +2041,16 @@ class MobileAppQRLogin extends \WC_REST_Data_Controller {
$consumed_record
);
- if ( ! $should_send ) {
- return;
- }
-
try {
+ if ( ! $should_send ) {
+ // WordPress's email was skipped during password creation to prevent a duplicate. Send it now when ours is off.
+ if ( function_exists( 'wp_application_password_created_notification' )
+ && false !== has_action( 'wp_create_application_password', 'wp_application_password_created_notification' ) ) {
+ wp_application_password_created_notification( $user->ID, $application_password );
+ }
+ return;
+ }
+
if ( ! $this->send_sign_in_notification_email( $user, $consumed_record ) ) {
wc_get_logger()->warning(
sprintf(
diff --git a/plugins/woocommerce/tests/php/src/Admin/API/MobileAppQRLoginTest.php b/plugins/woocommerce/tests/php/src/Admin/API/MobileAppQRLoginTest.php
index ce87b3a90c3..4e151696587 100644
--- a/plugins/woocommerce/tests/php/src/Admin/API/MobileAppQRLoginTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/API/MobileAppQRLoginTest.php
@@ -1762,6 +1762,42 @@ class MobileAppQRLoginTest extends WC_Unit_Test_Case {
}//end try
}
+ /**
+ * @testdox The WordPress application password email is skipped only for the QR login password.
+ */
+ public function test_exchange_token_skips_wordpress_application_password_email_only_for_its_password(): void {
+ // A site forcing WordPress's email on at a later priority must not override the skip.
+ add_filter( 'wp_send_application_password_created_email', '__return_true', 20 );
+
+ $decisions = array();
+ $listener = static function ( $user_id, $new_item ) use ( &$decisions ) {
+ $user = get_userdata( $user_id );
+ $other_item = array_merge( $new_item, array( 'app_id' => 'another-app' ) );
+ $decisions = array(
+ 'qr' => apply_filters( 'wp_send_application_password_created_email', true, $user, $new_item ),
+ 'other' => apply_filters( 'wp_send_application_password_created_email', true, $user, $other_item ),
+ 'item' => $new_item,
+ );
+ };
+ add_action( 'wp_create_application_password', $listener, 10, 2 );
+
+ $prep = $this->prepare_exchange_token(
+ array(
+ 'os' => 'iOS',
+ 'model' => 'iPhone 15',
+ )
+ );
+
+ $response = $this->dispatch_exchange( $prep['plaintext'], $prep['exchange_grant'] );
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertFalse( $decisions['qr'], 'The WordPress email should be skipped for the QR login password, even when a site forces it on.' );
+ $this->assertTrue( $decisions['other'], 'Other application passwords should keep WordPress email behavior.' );
+ $this->assertTrue(
+ apply_filters( 'wp_send_application_password_created_email', true, get_userdata( $this->admin_id ), $decisions['item'] ),
+ 'The temporary skip filter should be removed after the exchange.'
+ );
+ }
+
/**
* @testdox The sign-in notification email can be suppressed via the woocommerce_qr_login_should_send_signin_email filter.
*/
@@ -1780,17 +1816,90 @@ class MobileAppQRLoginTest extends WC_Unit_Test_Case {
$response = $this->dispatch_exchange( $prep['plaintext'], $prep['exchange_grant'] );
$this->assertSame( 200, $response->get_status() );
- $this->assertCount(
- 0,
- $capture['captures'],
- 'Filter returning false must suppress the email send entirely.'
- );
+ foreach ( $capture['captures'] as $mail ) {
+ $this->assertStringContainsString(
+ 'Application Password Created',
+ (string) $mail['subject'],
+ 'Filter returning false must suppress WooCommerce\'s email; only WordPress\'s own email may be sent.'
+ );
+ }
} finally {
remove_filter( 'woocommerce_qr_login_should_send_signin_email', $suppress );
$capture['remove']();
}//end try
}
+ /**
+ * @testdox When the WooCommerce email is disabled, WordPress's application password email is sent unless the site has turned it off.
+ * @dataProvider wp_application_password_email_fallback_provider
+ * @requires function wp_application_password_created_notification
+ *
+ * @param callable $site_setup Applies the site's WordPress email configuration.
+ * @param int $expected_email_count Number of emails the exchange should send.
+ */
+ public function test_wordpress_application_password_email_is_used_when_sign_in_email_is_disabled( callable $site_setup, int $expected_email_count ): void {
+ $site_setup();
+ add_filter( 'woocommerce_qr_login_should_send_signin_email', '__return_false' );
+
+ $capture = $this->capture_wp_mail();
+ $application_password = array();
+ $capture_application_password = static function ( $user_id, $new_item ) use ( &$application_password ) {
+ unset( $user_id );
+ $application_password = $new_item;
+ };
+ add_action( 'wp_create_application_password', $capture_application_password, 1, 2 );
+
+ $prep = $this->prepare_exchange_token(
+ array(
+ 'os' => 'iOS',
+ 'model' => 'iPhone 15',
+ )
+ );
+
+ $response = $this->dispatch_exchange( $prep['plaintext'], $prep['exchange_grant'] );
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertCount( $expected_email_count, $capture['captures'] );
+
+ if ( 1 === $expected_email_count ) {
+ $mail = $capture['captures'][0];
+ $this->assertSame( get_userdata( $this->admin_id )->user_email, $mail['to'] );
+ $this->assertStringContainsString( 'Application Password Created', (string) $mail['subject'] );
+ $this->assertStringContainsString( $application_password['name'], (string) $mail['message'] );
+ }
+ }
+
+ /**
+ * Provide site configurations for the WordPress application password email fallback.
+ *
+ * @return array<string, array{callable, int}>
+ */
+ public static function wp_application_password_email_fallback_provider(): array {
+ return array(
+ 'WordPress email is enabled' => array(
+ static function () {},
+ 1,
+ ),
+ 'WordPress email action is removed' => array(
+ static function () {
+ remove_action( 'wp_create_application_password', 'wp_application_password_created_notification' );
+ },
+ 0,
+ ),
+ 'WordPress email is disabled by a filter' => array(
+ static function () {
+ add_filter( 'wp_send_application_password_created_email', '__return_false' );
+ },
+ 0,
+ ),
+ 'WordPress email is forced on at a later priority' => array(
+ static function () {
+ add_filter( 'wp_send_application_password_created_email', '__return_true', 20 );
+ },
+ 1,
+ ),
+ );
+ }
+
/**
* @testdox A wp_mail false return is logged without failing a successful exchange.
*/