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