Skip to content

Commit ce6041c

Browse files
committed
Users: Treat email addresses that differ only in letter case as the same.
`email_exists()` now matches email addresses case-insensitively regardless of the database collation, so a second account cannot be created with an address that only differs in letter case (for example `abc@example.com` and `ABc@example.com`). With the default case-insensitive collation of the users table, the existing lookup already ignores letter case and uses the `user_email` index, so no extra query is made. The `LOWER()` fallback, which cannot use the index, only runs when the `user_email` column has a case-sensitive collation or the collation cannot be determined. This is detected once per request by the new private `_wp_is_user_email_case_sensitive()` function, and can be overridden with the `wp_is_user_email_case_sensitive` filter. The Users list table also flags users on the current page whose email address is shared with another account, ignoring letter case, so that existing duplicates can be found and cleaned up. The lookup only uses the addresses on the current page and is index-friendly on case-insensitive collations. See #66238.
1 parent ec18c2d commit ce6041c

5 files changed

Lines changed: 353 additions & 0 deletions

File tree

‎src/wp-admin/css/list-tables.css‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -870,6 +870,18 @@ p.pagenav {
870870
margin-top: 1px;
871871
}
872872

873+
.column-email .duplicate-email {
874+
margin: 0.3em 0 0;
875+
color: #b32d2e;
876+
}
877+
878+
.column-email .duplicate-email .dashicons {
879+
font-size: 16px;
880+
width: 16px;
881+
height: 16px;
882+
vertical-align: text-bottom;
883+
}
884+
873885
.row-actions {
874886
color: #646970;
875887
font-size: 13px;

‎src/wp-admin/includes/class-wp-users-list-table.php‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,15 @@ class WP_Users_List_Table extends WP_List_Table {
3232
*/
3333
public $is_site_users;
3434

35+
/**
36+
* IDs of users on the current page whose email address is also used by another
37+
* user, ignoring letter case. Keys are user IDs.
38+
*
39+
* @since 7.2.0
40+
* @var true[]
41+
*/
42+
protected $duplicate_email_user_ids = array();
43+
3544
/**
3645
* Constructor.
3746
*
@@ -411,11 +420,77 @@ public function display_rows() {
411420
$post_counts = count_many_users_posts( array_keys( $this->items ) );
412421
}
413422

423+
$this->duplicate_email_user_ids = $this->get_duplicate_email_user_ids( array_keys( $this->items ) );
424+
414425
foreach ( $this->items as $userid => $user_object ) {
415426
echo "\n\t" . $this->single_row( $user_object, '', '', isset( $post_counts ) ? $post_counts[ $userid ] : 0 );
416427
}
417428
}
418429

430+
/**
431+
* Finds which of the given users share an email address with another user,
432+
* ignoring letter case.
433+
*
434+
* Many mailbox providers treat `abc@example.com` and `ABc@example.com` as the
435+
* same mailbox, so such accounts are likely duplicates.
436+
*
437+
* @since 7.2.0
438+
*
439+
* @global wpdb $wpdb WordPress database abstraction object.
440+
*
441+
* @param int[] $user_ids IDs of the users to check.
442+
* @return true[] Array keyed by the IDs of users whose email address is shared.
443+
*/
444+
protected function get_duplicate_email_user_ids( $user_ids ) {
445+
global $wpdb;
446+
447+
$user_ids = array_filter( array_map( 'intval', $user_ids ) );
448+
if ( empty( $user_ids ) ) {
449+
return array();
450+
}
451+
452+
$emails = array();
453+
foreach ( $user_ids as $user_id ) {
454+
$user = get_userdata( $user_id );
455+
if ( $user && '' !== $user->user_email ) {
456+
$emails[ strtolower( $user->user_email ) ] = true;
457+
}
458+
}
459+
460+
if ( empty( $emails ) ) {
461+
return array();
462+
}
463+
464+
$emails = array_keys( $emails );
465+
$placeholders = implode( ',', array_fill( 0, count( $emails ), '%s' ) );
466+
467+
/*
468+
* All users whose email matches, ignoring case, the email of a user on this page.
469+
* With the default case-insensitive collation a plain comparison already ignores
470+
* letter case and can use the user_email index; LOWER() is only needed otherwise.
471+
*/
472+
$email_column = _wp_is_user_email_case_sensitive() ? 'LOWER(user_email)' : 'user_email';
473+
474+
$matches = $wpdb->get_results(
475+
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
476+
$wpdb->prepare( "SELECT ID, user_email FROM $wpdb->users WHERE $email_column IN ($placeholders)", $emails )
477+
);
478+
479+
$users_by_email = array();
480+
foreach ( $matches as $match ) {
481+
$users_by_email[ strtolower( $match->user_email ) ][] = (int) $match->ID;
482+
}
483+
484+
$duplicates = array();
485+
foreach ( $users_by_email as $ids ) {
486+
if ( count( $ids ) > 1 ) {
487+
$duplicates += array_fill_keys( $ids, true );
488+
}
489+
}
490+
491+
return array_intersect_key( $duplicates, array_flip( $user_ids ) );
492+
}
493+
419494
/**
420495
* Generates HTML for a single row on the users.php admin panel.
421496
*
@@ -599,6 +674,12 @@ public function single_row( $user_object, $style = '', $role = '', $numposts = 0
599674
break;
600675
case 'email':
601676
$row .= "<a href='" . esc_url( "mailto:$email" ) . "'>$email</a>";
677+
if ( isset( $this->duplicate_email_user_ids[ $user_object->ID ] ) ) {
678+
$row .= sprintf(
679+
'<p class="duplicate-email"><span class="dashicons dashicons-warning" aria-hidden="true"></span> %s</p>',
680+
__( 'Another user has this email address, possibly with different letter case.' )
681+
);
682+
}
602683
break;
603684
case 'role':
604685
$row .= esc_html( $roles_list );

‎src/wp-includes/user.php‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2132,16 +2132,42 @@ function username_exists( $username ) {
21322132
* Conditional Tags} article in the Theme Developer Handbook.
21332133
*
21342134
* @since 2.1.0
2135+
* @since 7.2.0 The comparison is case-insensitive regardless of the database collation.
2136+
*
2137+
* @global wpdb $wpdb WordPress database abstraction object.
21352138
*
21362139
* @param string $email The email to check for existence.
21372140
* @return int|false The user ID on success, false on failure.
21382141
*/
21392142
function email_exists( $email ) {
2143+
global $wpdb;
2144+
21402145
$user = get_user_by( 'email', $email );
21412146
if ( $user ) {
21422147
$user_id = $user->ID;
21432148
} else {
21442149
$user_id = false;
2150+
2151+
/*
2152+
* Most mailbox providers treat the local part of an address as case-insensitive.
2153+
* The default users table collation already compares emails that way, so the
2154+
* lookup above has covered it. Only when the column compares case-sensitively
2155+
* (or the collation is unknown) fall back to an explicit case-insensitive lookup.
2156+
* That lookup cannot use the user_email index, so it is avoided where possible.
2157+
*/
2158+
$trimmed_email = trim( (string) $email );
2159+
if ( '' !== $trimmed_email && _wp_is_user_email_case_sensitive() ) {
2160+
$found_id = $wpdb->get_var(
2161+
$wpdb->prepare(
2162+
"SELECT ID FROM $wpdb->users WHERE LOWER(user_email) = LOWER(%s) LIMIT 1",
2163+
$trimmed_email
2164+
)
2165+
);
2166+
2167+
if ( $found_id ) {
2168+
$user_id = (int) $found_id;
2169+
}
2170+
}
21452171
}
21462172

21472173
/**
@@ -2156,6 +2182,56 @@ function email_exists( $email ) {
21562182
return apply_filters( 'email_exists', $user_id, $email );
21572183
}
21582184

2185+
/**
2186+
* Determines whether the database compares user email addresses case-sensitively.
2187+
*
2188+
* The default collation of the users table is case-insensitive, in which case
2189+
* a plain comparison already matches email addresses that differ only in letter
2190+
* case, and can use the user_email index. Case-sensitive collations such as
2191+
* `*_bin` or `*_cs` need an explicit LOWER() comparison instead.
2192+
*
2193+
* When the collation cannot be determined, for example on a non-MySQL database,
2194+
* the comparison is assumed to be case-sensitive.
2195+
*
2196+
* @since 7.2.0
2197+
* @access private
2198+
*
2199+
* @global wpdb $wpdb WordPress database abstraction object.
2200+
*
2201+
* @return bool True if user email comparisons are case-sensitive, false otherwise.
2202+
*/
2203+
function _wp_is_user_email_case_sensitive() {
2204+
global $wpdb;
2205+
2206+
static $is_case_sensitive = array();
2207+
2208+
if ( ! isset( $is_case_sensitive[ $wpdb->users ] ) ) {
2209+
$result = true;
2210+
2211+
if ( $wpdb->is_mysql ) {
2212+
$column = $wpdb->get_row( "SHOW FULL COLUMNS FROM $wpdb->users LIKE 'user_email'" );
2213+
2214+
if ( $column && ! empty( $column->Collation ) ) {
2215+
$result = ! str_ends_with( strtolower( $column->Collation ), '_ci' );
2216+
}
2217+
}
2218+
2219+
$is_case_sensitive[ $wpdb->users ] = $result;
2220+
}
2221+
2222+
/**
2223+
* Filters whether user email comparisons are case-sensitive in the database.
2224+
*
2225+
* When true, email lookups that need to ignore letter case use LOWER(), which
2226+
* cannot use the user_email index.
2227+
*
2228+
* @since 7.2.0
2229+
*
2230+
* @param bool $is_case_sensitive Whether the user_email column compares case-sensitively.
2231+
*/
2232+
return (bool) apply_filters( 'wp_is_user_email_case_sensitive', $is_case_sensitive[ $wpdb->users ] );
2233+
}
2234+
21592235
/**
21602236
* Checks whether a username is valid.
21612237
*

‎tests/phpunit/tests/admin/wpUsersListTable.php‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,92 @@ public function test_get_views_should_return_views_by_default() {
3030

3131
$this->assertSame( $expected, $this->table->get_views() );
3232
}
33+
34+
/**
35+
* @ticket 66238
36+
*
37+
* @covers WP_Users_List_Table::get_duplicate_email_user_ids
38+
*
39+
* @dataProvider data_email_case_sensitivity
40+
*
41+
* @param bool $is_case_sensitive Whether the database compares user emails case-sensitively.
42+
*/
43+
public function test_get_duplicate_email_user_ids_flags_case_variants( $is_case_sensitive ) {
44+
global $wpdb;
45+
46+
$original = self::factory()->user->create( array( 'user_email' => 'abc@example.com' ) );
47+
$variant = self::factory()->user->create( array( 'user_email' => 'placeholder@example.com' ) );
48+
$unique = self::factory()->user->create( array( 'user_email' => 'unique@example.com' ) );
49+
50+
// Duplicates can no longer be created through the API, so simulate legacy data.
51+
$wpdb->update( $wpdb->users, array( 'user_email' => 'ABc@example.com' ), array( 'ID' => $variant ) );
52+
clean_user_cache( $variant );
53+
54+
add_filter( 'wp_is_user_email_case_sensitive', $is_case_sensitive ? '__return_true' : '__return_false' );
55+
56+
$method = new ReflectionMethod( $this->table, 'get_duplicate_email_user_ids' );
57+
if ( PHP_VERSION_ID < 80100 ) {
58+
$method->setAccessible( true );
59+
}
60+
61+
$queries = array();
62+
$collect = static function ( $query ) use ( &$queries ) {
63+
$queries[] = $query;
64+
return $query;
65+
};
66+
add_filter( 'query', $collect );
67+
68+
$flagged = $method->invoke( $this->table, array( $original, $unique ) );
69+
70+
remove_filter( 'query', $collect );
71+
72+
$this->assertSame( array( $original ), array_keys( $flagged ), 'Only the user sharing an address should be flagged, even when the other account is not on the page.' );
73+
74+
$lower_queries = preg_grep( '/LOWER\(user_email\)/', $queries );
75+
if ( $is_case_sensitive ) {
76+
$this->assertNotEmpty( $lower_queries, 'A case-sensitive collation should use a LOWER() comparison.' );
77+
} else {
78+
$this->assertEmpty( $lower_queries, 'A case-insensitive collation should not use LOWER(), so that the index can be used.' );
79+
}
80+
}
81+
82+
/**
83+
* Data provider.
84+
*
85+
* @return array[]
86+
*/
87+
public function data_email_case_sensitivity() {
88+
return array(
89+
'case-insensitive collation' => array( false ),
90+
'case-sensitive collation' => array( true ),
91+
);
92+
}
93+
94+
/**
95+
* @ticket 66238
96+
*
97+
* @covers WP_Users_List_Table::display_rows
98+
*/
99+
public function test_display_rows_highlights_duplicate_email() {
100+
global $wpdb;
101+
102+
$original = self::factory()->user->create( array( 'user_email' => 'abc@example.com' ) );
103+
$variant = self::factory()->user->create( array( 'user_email' => 'placeholder@example.com' ) );
104+
$unique = self::factory()->user->create( array( 'user_email' => 'unique@example.com' ) );
105+
106+
$wpdb->update( $wpdb->users, array( 'user_email' => 'ABc@example.com' ), array( 'ID' => $variant ) );
107+
clean_user_cache( $variant );
108+
109+
$this->table->items = array(
110+
$original => get_userdata( $original ),
111+
$unique => get_userdata( $unique ),
112+
);
113+
114+
ob_start();
115+
$this->table->display_rows();
116+
$output = ob_get_clean();
117+
118+
$this->assertSame( 1, substr_count( $output, 'class="duplicate-email"' ), 'Exactly one row should be highlighted.' );
119+
$this->assertMatchesRegularExpression( "#<tr id='user-{$original}'.*?class=\"duplicate-email\".*?</tr>#s", $output, 'The duplicate user row should be highlighted.' );
120+
}
33121
}

0 commit comments

Comments
 (0)