From 3dc93e0918a4b9c28afb5a8ef32e74cd7da11091 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Fri, 11 Sep 2026 12:41:33 +0600 Subject: [PATCH 1/9] Users: Require confirmation for email changes made outside the profile screen. Splits `send_confirmation_on_profile_email()` into a function that sends the confirmation request and one that processes a confirmed change, then calls the former from `WP_REST_Users_Controller::update_item()`, so that a user changing their own email address over the REST API is asked to confirm it in the same way they are on the profile screen. Adds a `wp-login.php?action=confirmemail` route so a change started outside of the admin can be confirmed. This is the patch from https://github.com/WordPress/wordpress-develop/pull/3813 rebased onto trunk, with no changes other than conflict resolution against the `$user_id` parameter added to `send_confirmation_on_profile_email()` in 7.0.3. See #57413. --- src/wp-admin/user-edit.php | 13 +- .../class-wp-rest-users-controller.php | 8 + src/wp-includes/user.php | 205 ++++++++++++------ src/wp-login.php | 39 ++++ .../tests/rest-api/rest-users-controller.php | 68 ++++++ tests/phpunit/tests/user.php | 35 +++ 6 files changed, 293 insertions(+), 75 deletions(-) diff --git a/src/wp-admin/user-edit.php b/src/wp-admin/user-edit.php index 8055de6b36385..2e21f7377ceec 100644 --- a/src/wp-admin/user-edit.php +++ b/src/wp-admin/user-edit.php @@ -106,21 +106,12 @@ // Execute confirmed email change. See send_confirmation_on_profile_email(). if ( IS_PROFILE_PAGE && isset( $_GET['newuseremail'] ) && $current_user->ID ) { - $new_email = get_user_meta( $current_user->ID, '_new_email', true ); - if ( $new_email && hash_equals( $new_email['hash'], $_GET['newuseremail'] ) ) { - $user = new stdClass(); - $user->ID = $current_user->ID; - $user->user_email = esc_html( trim( $new_email['newemail'] ) ); - if ( is_multisite() && $wpdb->get_var( $wpdb->prepare( "SELECT user_login FROM {$wpdb->signups} WHERE user_login = %s", $current_user->user_login ) ) ) { - $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->signups} SET user_email = %s WHERE user_login = %s", $user->user_email, $current_user->user_login ) ); - } - wp_update_user( $user ); - delete_user_meta( $current_user->ID, '_new_email' ); + if ( send_user_email_change_confirmation_process( $current_user->ID, $_GET['newuseremail'] ) ) { wp_redirect( add_query_arg( array( 'updated' => 'true' ), self_admin_url( 'profile.php' ) ) ); - die(); } else { wp_redirect( add_query_arg( array( 'error' => 'new-email' ), self_admin_url( 'profile.php' ) ) ); } + die(); } elseif ( IS_PROFILE_PAGE && ! empty( $_GET['dismiss'] ) && $current_user->ID . '_new_email' === $_GET['dismiss'] ) { check_admin_referer( 'dismiss-' . $current_user->ID . '_new_email' ); delete_user_meta( $current_user->ID, '_new_email' ); diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php index 9b25cf7974cbc..d26861244a3d1 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php @@ -806,6 +806,14 @@ public function update_item( $request ) { } } + // Maybe send the change email confirmation. + if ( ! empty( $request['email'] ) ) { + $email_sent = send_user_email_change_confirmation_email( $user, $request['email'] ); + if ( true === $email_sent ) { + unset( $request['email'] ); + } + } + $user = $this->prepare_item_for_database( $request ); // Ensure we're operating on the same user we already checked. diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php index 6ce8dbf05175d..487146ad9bd59 100644 --- a/src/wp-includes/user.php +++ b/src/wp-includes/user.php @@ -3877,55 +3877,101 @@ function send_confirmation_on_profile_email( $user_id = 0 ) { $user_id = absint( $_POST['user_id'] ); } - $current_user = wp_get_current_user(); if ( ! is_object( $errors ) ) { $errors = new WP_Error(); } - if ( 0 === $current_user->ID || $current_user->ID !== (int) $user_id ) { - return false; - } + $user = get_user_by( 'id', $user_id ); + $email_sent = send_user_email_change_confirmation_email( $user, $_POST['email'] ); - if ( $current_user->user_email !== $_POST['email'] ) { - if ( ! is_email( $_POST['email'] ) ) { + if ( is_wp_error( $email_sent ) ) { + // WP_Error::copy_errors() with the addition of adding data. + foreach ( $email_sent->get_error_codes() as $code ) { $errors->add( - 'user_email', - __( 'Error: The email address is not correct.' ), + $code, + $email_sent->get_error_message( $code ), array( 'form-field' => 'email', ) ); - - $_POST['email'] = addslashes( $current_user->user_email ); - return; } + } - if ( email_exists( $_POST['email'] ) ) { - $errors->add( - 'user_email', - __( 'Error: The email address is already used.' ), - array( - 'form-field' => 'email', - ) - ); - delete_user_meta( $current_user->ID, '_new_email' ); + if ( true === $email_sent ) { + $_POST['email'] = $user->user_email; + } +} - $_POST['email'] = addslashes( $current_user->user_email ); - return; - } +/** + * Send the 'confirm your email' email. + * + * @since x.x + * + * @param WP_User $user The user to act upon. + * @param string $email The new email address. + * @return null|true|WP_Error true if email sent, WP_Error on error, and null otherwise. + */ +function send_user_email_change_confirmation_email( $user, $email ) { + if ( $user->user_email == $email ) { + return; + } + + if ( ! is_email( $email ) ) { + return new WP_Error( + 'user_email', + __( 'Error: The email address is not correct.' ) + ); + } + + if ( email_exists( $email ) ) { + delete_user_meta( $user->ID, '_new_email' ); - $hash = md5( $_POST['email'] . time() . wp_rand() ); - $new_user_email = array( - 'hash' => $hash, - 'newemail' => $_POST['email'], + return new WP_Error( + 'user_email', + __( 'Error: The email address is already used.' ) ); - update_user_meta( $current_user->ID, '_new_email', $new_user_email ); + } + + // The email is only sent if a user changes their own email. + $should_send_email_for_change = ( get_current_user_id() === $user->ID ); + + /** + * Filters whether a 'confirm your email address' email should be sent. + * If false is returned, the change is made immediately. + * + * @since x.x + * + * @param bool $should_send_email_for_change Whether to use an email confirmation. + * @param WP_User $user The user having their email changed. + * @param string $email The new email address. + */ + $should_send_email_for_change = apply_filters( 'should_send_email_for_email_change', $should_send_email_for_change, $user, $email ); + + if ( ! $should_send_email_for_change ) { + return; + } + + $hash = md5( $email . time() . wp_rand() ); + $new_user_email = array( + 'hash' => $hash, + 'newemail' => $email, + ); + update_user_meta( $user->ID, '_new_email', $new_user_email ); - $sitename = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES ); + $confirm_url = add_query_arg( + array( + 'action' => 'confirmemail', + 'id' => $user->ID, + 'hash' => $hash, + ), + wp_login_url() + ); - /* translators: Do not translate USERNAME, ADMIN_URL, EMAIL, SITENAME, SITEURL: those are placeholders. */ - $email_text = __( - 'Howdy ###USERNAME###, + $sitename = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES ); + + /* translators: Do not translate USERNAME, ADMIN_URL, EMAIL, SITENAME, SITEURL: those are placeholders. */ + $email_text = __( + 'Howdy ###USERNAME###, You recently requested to have the email address on your account changed. @@ -3940,43 +3986,74 @@ function send_confirmation_on_profile_email( $user_id = 0 ) { Regards, All at ###SITENAME### ###SITEURL###' - ); + ); - /** - * Filters the text of the email sent when a change of user email address is attempted. - * - * The following strings have a special meaning and will get replaced dynamically: - * - * - `###USERNAME###` The current user's username. - * - `###ADMIN_URL###` The link to click on to confirm the email change. - * - `###EMAIL###` The new email. - * - `###SITENAME###` The name of the site. - * - `###SITEURL###` The URL to the site. - * - * @since MU (3.0.0) - * @since 4.9.0 This filter is no longer Multisite specific. - * - * @param string $email_text Text in the email. - * @param array $new_user_email { - * Data relating to the new user email address. - * - * @type string $hash The secure hash used in the confirmation link URL. - * @type string $newemail The proposed new email address. - * } - */ - $content = apply_filters( 'new_user_email_content', $email_text, $new_user_email ); + /** + * Filters the text of the email sent when a change of user email address is attempted. + * + * The following strings have a special meaning and will get replaced dynamically: + * + * - `###USERNAME###` The current user's username. + * - `###ADMIN_URL###` The link to click on to confirm the email change. + * - `###EMAIL###` The new email. + * - `###SITENAME###` The name of the site. + * - `###SITEURL###` The URL to the site. + * + * @since MU (3.0.0) + * @since 4.9.0 This filter is no longer Multisite specific. + * + * @param string $email_text Text in the email. + * @param array $new_user_email { + * Data relating to the new user email address. + * + * @type string $hash The secure hash used in the confirmation link URL. + * @type string $newemail The proposed new email address. + * } + */ + $content = apply_filters( 'new_user_email_content', $email_text, $new_user_email ); + + $content = str_replace( '###USERNAME###', $user->user_login, $content ); + $content = str_replace( '###ADMIN_URL###', esc_url( $confirm_url ), $content ); + $content = str_replace( '###EMAIL###', $email, $content ); + $content = str_replace( '###SITENAME###', $sitename, $content ); + $content = str_replace( '###SITEURL###', home_url(), $content ); - $content = str_replace( '###USERNAME###', $current_user->user_login, $content ); - $content = str_replace( '###ADMIN_URL###', esc_url( self_admin_url( 'profile.php?newuseremail=' . $hash ) ), $content ); - $content = str_replace( '###EMAIL###', $_POST['email'], $content ); - $content = str_replace( '###SITENAME###', $sitename, $content ); - $content = str_replace( '###SITEURL###', home_url(), $content ); + /* translators: New email address notification email subject. %s: Site title. */ + wp_mail( $email, sprintf( __( '[%s] Email Change Request' ), $sitename ), $content ); + + return true; +} + +/** + * Process the confirmation of an email change request. + * + * @since x.x + * + * @param int $user_id The User ID being acted upon. + * @param string $email_hash The email hash of the request + * + * @return bool Whether or not the change succeeded. + */ +function send_user_email_change_confirmation_process( $user_id, $email_hash ) { - /* translators: New email address notification email subject. %s: Site title. */ - wp_mail( $_POST['email'], sprintf( __( '[%s] Email Change Request' ), $sitename ), $content ); + $new_email = get_user_meta( $user_id, '_new_email', true ); + if ( ! $new_email || ! hash_equals( $new_email['hash'], $email_hash ) ) { + return false; + } - $_POST['email'] = $current_user->user_email; + $the_user = get_user_by( 'id', $user_id ); + $user = new stdClass(); + $user->ID = $the_user->ID; + $user->user_email = esc_html( trim( $new_email['newemail'] ) ); + if ( is_multisite() && $wpdb->get_var( $wpdb->prepare( "SELECT user_login FROM {$wpdb->signups} WHERE user_login = %s", $the_user->user_login ) ) ) { + $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->signups} SET user_email = %s WHERE user_login = %s", $user->user_email, $the_user->user_login ) ); } + + wp_update_user( $user ); + + delete_user_meta( $user->ID, '_new_email' ); + + return true; } /** diff --git a/src/wp-login.php b/src/wp-login.php index 43689323647de..fd35c4e3106bf 100644 --- a/src/wp-login.php +++ b/src/wp-login.php @@ -495,6 +495,7 @@ function wp_login_viewport_meta() { 'register', 'checkemail', 'confirmaction', + 'confirmemail', 'login', WP_Recovery_Mode_Link_Service::LOGIN_ACTION_ENTERED, ); @@ -1237,6 +1238,44 @@ function wp_login_viewport_meta() { login_footer(); break; + case 'confirmemail': + if ( ! isset( $_GET['id'], $_GET['hash'] ) ) { + wp_die( __( 'Missing or invalid key.' ) ); + } + + if ( ! is_user_logged_in() ) { + wp_safe_redirect( wp_login_url() ); + exit; + } + + $user_id = wp_unslash( $_GET['id'] ); + $email_key = wp_unslash( $_GET['hash'] ); + + if ( ! current_user_can( 'edit_user', $user_id ) ) { + wp_die( __( 'Missing or invalid key.' ) ); + } + + $updated = send_user_email_change_confirmation_process( $user_id, $email_key ); + if ( ! $updated ) { + wp_die( __( 'Missing or invalid key.' ) ); + } + + /** + * Fires an action hook when the account email has been confirmed by the user. + * + * @since x.x + * + * @param int $user_id User ID. + */ + do_action( 'user_email_confirmed', $user_id ); + + login_header( + __( 'Confirm your email' ), + '

' . __( 'Your email has been confirmed.' ) . '

' + ); + login_footer(); + break; + case 'confirmaction': if ( ! isset( $_GET['request_id'] ) ) { wp_die( __( 'Missing request ID.' ) ); diff --git a/tests/phpunit/tests/rest-api/rest-users-controller.php b/tests/phpunit/tests/rest-api/rest-users-controller.php index 86ec4b8048551..18e4c47a9c0ee 100644 --- a/tests/phpunit/tests/rest-api/rest-users-controller.php +++ b/tests/phpunit/tests/rest-api/rest-users-controller.php @@ -3128,6 +3128,74 @@ public function test_get_default_value( $args, $expected ) { $this->assertSame( $expected, $meta[ $meta_key ] ); } + /** + * @ticket 57413 + */ + public function test_send_confirmation_on_profile_email() { + reset_phpmailer_instance(); + $was_confirmation_email_sent = false; + + $user_id = self::factory()->user->create( + array( + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/users/%d', $user_id ) ); + $request->set_param( 'email', 'after@example.com' ); + $response = rest_get_server()->dispatch( $request ); + + $new_data = $response->get_data(); + $this->assertSame( 'before@example.com', $new_data['email'] ); + + if ( ! empty( $GLOBALS['phpmailer']->mock_sent ) ) { + $was_confirmation_email_sent = ( isset( $GLOBALS['phpmailer']->mock_sent[0] ) && 'after@example.com' === $GLOBALS['phpmailer']->mock_sent[0]['to'][0][0] ); + } + + // A confirmation email is sent. + $this->assertTrue( $was_confirmation_email_sent ); + + // The new email address gets put into user_meta. + $new_email_meta = get_user_meta( $user_id, '_new_email', true ); + $this->assertSame( 'after@example.com', $new_email_meta['newemail'] ); + } + + /** + * @ticket 57413 + */ + public function test_no_confirmation_on_profile_email_by_admin() { + reset_phpmailer_instance(); + $was_confirmation_email_sent = false; + + $user_id = self::factory()->user->create( + array( + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( self::$superadmin ); + + $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/users/%d', $user_id ) ); + $request->set_param( 'email', 'after@example.com' ); + $response = rest_get_server()->dispatch( $request ); + + $new_data = $response->get_data(); + $this->assertSame( 'after@example.com', $new_data['email'] ); + + if ( ! empty( $GLOBALS['phpmailer']->mock_sent ) ) { + $was_confirmation_email_sent = ( isset( $GLOBALS['phpmailer']->mock_sent[0] ) && 'after@example.com' === $GLOBALS['phpmailer']->mock_sent[0]['to'][0][0] ); + } + + // No confirmation email is sent. + $this->assertFalse( $was_confirmation_email_sent ); + + // No usermeta is created. + $new_email_meta = get_user_meta( $user_id, '_new_email', true ); + $this->assertEmpty( $new_email_meta ); + } + public function data_get_default_data() { return array( array( diff --git a/tests/phpunit/tests/user.php b/tests/phpunit/tests/user.php index f600adbcb1164..36f776b2d10fb 100644 --- a/tests/phpunit/tests/user.php +++ b/tests/phpunit/tests/user.php @@ -2318,6 +2318,41 @@ public function test_remove_send_confirmation_on_profile_email() { $this->assertSame( $_POST['email'], 'after@example.com' ); } + /** + * @ticket 57413 + */ + public function test_no_confirmation_on_profile_email_by_admin() { + reset_phpmailer_instance(); + $was_confirmation_email_sent = false; + + $user = self::factory()->user->create_and_get( + array( + 'user_email' => 'before@example.com', + ) + ); + + $_POST['email'] = 'after@example.com'; + $_POST['user_id'] = $user->ID; + + wp_set_current_user( self::$admin_id ); + + do_action( 'personal_options_update' ); + + if ( ! empty( $GLOBALS['phpmailer']->mock_sent ) ) { + $was_confirmation_email_sent = ( isset( $GLOBALS['phpmailer']->mock_sent[0] ) && 'after@example.com' === $GLOBALS['phpmailer']->mock_sent[0]['to'][0][0] ); + } + + // No confirmation email is sent. + $this->assertFalse( $was_confirmation_email_sent ); + + // No usermeta is created. + $new_email_meta = get_user_meta( $user->ID, '_new_email', true ); + $this->assertEmpty( $new_email_meta ); + + // $_POST['email'] should be the email address posted from the form. + $this->assertSame( 'after@example.com', $_POST['email'] ); + } + /** * Ensure user email address change confirmation emails do not contain encoded HTML entities * From ecf55a6ed7250b36b951a9c3689f424850721a3f Mon Sep 17 00:00:00 2001 From: Shahjahan Jewel <1053500+techjewel@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:41:56 +0600 Subject: [PATCH 2/9] Users: Fix a fatal error when confirming an email change on Multisite. `send_user_email_change_confirmation_process()` uses `$wpdb` in the Multisite branch, but the code was moved out of `wp-admin/user-edit.php`, where `$wpdb` was already in scope at the top level, so the function had no `global $wpdb;` declaration. Confirming an email change on Multisite called a method on `null`. Also guards the lookups the function performs: an unknown user ID, a missing `_new_email` value, and a `wp_update_user()` failure are all now reported as a failed confirmation rather than assumed to have worked. Reading `['hash']` off a non-array `_new_email` was only silent because `hash_equals()` was reached through `! $new_email`. See #57413. --- src/wp-includes/user.php | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php index 487146ad9bd59..2541b7f12dd75 100644 --- a/src/wp-includes/user.php +++ b/src/wp-includes/user.php @@ -4029,19 +4029,32 @@ function send_user_email_change_confirmation_email( $user, $email ) { * * @since x.x * + * @global wpdb $wpdb WordPress database abstraction object. + * * @param int $user_id The User ID being acted upon. * @param string $email_hash The email hash of the request * * @return bool Whether or not the change succeeded. */ function send_user_email_change_confirmation_process( $user_id, $email_hash ) { + global $wpdb; + + $the_user = get_userdata( $user_id ); + + if ( ! $the_user ) { + return false; + } - $new_email = get_user_meta( $user_id, '_new_email', true ); - if ( ! $new_email || ! hash_equals( $new_email['hash'], $email_hash ) ) { + $new_email = get_user_meta( $the_user->ID, '_new_email', true ); + + if ( ! is_array( $new_email ) || empty( $new_email['hash'] ) || empty( $new_email['newemail'] ) ) { + return false; + } + + if ( ! hash_equals( $new_email['hash'], (string) $email_hash ) ) { return false; } - $the_user = get_user_by( 'id', $user_id ); $user = new stdClass(); $user->ID = $the_user->ID; $user->user_email = esc_html( trim( $new_email['newemail'] ) ); @@ -4049,7 +4062,11 @@ function send_user_email_change_confirmation_process( $user_id, $email_hash ) { $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->signups} SET user_email = %s WHERE user_login = %s", $user->user_email, $the_user->user_login ) ); } - wp_update_user( $user ); + $updated = wp_update_user( $user ); + + if ( is_wp_error( $updated ) ) { + return false; + } delete_user_meta( $user->ID, '_new_email' ); From 02b2c050fe2037a4b81001f6fc53f80bbb9dc443 Mon Sep 17 00:00:00 2001 From: Shahjahan Jewel <1053500+techjewel@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:42:13 +0600 Subject: [PATCH 3/9] Users: Restore the profile screen behaviour lost in the refactor. The split dropped three things that `send_confirmation_on_profile_email()` did before it: * The `0 === $current_user->ID || $current_user->ID !== (int) $user_id` guard. Without it, `get_user_by( 'id', 0 )` returns `false` on a logged-out request and `$user->user_email` fatals on PHP 8. * Restoring `$_POST['email']` to `addslashes( $current_user->user_email )` when the address is rejected, added in 7.0.3, so a rejected change does not fall through to `wp_update_user()`. * Comparing the current and submitted addresses strictly. `send_user_email_change_confirmation_email()` now also returns early when it is not handed a user that exists, since it is a public function that callers other than the two in core may reach. See #57413. --- src/wp-includes/user.php | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php index 2541b7f12dd75..b20925ea067a2 100644 --- a/src/wp-includes/user.php +++ b/src/wp-includes/user.php @@ -3877,12 +3877,16 @@ function send_confirmation_on_profile_email( $user_id = 0 ) { $user_id = absint( $_POST['user_id'] ); } + $current_user = wp_get_current_user(); if ( ! is_object( $errors ) ) { $errors = new WP_Error(); } - $user = get_user_by( 'id', $user_id ); - $email_sent = send_user_email_change_confirmation_email( $user, $_POST['email'] ); + if ( 0 === $current_user->ID || $current_user->ID !== (int) $user_id ) { + return false; + } + + $email_sent = send_user_email_change_confirmation_email( $current_user, $_POST['email'] ); if ( is_wp_error( $email_sent ) ) { // WP_Error::copy_errors() with the addition of adding data. @@ -3895,10 +3899,13 @@ function send_confirmation_on_profile_email( $user_id = 0 ) { ) ); } + + $_POST['email'] = addslashes( $current_user->user_email ); + return; } if ( true === $email_sent ) { - $_POST['email'] = $user->user_email; + $_POST['email'] = $current_user->user_email; } } @@ -3912,7 +3919,11 @@ function send_confirmation_on_profile_email( $user_id = 0 ) { * @return null|true|WP_Error true if email sent, WP_Error on error, and null otherwise. */ function send_user_email_change_confirmation_email( $user, $email ) { - if ( $user->user_email == $email ) { + if ( ! $user instanceof WP_User || ! $user->exists() ) { + return; + } + + if ( $user->user_email === $email ) { return; } From 4076b3fcabcfd43d46133c0698b31dde93bf01f9 Mon Sep 17 00:00:00 2001 From: Shahjahan Jewel <1053500+techjewel@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:42:42 +0600 Subject: [PATCH 4/9] Users: Rename the confirmation processing function and fill in the @since tags. `send_user_email_change_confirmation_process()` does not send anything, so it is renamed to `confirm_user_email_change()`, which says what it does and matches the `confirmemail` action it backs. The `user_email_confirmed` action moves from `wp-login.php` into the function, so it fires for both confirmation routes rather than only the one. Previously a change confirmed from the profile screen fired nothing. Replaces the `@since x.x` placeholders with 7.2.0 and expands the function documentation. See #57413. --- src/wp-admin/user-edit.php | 2 +- src/wp-includes/user.php | 41 +++++++++++++++++++++++++++----------- src/wp-login.php | 12 +---------- 3 files changed, 31 insertions(+), 24 deletions(-) diff --git a/src/wp-admin/user-edit.php b/src/wp-admin/user-edit.php index 2e21f7377ceec..eda921f221cf0 100644 --- a/src/wp-admin/user-edit.php +++ b/src/wp-admin/user-edit.php @@ -106,7 +106,7 @@ // Execute confirmed email change. See send_confirmation_on_profile_email(). if ( IS_PROFILE_PAGE && isset( $_GET['newuseremail'] ) && $current_user->ID ) { - if ( send_user_email_change_confirmation_process( $current_user->ID, $_GET['newuseremail'] ) ) { + if ( confirm_user_email_change( $current_user->ID, $_GET['newuseremail'] ) ) { wp_redirect( add_query_arg( array( 'updated' => 'true' ), self_admin_url( 'profile.php' ) ) ); } else { wp_redirect( add_query_arg( array( 'error' => 'new-email' ), self_admin_url( 'profile.php' ) ) ); diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php index b20925ea067a2..2cee41ce862f5 100644 --- a/src/wp-includes/user.php +++ b/src/wp-includes/user.php @@ -3910,13 +3910,22 @@ function send_confirmation_on_profile_email( $user_id = 0 ) { } /** - * Send the 'confirm your email' email. + * Sends a confirmation request email when a change of user email address is attempted. + * + * The new address is held in the `_new_email` user meta until the user confirms it + * by following the link in the email. The address on the account is unchanged + * until then. + * + * A confirmation is only required when a user changes their own email address. An + * administrator changing somebody else's address is not asked to confirm it. * - * @since x.x + * @since 7.2.0 * - * @param WP_User $user The user to act upon. + * @param WP_User $user The user whose email address is being changed. * @param string $email The new email address. - * @return null|true|WP_Error true if email sent, WP_Error on error, and null otherwise. + * @return true|WP_Error|null True if a confirmation email was sent and the change should not be + * applied yet, WP_Error if the address was rejected, null if no + * confirmation is needed and the change may be applied. */ function send_user_email_change_confirmation_email( $user, $email ) { if ( ! $user instanceof WP_User || ! $user->exists() ) { @@ -3950,7 +3959,7 @@ function send_user_email_change_confirmation_email( $user, $email ) { * Filters whether a 'confirm your email address' email should be sent. * If false is returned, the change is made immediately. * - * @since x.x + * @since 7.2.0 * * @param bool $should_send_email_for_change Whether to use an email confirmation. * @param WP_User $user The user having their email changed. @@ -4036,18 +4045,17 @@ function send_user_email_change_confirmation_email( $user, $email ) { } /** - * Process the confirmation of an email change request. + * Applies a pending email address change once the user has confirmed it. * - * @since x.x + * @since 7.2.0 * * @global wpdb $wpdb WordPress database abstraction object. * - * @param int $user_id The User ID being acted upon. - * @param string $email_hash The email hash of the request - * - * @return bool Whether or not the change succeeded. + * @param int $user_id The ID of the user whose email address is being changed. + * @param string $email_hash The confirmation hash from the link in the confirmation email. + * @return bool Whether the email address was changed. */ -function send_user_email_change_confirmation_process( $user_id, $email_hash ) { +function confirm_user_email_change( $user_id, $email_hash ) { global $wpdb; $the_user = get_userdata( $user_id ); @@ -4081,6 +4089,15 @@ function send_user_email_change_confirmation_process( $user_id, $email_hash ) { delete_user_meta( $user->ID, '_new_email' ); + /** + * Fires after a user has confirmed a change to their email address. + * + * @since 7.2.0 + * + * @param int $user_id The ID of the user whose email address was changed. + */ + do_action( 'user_email_confirmed', $user->ID ); + return true; } diff --git a/src/wp-login.php b/src/wp-login.php index fd35c4e3106bf..e33503134451d 100644 --- a/src/wp-login.php +++ b/src/wp-login.php @@ -1255,20 +1255,10 @@ function wp_login_viewport_meta() { wp_die( __( 'Missing or invalid key.' ) ); } - $updated = send_user_email_change_confirmation_process( $user_id, $email_key ); - if ( ! $updated ) { + if ( ! confirm_user_email_change( $user_id, $email_key ) ) { wp_die( __( 'Missing or invalid key.' ) ); } - /** - * Fires an action hook when the account email has been confirmed by the user. - * - * @since x.x - * - * @param int $user_id User ID. - */ - do_action( 'user_email_confirmed', $user_id ); - login_header( __( 'Confirm your email' ), '

' . __( 'Your email has been confirmed.' ) . '

' From 0ee2e92783b0f737a6cf96c79c4dd1403bb67893 Mon Sep 17 00:00:00 2001 From: Shahjahan Jewel <1053500+techjewel@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:43:02 +0600 Subject: [PATCH 5/9] Users: Harden the confirmation route and report rejected addresses over REST. `WP_REST_Users_Controller::update_item()` discarded a `WP_Error` from `send_user_email_change_confirmation_email()` and carried on, which would apply an address the confirmation step had just rejected. It now returns a 400 using the `rest_user_invalid_email` code the endpoint already uses for a rejected address. The empty check also becomes `is_string()`, matching the `email_exists()` check earlier in the same method, so that a non-string `email` is not silently skipped. In `wp-login.php`, a confirmation link followed while logged out sent the user to a login form that then dropped them on the dashboard, losing the confirmation. The redirect now carries the confirmation URL through `redirect_to`. The user ID and hash are also cast and sanitized before use, and the route exits rather than breaking, matching the `confirmaction` route above it. See #57413. --- .../class-wp-rest-users-controller.php | 17 +++++++++++-- src/wp-login.php | 24 +++++++++++++------ 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php index d26861244a3d1..6f188550fab0d 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-users-controller.php @@ -806,9 +806,22 @@ public function update_item( $request ) { } } - // Maybe send the change email confirmation. - if ( ! empty( $request['email'] ) ) { + /* + * Ask a user to confirm a change to their own email address, rather than + * applying it immediately, as the profile screen does. The change is held + * in the `_new_email` user meta until it is confirmed. + */ + if ( is_string( $request['email'] ) && '' !== $request['email'] ) { $email_sent = send_user_email_change_confirmation_email( $user, $request['email'] ); + + if ( is_wp_error( $email_sent ) ) { + return new WP_Error( + 'rest_user_invalid_email', + __( 'Invalid email address.' ), + array( 'status' => 400 ) + ); + } + if ( true === $email_sent ) { unset( $request['email'] ); } diff --git a/src/wp-login.php b/src/wp-login.php index e33503134451d..a5e19c6f9a45a 100644 --- a/src/wp-login.php +++ b/src/wp-login.php @@ -1243,14 +1243,23 @@ function wp_login_viewport_meta() { wp_die( __( 'Missing or invalid key.' ) ); } + $user_id = (int) $_GET['id']; + $email_key = sanitize_text_field( wp_unslash( $_GET['hash'] ) ); + if ( ! is_user_logged_in() ) { - wp_safe_redirect( wp_login_url() ); + $confirm_url = add_query_arg( + array( + 'action' => 'confirmemail', + 'id' => $user_id, + 'hash' => rawurlencode( $email_key ), + ), + wp_login_url() + ); + + wp_safe_redirect( wp_login_url( $confirm_url ) ); exit; } - $user_id = wp_unslash( $_GET['id'] ); - $email_key = wp_unslash( $_GET['hash'] ); - if ( ! current_user_can( 'edit_user', $user_id ) ) { wp_die( __( 'Missing or invalid key.' ) ); } @@ -1260,11 +1269,12 @@ function wp_login_viewport_meta() { } login_header( - __( 'Confirm your email' ), - '

' . __( 'Your email has been confirmed.' ) . '

' + __( 'Email address confirmed.' ), + '

' . __( 'Your new email address has been confirmed.' ) . '

' ); + login_footer(); - break; + exit; case 'confirmaction': if ( ! isset( $_GET['request_id'] ) ) { From 40a0ae1aa82580a200d41530514d66e4d8a3f430 Mon Sep 17 00:00:00 2001 From: Shahjahan Jewel <1053500+techjewel@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:44:00 +0600 Subject: [PATCH 6/9] Tests: Cover the email change confirmation functions directly. Adds the unit tests asked for in the PR, for the two functions the split introduced rather than only for the callers: * `send_user_email_change_confirmation_email()`: an unchanged address, an invalid address, an address already in use (and that it discards an earlier pending change), storing the pending change without touching the account, skipping the confirmation for an administrator editing another user, and the `should_send_email_for_email_change` filter, including the arguments it is passed. * `confirm_user_email_change()`: applying a pending change and firing `user_email_confirmed`, rejecting a wrong hash while leaving the pending change in place, no pending change, and an unknown user. Also covers the REST endpoint holding the change while applying other fields in the same request, an unchanged address, and the filter turning the confirmation off. See #57413. --- .../tests/rest-api/rest-users-controller.php | 150 +++++++++++ tests/phpunit/tests/user.php | 235 ++++++++++++++++++ 2 files changed, 385 insertions(+) diff --git a/tests/phpunit/tests/rest-api/rest-users-controller.php b/tests/phpunit/tests/rest-api/rest-users-controller.php index 18e4c47a9c0ee..ff45aab51d0b0 100644 --- a/tests/phpunit/tests/rest-api/rest-users-controller.php +++ b/tests/phpunit/tests/rest-api/rest-users-controller.php @@ -3196,6 +3196,156 @@ public function test_no_confirmation_on_profile_email_by_admin() { $this->assertEmpty( $new_email_meta ); } + /** + * A user changing their own email address over REST is asked to confirm it, + * the same as on the profile screen. + * + * @ticket 57413 + */ + public function test_update_item_own_email_requires_confirmation() { + reset_phpmailer_instance(); + + $user_id = self::factory()->user->create( + array( + 'role' => 'subscriber', + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/users/%d', $user_id ) ); + $request->set_param( 'email', 'after@example.com' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + // The address on the account is unchanged until the user confirms it. + $data = $response->get_data(); + $this->assertSame( 'before@example.com', $data['email'] ); + $this->assertSame( 'before@example.com', get_userdata( $user_id )->user_email ); + + // The change is held in user meta. + $new_email_meta = get_user_meta( $user_id, '_new_email', true ); + $this->assertSame( 'after@example.com', $new_email_meta['newemail'] ); + + // A confirmation email is sent to the new address. + $mailer = tests_retrieve_phpmailer_instance(); + $this->assertSame( 'after@example.com', $mailer->get_recipient( 'to' )->address ); + } + + /** + * Other fields in the same request are still applied while the email change is pending. + * + * @ticket 57413 + */ + public function test_update_item_applies_other_fields_while_email_is_pending() { + reset_phpmailer_instance(); + + $user_id = self::factory()->user->create( + array( + 'role' => 'subscriber', + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/users/%d', $user_id ) ); + $request->set_param( 'email', 'after@example.com' ); + $request->set_param( 'first_name', 'Updated' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'Updated', get_user_meta( $user_id, 'first_name', true ) ); + $this->assertSame( 'before@example.com', get_userdata( $user_id )->user_email ); + } + + /** + * An administrator changing somebody else's address is not asked to confirm it. + * + * @ticket 57413 + */ + public function test_update_item_other_user_email_is_applied_immediately() { + reset_phpmailer_instance(); + + $user_id = self::factory()->user->create( + array( + 'role' => 'subscriber', + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( self::$superadmin ); + + $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/users/%d', $user_id ) ); + $request->set_param( 'email', 'after@example.com' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertSame( 'after@example.com', $data['email'] ); + $this->assertSame( 'after@example.com', get_userdata( $user_id )->user_email ); + $this->assertEmpty( get_user_meta( $user_id, '_new_email', true ) ); + $this->assertEmpty( tests_retrieve_phpmailer_instance()->mock_sent ); + } + + /** + * Sending the address the account already has is not a change, so it is not held. + * + * @ticket 57413 + */ + public function test_update_item_unchanged_email_does_not_require_confirmation() { + reset_phpmailer_instance(); + + $user_id = self::factory()->user->create( + array( + 'role' => 'subscriber', + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( $user_id ); + + $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/users/%d', $user_id ) ); + $request->set_param( 'email', 'before@example.com' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertEmpty( get_user_meta( $user_id, '_new_email', true ) ); + $this->assertEmpty( tests_retrieve_phpmailer_instance()->mock_sent ); + } + + /** + * The confirmation can be turned off, for sites that manage addresses elsewhere. + * + * @ticket 57413 + */ + public function test_update_item_own_email_confirmation_can_be_filtered_off() { + reset_phpmailer_instance(); + + $user_id = self::factory()->user->create( + array( + 'role' => 'subscriber', + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( $user_id ); + + add_filter( 'should_send_email_for_email_change', '__return_false' ); + + $request = new WP_REST_Request( 'PUT', sprintf( '/wp/v2/users/%d', $user_id ) ); + $request->set_param( 'email', 'after@example.com' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'after@example.com', get_userdata( $user_id )->user_email ); + $this->assertEmpty( get_user_meta( $user_id, '_new_email', true ) ); + $this->assertEmpty( tests_retrieve_phpmailer_instance()->mock_sent ); + } + public function data_get_default_data() { return array( array( diff --git a/tests/phpunit/tests/user.php b/tests/phpunit/tests/user.php index 36f776b2d10fb..b04f8d48f3fc9 100644 --- a/tests/phpunit/tests/user.php +++ b/tests/phpunit/tests/user.php @@ -2353,6 +2353,241 @@ public function test_no_confirmation_on_profile_email_by_admin() { $this->assertSame( 'after@example.com', $_POST['email'] ); } + /** + * @ticket 57413 + * + * @covers ::send_user_email_change_confirmation_email + */ + public function test_send_user_email_change_confirmation_email_returns_null_when_email_is_unchanged() { + $user = self::factory()->user->create_and_get( + array( + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( $user->ID ); + + $this->assertNull( send_user_email_change_confirmation_email( $user, 'before@example.com' ) ); + $this->assertEmpty( get_user_meta( $user->ID, '_new_email', true ) ); + } + + /** + * @ticket 57413 + * + * @covers ::send_user_email_change_confirmation_email + */ + public function test_send_user_email_change_confirmation_email_rejects_an_invalid_email() { + $user = self::factory()->user->create_and_get( + array( + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( $user->ID ); + + $result = send_user_email_change_confirmation_email( $user, 'not-an-email' ); + + $this->assertWPError( $result, 'An invalid address should return a WP_Error.' ); + $this->assertSame( 'user_email', $result->get_error_code() ); + $this->assertEmpty( get_user_meta( $user->ID, '_new_email', true ), 'No change should be left pending.' ); + } + + /** + * @ticket 57413 + * + * @covers ::send_user_email_change_confirmation_email + */ + public function test_send_user_email_change_confirmation_email_rejects_an_address_in_use() { + self::factory()->user->create( array( 'user_email' => 'taken@example.com' ) ); + + $user = self::factory()->user->create_and_get( + array( + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( $user->ID ); + + // A pending change from an earlier request should not survive a rejection. + update_user_meta( + $user->ID, + '_new_email', + array( + 'hash' => 'stalehash', + 'newemail' => 'pending@example.com', + ) + ); + + $result = send_user_email_change_confirmation_email( $user, 'taken@example.com' ); + + $this->assertWPError( $result, 'An address already in use should return a WP_Error.' ); + $this->assertSame( 'user_email', $result->get_error_code() ); + $this->assertEmpty( get_user_meta( $user->ID, '_new_email', true ), 'The pending change should be discarded.' ); + } + + /** + * @ticket 57413 + * + * @covers ::send_user_email_change_confirmation_email + */ + public function test_send_user_email_change_confirmation_email_stores_the_pending_change() { + reset_phpmailer_instance(); + + $user = self::factory()->user->create_and_get( + array( + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( $user->ID ); + + $this->assertTrue( send_user_email_change_confirmation_email( $user, 'after@example.com' ) ); + + $new_email_meta = get_user_meta( $user->ID, '_new_email', true ); + $this->assertSame( 'after@example.com', $new_email_meta['newemail'] ); + $this->assertNotEmpty( $new_email_meta['hash'] ); + + // The address on the account is untouched until the change is confirmed. + $this->assertSame( 'before@example.com', get_userdata( $user->ID )->user_email ); + + $mailer = tests_retrieve_phpmailer_instance(); + $this->assertSame( 'after@example.com', $mailer->get_recipient( 'to' )->address ); + $this->assertStringContainsString( 'action=confirmemail', $mailer->get_sent()->body ); + } + + /** + * An administrator changing somebody else's address is not asked to confirm it. + * + * @ticket 57413 + * + * @covers ::send_user_email_change_confirmation_email + */ + public function test_send_user_email_change_confirmation_email_skips_confirmation_for_another_user() { + reset_phpmailer_instance(); + + $user = self::factory()->user->create_and_get( + array( + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( self::$admin_id ); + + $this->assertNull( send_user_email_change_confirmation_email( $user, 'after@example.com' ) ); + $this->assertEmpty( get_user_meta( $user->ID, '_new_email', true ) ); + $this->assertEmpty( tests_retrieve_phpmailer_instance()->mock_sent ); + } + + /** + * @ticket 57413 + * + * @covers ::send_user_email_change_confirmation_email + */ + public function test_should_send_email_for_email_change_filter_can_skip_the_confirmation() { + reset_phpmailer_instance(); + + $user = self::factory()->user->create_and_get( + array( + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( $user->ID ); + + $filter_args = array(); + + add_filter( + 'should_send_email_for_email_change', + static function ( $should_send, $filtered_user, $email ) use ( &$filter_args ) { + $filter_args = array( $should_send, $filtered_user, $email ); + return false; + }, + 10, + 3 + ); + + $this->assertNull( send_user_email_change_confirmation_email( $user, 'after@example.com' ) ); + $this->assertEmpty( get_user_meta( $user->ID, '_new_email', true ) ); + $this->assertEmpty( tests_retrieve_phpmailer_instance()->mock_sent ); + + $this->assertTrue( $filter_args[0], 'A user changing their own address should default to requiring confirmation.' ); + $this->assertSame( $user->ID, $filter_args[1]->ID ); + $this->assertSame( 'after@example.com', $filter_args[2] ); + } + + /** + * @ticket 57413 + * + * @covers ::confirm_user_email_change + */ + public function test_confirm_user_email_change_applies_the_pending_change() { + $user = self::factory()->user->create_and_get( + array( + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( $user->ID ); + + send_user_email_change_confirmation_email( $user, 'after@example.com' ); + + $new_email_meta = get_user_meta( $user->ID, '_new_email', true ); + + $action = new MockAction(); + add_action( 'user_email_confirmed', array( $action, 'action' ) ); + + $this->assertTrue( confirm_user_email_change( $user->ID, $new_email_meta['hash'] ) ); + $this->assertSame( 'after@example.com', get_userdata( $user->ID )->user_email ); + $this->assertEmpty( get_user_meta( $user->ID, '_new_email', true ), 'The pending change should be cleared.' ); + $this->assertSame( 1, $action->get_call_count() ); + } + + /** + * @ticket 57413 + * + * @covers ::confirm_user_email_change + */ + public function test_confirm_user_email_change_rejects_an_invalid_hash() { + $user = self::factory()->user->create_and_get( + array( + 'user_email' => 'before@example.com', + ) + ); + + wp_set_current_user( $user->ID ); + + send_user_email_change_confirmation_email( $user, 'after@example.com' ); + + $this->assertFalse( confirm_user_email_change( $user->ID, 'not-the-hash' ) ); + $this->assertSame( 'before@example.com', get_userdata( $user->ID )->user_email ); + $this->assertNotEmpty( get_user_meta( $user->ID, '_new_email', true ), 'The pending change should survive a failed attempt.' ); + } + + /** + * @ticket 57413 + * + * @covers ::confirm_user_email_change + */ + public function test_confirm_user_email_change_returns_false_without_a_pending_change() { + $user = self::factory()->user->create_and_get( + array( + 'user_email' => 'before@example.com', + ) + ); + + $this->assertFalse( confirm_user_email_change( $user->ID, 'any-hash' ) ); + $this->assertSame( 'before@example.com', get_userdata( $user->ID )->user_email ); + } + + /** + * @ticket 57413 + * + * @covers ::confirm_user_email_change + */ + public function test_confirm_user_email_change_returns_false_for_an_unknown_user() { + $this->assertFalse( confirm_user_email_change( 0, 'any-hash' ) ); + } + /** * Ensure user email address change confirmation emails do not contain encoded HTML entities * From bef3d09c4053d5b1d13befca9b5ccf43d4e1d3f4 Mon Sep 17 00:00:00 2001 From: Shahjahan Jewel <1053500+techjewel@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:48:15 +0600 Subject: [PATCH 7/9] Tests: Assert on the confirmation email rather than on all outgoing mail. wp_update_user() mails a "Notice of Email Change" to the old address on every change, so asserting that no mail at all was sent failed whenever the change was applied immediately. The assertion is now that nothing was sent to the new address asking for confirmation. See #57413. --- .../tests/rest-api/rest-users-controller.php | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/tests/phpunit/tests/rest-api/rest-users-controller.php b/tests/phpunit/tests/rest-api/rest-users-controller.php index ff45aab51d0b0..e9766ff3b634a 100644 --- a/tests/phpunit/tests/rest-api/rest-users-controller.php +++ b/tests/phpunit/tests/rest-api/rest-users-controller.php @@ -3288,7 +3288,13 @@ public function test_update_item_other_user_email_is_applied_immediately() { $this->assertSame( 'after@example.com', $data['email'] ); $this->assertSame( 'after@example.com', get_userdata( $user_id )->user_email ); $this->assertEmpty( get_user_meta( $user_id, '_new_email', true ) ); - $this->assertEmpty( tests_retrieve_phpmailer_instance()->mock_sent ); + + /* + * wp_update_user() mails a "Notice of Email Change" to the old address on every + * change, so the assertion is that nothing was sent to the new address asking + * for confirmation, not that no mail was sent at all. + */ + $this->assertFalse( $this->was_mail_sent_to( 'after@example.com' ) ); } /** @@ -3343,7 +3349,25 @@ public function test_update_item_own_email_confirmation_can_be_filtered_off() { $this->assertSame( 200, $response->get_status() ); $this->assertSame( 'after@example.com', get_userdata( $user_id )->user_email ); $this->assertEmpty( get_user_meta( $user_id, '_new_email', true ) ); - $this->assertEmpty( tests_retrieve_phpmailer_instance()->mock_sent ); + $this->assertFalse( $this->was_mail_sent_to( 'after@example.com' ) ); + } + + /** + * Whether any mail was sent to the given address. + * + * @since 7.2.0 + * + * @param string $address The address to look for. + * @return bool Whether a message was sent to the address. + */ + protected function was_mail_sent_to( $address ) { + foreach ( tests_retrieve_phpmailer_instance()->mock_sent as $mail ) { + if ( isset( $mail['to'][0][0] ) && $address === $mail['to'][0][0] ) { + return true; + } + } + + return false; } public function data_get_default_data() { From 97be456fd494c47879a61f030c8eed31e4ca62dc Mon Sep 17 00:00:00 2001 From: Shahjahan Jewel <1053500+techjewel@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:50:09 +0600 Subject: [PATCH 8/9] Users: Do not treat a change of case as a change of address. `email_exists()` is not case sensitive, so routing the profile screen and the REST API through the same check made correcting the case of your own address fail: on REST as a 400 from `rest_user_invalid_email`, breaking the behaviour added in #44672, and on the profile screen as "The email address is already used." A change of case is the same mailbox, so it is now not a change that needs confirming and is applied directly. This matches `wp_update_user()`, which guards its own `email_exists()` check with the same `strcasecmp()` comparison. See #57413, #44672. --- src/wp-includes/user.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php index 2cee41ce862f5..a37591971ddda 100644 --- a/src/wp-includes/user.php +++ b/src/wp-includes/user.php @@ -3932,7 +3932,12 @@ function send_user_email_change_confirmation_email( $user, $email ) { return; } - if ( $user->user_email === $email ) { + /* + * Correcting the case of an address is the same mailbox, so it is not a change + * that needs confirming, and `email_exists()` below would read it as an address + * that is already in use. See #44672. + */ + if ( 0 === strcasecmp( $user->user_email, $email ) ) { return; } From 5c7eaa10d63d996757d610ac23a264db7c03ff32 Mon Sep 17 00:00:00 2001 From: Shahjahan Jewel <1053500+techjewel@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:00:22 +0600 Subject: [PATCH 9/9] Users: Return null explicitly and update the PHPStan baselines. `send_user_email_change_confirmation_email()` is documented as returning `true|WP_Error|null`, but used bare `return;` statements, which PHPStan reports as `return.empty` and, separately, as the null in the return type never being used. Returning `null` explicitly says what the function means and keeps both out of the baselines, which exist to be emptied rather than added to. Moving the Multisite branch out of `wp-admin/user-edit.php` also halves the baselined `variable.undefined` count for `$wpdb` in that file, from 12 to 6. That entry is what the missing `global $wpdb;` in the moved code would have been reported as, had the new function not been exempt by being new. See #57413. --- src/wp-includes/user.php | 6 +++--- tests/phpstan/baselines/variable.undefined.neon | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php index a37591971ddda..3bd3b24db4bfd 100644 --- a/src/wp-includes/user.php +++ b/src/wp-includes/user.php @@ -3929,7 +3929,7 @@ function send_confirmation_on_profile_email( $user_id = 0 ) { */ function send_user_email_change_confirmation_email( $user, $email ) { if ( ! $user instanceof WP_User || ! $user->exists() ) { - return; + return null; } /* @@ -3938,7 +3938,7 @@ function send_user_email_change_confirmation_email( $user, $email ) { * that is already in use. See #44672. */ if ( 0 === strcasecmp( $user->user_email, $email ) ) { - return; + return null; } if ( ! is_email( $email ) ) { @@ -3973,7 +3973,7 @@ function send_user_email_change_confirmation_email( $user, $email ) { $should_send_email_for_change = apply_filters( 'should_send_email_for_email_change', $should_send_email_for_change, $user, $email ); if ( ! $should_send_email_for_change ) { - return; + return null; } $hash = md5( $email . time() . wp_rand() ); diff --git a/tests/phpstan/baselines/variable.undefined.neon b/tests/phpstan/baselines/variable.undefined.neon index 91c668011373e..9dfbbcd30a34f 100644 --- a/tests/phpstan/baselines/variable.undefined.neon +++ b/tests/phpstan/baselines/variable.undefined.neon @@ -536,7 +536,7 @@ parameters: - message: '#^Variable \$wpdb might not be defined\.$#' identifier: variable.undefined - count: 12 + count: 6 path: ../../../src/wp-admin/user-edit.php - message: '#^Variable \$blog_id might not be defined\.$#'