diff --git a/Languages/en_US/General.php b/Languages/en_US/General.php index 3a55b5b0954..139f47cb406 100644 --- a/Languages/en_US/General.php +++ b/Languages/en_US/General.php @@ -748,7 +748,7 @@ $txt['smtp_port_ssl'] = 'SMTP port setting incorrect; it should be 465 for SSL servers. Hostname may need ssl:// prefix.'; $txt['smtp_bad_response'] = 'Could not get mail server response codes'; $txt['smtp_error'] = 'Ran into problems sending mail. Error: {0}'; -$txt['mail_send_unable'] = 'Unable to send mail to the email address {0}'; +$txt['mail_send_unable'] = 'Unable to send mail to the email address "{0}"'; $txt['mlist_search'] = 'Search for Members'; $txt['mlist_search_again'] = 'Search again'; diff --git a/Sources/Actions/Activate.php b/Sources/Actions/Activate.php index 3d5479f02d5..ba946f31a58 100644 --- a/Sources/Actions/Activate.php +++ b/Sources/Actions/Activate.php @@ -20,6 +20,7 @@ use SMF\ActionTrait; use SMF\Config; use SMF\Db\DatabaseApi as Db; +use SMF\EmailAddress; use SMF\ErrorHandler; use SMF\IntegrationHook; use SMF\Lang; @@ -309,7 +310,9 @@ protected function updateEmail(): void ErrorHandler::fatalLang('no_access', false); } - if (!filter_var($_POST['new_email'], FILTER_VALIDATE_EMAIL)) { + $email = new EmailAddress($_POST['new_email'], true); + + if (!$email->isValid()) { ErrorHandler::fatal(Lang::getTxt('valid_email_needed', ['email' => Utils::htmlspecialchars($_POST['new_email'])], file: 'Login'), false); } @@ -317,10 +320,10 @@ protected function updateEmail(): void $request = Db::$db->query( 'SELECT id_member FROM {db_prefix}members - WHERE email_address = {string:email_address} + WHERE email_address_ci = {string:email_address} LIMIT 1', [ - 'email_address' => $_POST['new_email'], + 'email_address' => $email->casefolded(), ], ); @@ -330,7 +333,7 @@ protected function updateEmail(): void Db::$db->free_result($request); // Set the new email address. - $this->member->email = $_POST['new_email']; + $this->member->email = (string) $email; // Make sure their email isn't banned. $bans = Security::checkBans($this->member, true); diff --git a/Sources/Actions/Admin/Bans.php b/Sources/Actions/Admin/Bans.php index eb20852c6f1..437c812f22d 100644 --- a/Sources/Actions/Admin/Bans.php +++ b/Sources/Actions/Admin/Bans.php @@ -23,6 +23,7 @@ use SMF\ActionTrait; use SMF\Config; use SMF\Db\DatabaseApi as Db; +use SMF\EmailAddress; use SMF\ErrorHandler; use SMF\IntegrationHook; use SMF\IP; @@ -496,7 +497,7 @@ public function edit(): void // Overwrite some of the default form values if a user ID was given. if (!empty($_REQUEST['u'])) { $request = Db::$db->query( - 'SELECT id_member, real_name, member_ip, email_address + 'SELECT id_member, real_name, member_ip, email_address_ci FROM {db_prefix}members WHERE id_member = {int:current_user} LIMIT 1', @@ -552,6 +553,12 @@ public function edit(): void list(Utils::$context['ban_suggestions']['member']['name'], Utils::$context['ban_suggestions']['main_ip'], Utils::$context['ban_suggestions']['email']) = Db::$db->fetch_row($request); Utils::$context['ban_suggestions']['main_ip'] = new IP(Utils::$context['ban_suggestions']['main_ip']); + + $email = new EmailAddress(Utils::$context['ban_suggestions']['email']); + + if ($email->isValid()) { + Utils::$context['ban_suggestions']['email'] = $email->casefolded(); + } } Db::$db->free_result($request); @@ -1060,14 +1067,14 @@ public static function updateBanMembers(): void } if (!empty($memberEmails)) { - $queryPart[] = 'mem.email_address IN ({array_string:member_emails})'; + $queryPart[] = 'mem.email_address_ci IN ({array_string:member_emails})'; $queryValues['member_emails'] = $memberEmails; } $count = 0; foreach ($memberEmailWild as $email) { - $queryPart[] = 'mem.email_address LIKE {string:wild_' . $count . '}'; + $queryPart[] = 'mem.email_address_ci LIKE {string:wild_' . $count . '}'; $queryValues['wild_' . $count++] = $email; } @@ -1109,7 +1116,7 @@ public static function updateBanMembers(): void $request = Db::$db->query( 'SELECT mem.id_member, mem.is_activated - {int:ban_flag} AS new_value FROM {db_prefix}members AS mem - LEFT JOIN {db_prefix}ban_items AS bi ON (bi.id_member = mem.id_member OR mem.email_address LIKE bi.email_address) + LEFT JOIN {db_prefix}ban_items AS bi ON (bi.id_member = mem.id_member OR mem.email_address_ci LIKE bi.email_address) LEFT JOIN {db_prefix}ban_groups AS bg ON (bg.id_ban_group = bi.id_ban_group AND bg.cannot_access = {int:cannot_access_activated} AND (bg.expire_time IS NULL OR bg.expire_time > {int:current_time})) WHERE (bi.id_ban IS NULL OR bg.id_ban_group IS NULL) AND mem.is_activated >= {int:ban_flag}', @@ -1884,16 +1891,28 @@ protected function validateTriggers(array &$triggers): array $ban_triggers['hostname']['hostname'] = $value; } } elseif ($key == 'email') { - if (preg_match('/[^\w.\-\+*@]/', $value) == 1) { + // Can this pattern resolve to a valid email address once + // any wildcards are replaced with real strings? + $test = new EmailAddress(strtr($value, ['*' => md5('*') . '.com'])); + + if (!$test->isValid()) { Utils::$context['ban_errors'][] = 'invalid_email'; } + // If the pattern is valid, ensure it is casefolded. + else { + $value = strtr($test->casefolded(), [md5('*') . '.com' => '*']); + } + + // Escape literal SQL wildcard characters and replace POSIX + // wildcard characters with SQL wildcard characters. + $value = substr(strtr($value, ['_' => '\\_', '%' => '\\%', '*' => '%']), 0, 255); // Check the user is not banning an admin. $request = Db::$db->query( 'SELECT id_member FROM {db_prefix}members WHERE (id_group = {int:admin_group} OR FIND_IN_SET({int:admin_group}, additional_groups) != 0) - AND email_address LIKE {string:email} + AND email_address_ci LIKE {string:email} LIMIT 1', [ 'admin_group' => 1, @@ -1906,8 +1925,6 @@ protected function validateTriggers(array &$triggers): array } Db::$db->free_result($request); - $value = substr(strtolower(str_replace('*', '%', $value)), 0, 255); - $ban_triggers['email']['email_address'] = $value; } elseif ($key == 'user') { $user = preg_replace('~&#(\d{4,5}|[2-9]\d{2,4}|1[2-9]\d);~', '&#$1;', Utils::htmlspecialchars($value, ENT_QUOTES)); @@ -2343,7 +2360,7 @@ protected function getMemberData(int $id): array $suggestions = []; $request = Db::$db->query( - 'SELECT id_member, real_name, member_ip, email_address + 'SELECT id_member, real_name, member_ip, email_address_ci FROM {db_prefix}members WHERE id_member = {int:current_user} LIMIT 1', diff --git a/Sources/Actions/Admin/Maintenance.php b/Sources/Actions/Admin/Maintenance.php index 2587a574502..1d2be4137f2 100644 --- a/Sources/Actions/Admin/Maintenance.php +++ b/Sources/Actions/Admin/Maintenance.php @@ -1320,14 +1320,13 @@ public function reattribute(): void User::$me->checkSession(); // Find the member. - $members = User::find($_POST['to']); + $members = User::find($_POST['to'], ids_only: true); if (empty($members)) { ErrorHandler::fatalLang('reattribute_cannot_find_member'); } - $memID = array_shift($members); - $memID = $memID['id']; + $memID = reset($members); $email = $_POST['type'] == 'email' ? $_POST['from_email'] : ''; $membername = $_POST['type'] == 'name' ? $_POST['from_name'] : ''; diff --git a/Sources/Actions/Admin/Members.php b/Sources/Actions/Admin/Members.php index 77ab0e08064..ac274310933 100644 --- a/Sources/Actions/Admin/Members.php +++ b/Sources/Actions/Admin/Members.php @@ -300,7 +300,7 @@ public function view(): void 'type' => 'string', ], 'email' => [ - 'db_fields' => ['email_address'], + 'db_fields' => ['email_address_ci'], 'type' => 'string', ], 'website' => [ @@ -451,7 +451,17 @@ public function view(): void $where_params[$param_name . '_low'] = $search_params[$param_name]['low']; $where_params[$param_name . '_high'] = $search_params[$param_name]['high']; } - } elseif ($param_info['type'] != 'groups') { + } + // Email. + elseif ($param_name == 'email') { + $parameter = strtolower(strtr(Utils::htmlspecialchars($search_params[$param_name], ENT_QUOTES), ['%' => '\\%', '_' => '\\_', '*' => '%', '?' => '_'])); + + $query_parts[] = '(' . $param_info['db_fields'][0] . ' LIKE {string:' . $param_name . '})'; + + $where_params[$param_name] = '%' . $parameter . '%'; + } + // Anything else except groups. + elseif ($param_info['type'] != 'groups') { // Replace the wildcard characters ('*' and '?') into MySQL ones. $parameter = strtolower(strtr(Utils::htmlspecialchars($search_params[$param_name], ENT_QUOTES), ['%' => '\\%', '_' => '\\_', '*' => '%', '?' => '_'])); @@ -590,8 +600,8 @@ public function view(): void ], ], 'sort' => [ - 'default' => 'email_address', - 'reverse' => 'email_address DESC', + 'default' => 'email_address_ci', + 'reverse' => 'email_address_ci DESC', ], ], 'ip' => [ @@ -938,8 +948,8 @@ function onSelectChange() ], ], 'sort' => [ - 'default' => 'email_address', - 'reverse' => 'email_address DESC', + 'default' => 'email_address_ci', + 'reverse' => 'email_address_ci DESC', ], ], 'ip' => [ diff --git a/Sources/Actions/Admin/News.php b/Sources/Actions/Admin/News.php index f616a15f1be..48f806be0fd 100644 --- a/Sources/Actions/Admin/News.php +++ b/Sources/Actions/Admin/News.php @@ -21,6 +21,7 @@ use SMF\Config; use SMF\Db\DatabaseApi as Db; use SMF\Editor; +use SMF\EmailAddress; use SMF\Group; use SMF\IntegrationHook; use SMF\ItemList; @@ -467,7 +468,7 @@ public function compose(): void } // Find the members - $_POST[$type] = implode(',', array_keys(User::find($_POST[$type]))); + $_POST[$type] = implode(',', User::find($_POST[$type], ids_only: true)); } } @@ -534,7 +535,7 @@ public function compose(): void ); while ($row = Db::$db->fetch_assoc($request)) { - $condition_array[] = '{string:email_' . $count . '}'; + $condition_array[] = 'email_address_ci LIKE {string:email_' . $count . '}'; $condition_array_params['email_' . $count++] = $row['email_address']; } Db::$db->free_result($request); @@ -543,7 +544,7 @@ public function compose(): void $request = Db::$db->query( 'SELECT id_member FROM {db_prefix}members - WHERE email_address IN (' . implode(', ', $condition_array) . ')', + WHERE (' . implode(' OR ', $condition_array) . ')', $condition_array_params, ); @@ -720,13 +721,13 @@ public function send(bool $clean_only = false): void // Finally - emails! if (!empty($_POST['emails'])) { - $addressed = array_unique(explode(';', strtr($_POST['emails'], ["\n" => ';', "\r" => ';', ',' => ';']))); + $emails = array_unique(explode(';', strtr($_POST['emails'], ["\n" => ';', "\r" => ';', ',' => ';']))); - foreach ($addressed as $curmem) { - $curmem = trim($curmem); + foreach ($emails as $email) { + $email = new EmailAddress($email, true); - if ($curmem != '' && filter_var($curmem, FILTER_VALIDATE_EMAIL)) { - Utils::$context['recipients']['emails'][$curmem] = $curmem; + if ($email->isValid()) { + Utils::$context['recipients']['emails'][$email->sendable()] = $email->sendable(); } } } diff --git a/Sources/Actions/Admin/Subscriptions.php b/Sources/Actions/Admin/Subscriptions.php index 74545c10de1..4c3bca1e1b9 100644 --- a/Sources/Actions/Admin/Subscriptions.php +++ b/Sources/Actions/Admin/Subscriptions.php @@ -19,6 +19,7 @@ use SMF\ActionTrait; use SMF\Config; use SMF\Db\DatabaseApi as Db; +use SMF\EmailAddress; use SMF\ErrorHandler; use SMF\IntegrationHook; use SMF\ItemList; @@ -1307,13 +1308,14 @@ function toggleOther() $email_addresses = []; foreach (explode(',', $_POST['paid_email_to']) as $email) { - $email = trim($email); + $email = new EmailAddress($email, true); - if (!empty($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) { - $email_addresses[] = $email; + if ($email->isValid()) { + $email_addresses[] = $email->sendable(); } - $_POST['paid_email_to'] = implode(',', $email_addresses); } + + $_POST['paid_email_to'] = implode(',', $email_addresses); } // Can only handle this stuff if it's already enabled... diff --git a/Sources/Actions/Groups.php b/Sources/Actions/Groups.php index 0383274b44b..d6368d714c3 100644 --- a/Sources/Actions/Groups.php +++ b/Sources/Actions/Groups.php @@ -261,7 +261,7 @@ public function members(): void // Sort out the sorting! $sort_methods = [ 'name' => 'real_name', - 'email' => 'email_address', + 'email' => 'email_address_ci', 'active' => 'last_login', 'registered' => 'date_registered', 'posts' => 'posts', diff --git a/Sources/Actions/Memberlist.php b/Sources/Actions/Memberlist.php index 5e92dbc248c..d8ce6706453 100644 --- a/Sources/Actions/Memberlist.php +++ b/Sources/Actions/Memberlist.php @@ -20,6 +20,7 @@ use SMF\ActionTrait; use SMF\Config; use SMF\Db\DatabaseApi as Db; +use SMF\EmailAddress; use SMF\ErrorHandler; use SMF\IntegrationHook; use SMF\Lang; @@ -517,14 +518,21 @@ public function search(): void // Search for an email address? if (\in_array('email', $_POST['fields']) && User::$me->allowedTo('moderate_forum')) { - $fields += [2 => 'email_address']; + $fields += [2 => 'email_address_ci']; $search_fields[] = 'email'; + + $email = new EmailAddress($_POST['search']); + $query_parameters['email_search'] = '%' . strtr(Utils::convertCase($email->local_part, 'fold') . '@' . $email->ascii_domain_part, ['_' => '\\_', '%' => '\\%', '*' => '%']) . '%'; } // These are expressions as well as plain columns, so they are // folded here rather than through the {column_ci:} type. if (Db::$db->case_sensitive) { foreach ($fields as $key => $field) { + if ($field === 'email_address_ci') { + continue; + } + $fields[$key] = 'LOWER(' . $field . ')'; } } @@ -547,7 +555,13 @@ public function search(): void ErrorHandler::fatalLang('invalid_search_string', false); } - $query = $_POST['search'] == '' ? '= {string:blank_string}' : 'LIKE {string_ci:search}'; + $where = []; + + foreach ($fields as $field) { + $where[] = $field . ($_POST['search'] == '' ? ' = {empty}' : ' LIKE {string:' . ($field === 'email_address_ci' ? 'email_search' : 'search') . '}'); + } + + $where = implode("\n\t\t\t\t\t\tOR ", $where); $request = Db::$db->query( 'SELECT COUNT(*) @@ -555,7 +569,9 @@ public function search(): void LEFT JOIN {db_prefix}membergroups AS mg ON (mg.id_group = CASE WHEN mem.id_group = {int:regular_id_group} THEN mem.id_post_group ELSE mem.id_group END) ' . (empty($customJoin) ? '' : implode(' ', $customJoin)) . ' - WHERE (' . implode(' ' . $query . ' OR ', $fields) . ' ' . $query . ') + WHERE ( + ' . $where . ' + ) AND mem.is_activated = {int:is_activated}', $query_parameters, ); @@ -585,7 +601,9 @@ public function search(): void $custom_fields_qry . (empty($customJoin) ? '' : implode(' ', $customJoin)) . ' - WHERE (' . implode(' ' . $query . ' OR ', $fields) . ' ' . $query . ') + WHERE ( + ' . $where . ' + ) AND mem.is_activated = {int:is_activated} ORDER BY {raw:sort} LIMIT {int:start}, {int:max}', diff --git a/Sources/Actions/Post2.php b/Sources/Actions/Post2.php index 05bb8590c06..3a7d182432c 100644 --- a/Sources/Actions/Post2.php +++ b/Sources/Actions/Post2.php @@ -25,6 +25,7 @@ use SMF\Config; use SMF\Db\DatabaseApi as Db; use SMF\Draft; +use SMF\EmailAddress; use SMF\ErrorHandler; use SMF\IntegrationHook; use SMF\Lang; @@ -236,7 +237,7 @@ public function submit(): void $this->errors[] = 'no_email'; } - if (!User::$me->allowedTo('moderate_forum') && !filter_var($author->email, FILTER_VALIDATE_EMAIL)) { + if (!User::$me->allowedTo('moderate_forum') && !EmailAddress::create($author->email)->isValid()) { $this->errors[] = 'bad_email'; } } diff --git a/Sources/Actions/Profile/Summary.php b/Sources/Actions/Profile/Summary.php index 68e5946d155..97b868cb6d1 100644 --- a/Sources/Actions/Profile/Summary.php +++ b/Sources/Actions/Profile/Summary.php @@ -20,6 +20,7 @@ use SMF\ActionTrait; use SMF\Config; use SMF\Db\DatabaseApi as Db; +use SMF\EmailAddress; use SMF\IP; use SMF\Lang; use SMF\Menu; @@ -203,7 +204,7 @@ public function execute(): void // Check their email as well... if (\strlen(Profile::$member->formatted['email']) != 0) { $ban_query[] = '({string:email} LIKE bi.email_address)'; - $ban_query_vars['email'] = Profile::$member->formatted['email']; + $ban_query_vars['email'] = EmailAddress::create(Profile::$member->formatted['email'])->casefolded(); } // So... are they banned? Dying to know! diff --git a/Sources/Actions/Register2.php b/Sources/Actions/Register2.php index d70f3a486a2..416368b48a4 100644 --- a/Sources/Actions/Register2.php +++ b/Sources/Actions/Register2.php @@ -18,6 +18,7 @@ use SMF\Config; use SMF\Cookie; use SMF\Db\DatabaseApi as Db; +use SMF\EmailAddress; use SMF\ErrorHandler; use SMF\Group; use SMF\IntegrationHook; @@ -342,7 +343,7 @@ function (&$value, $key) { // Any masks to apply? if ($row['field_type'] == 'text' && !empty($row['mask']) && $row['mask'] != 'none') { - if ($row['mask'] == 'email' && (!filter_var($value, FILTER_VALIDATE_EMAIL) || \strlen($value) > 255)) { + if ($row['mask'] == 'email' && (!EmailAddress::create($value)->isValid() || \strlen($value) > 255)) { $custom_field_errors[] = ['custom_field_invalid_email', [$row['field_name']]]; } elseif ($row['mask'] == 'number' && preg_match('~[^\d]~', $value)) { $custom_field_errors[] = ['custom_field_not_number', [$row['field_name']]]; @@ -479,17 +480,19 @@ public static function registerMember(array &$reg_options, bool $return_errors = // Convert character encoding for non-utf8mb4 database $reg_options['username'] = Utils::htmlspecialchars($reg_options['username']); - // @todo Separate the sprintf? - if (empty($reg_options['email']) || !filter_var($reg_options['email'], FILTER_VALIDATE_EMAIL) || \strlen($reg_options['email']) > 255) { - $reg_errors[] = ['lang', 'profile_error_bad_email']; - } - $username_validation_errors = Security::validateUsername(0, $reg_options['username'], true, !empty($reg_options['check_reserved_name'])); if (!empty($username_validation_errors)) { $reg_errors = array_merge($reg_errors, $username_validation_errors); } + // Check whether the email address appears to be valid. + $reg_options['email'] = new EmailAddress($reg_options['email'] ?? ''); + + if (!$reg_options['email']->isValid() || \strlen((string) $reg_options['email']) > 255) { + $reg_errors[] = ['lang', 'profile_error_bad_email']; + } + // Generate a validation code if it's supposed to be emailed. $validation_code = ''; @@ -513,8 +516,15 @@ public static function registerMember(array &$reg_options, bool $return_errors = } // Now perform hard password validation as required. - if (!empty($reg_options['check_password_strength']) && $reg_options['password'] != '') { - $password_error = Security::validatePassword($reg_options['password'], $reg_options['username'], [$reg_options['email']]); + if ( + !empty($reg_options['check_password_strength']) + && $reg_options['password'] != '' + ) { + $password_error = Security::validatePassword( + $reg_options['password'], + $reg_options['username'], + [(string) $reg_options['email']], + ); // Password isn't legal? if ($password_error != null) { @@ -536,24 +546,30 @@ public static function registerMember(array &$reg_options, bool $return_errors = $request = Db::$db->query( 'SELECT id_member FROM {db_prefix}members - WHERE {column_ci:email_address} = {string:email_address} - OR {column_ci:email_address} = {string:username} + WHERE email_address_ci = {string:email} + OR email_address_ci = {string:username} LIMIT 1', [ - 'email_address' => $reg_options['email'], + 'email' => $reg_options['email']->casefolded(), 'username' => $reg_options['username'], ], ); if (Db::$db->num_rows($request) != 0) { - $reg_errors[] = ['lang', 'email_in_use', false, [Utils::htmlspecialchars($reg_options['email'])]]; + $reg_errors[] = [ + 'lang', + 'email_in_use', + false, + [Utils::htmlspecialchars((string) $reg_options['email'])], + ]; } + Db::$db->free_result($request); // Are they banned from registering? $temp = new User(); $temp->username = $reg_options['username']; - $temp->email = empty($reg_options['check_email_ban']) ? '' : $reg_options['email']; + $temp->email = empty($reg_options['check_email_ban']) ? '' : (string) $reg_options['email']; $temp->ip = $reg_options['interface'] == 'admin' ? '127.0.0.1' : User::$me->ip; $temp->ip2 = $reg_options['interface'] == 'admin' ? '127.0.0.1' : IP::getUserIPAlternative(); @@ -619,7 +635,7 @@ public static function registerMember(array &$reg_options, bool $return_errors = // Some of these might be overwritten. (the lower ones that are in the arrays below.) $reg_options['register_vars'] = [ 'member_name' => $reg_options['username'], - 'email_address' => $reg_options['email'], + 'email_address' => (string) $reg_options['email'], 'passwd' => Security::hashPassword($reg_options['password']), 'password_salt' => bin2hex(random_bytes(16)), 'posts' => 0, @@ -717,6 +733,8 @@ public static function registerMember(array &$reg_options, bool $return_errors = $reg_options['register_vars']['spoofdetector_name'] = Utils::htmlspecialchars(SpoofDetector::getSkeletonString(html_entity_decode($reg_options['register_vars']['real_name'] ?? $reg_options['register_vars']['member_name'], ENT_QUOTES))); + $reg_options['register_vars']['email_address_ci'] = $reg_options['email']->casefolded(); + $column_names = []; $values = []; @@ -805,7 +823,7 @@ public static function registerMember(array &$reg_options, bool $return_errors = $emaildata = Mail::loadEmailTemplate($email_message, $replacements); - Mail::send($reg_options['email'], $emaildata['subject'], $emaildata['body'], null, $email_message . $member_id, $emaildata['is_html'], 0); + Mail::send((string) $reg_options['email'], $emaildata['subject'], $emaildata['body'], null, $email_message . $member_id, $emaildata['is_html'], 0); } // All admins are finished here. @@ -824,7 +842,7 @@ public static function registerMember(array &$reg_options, bool $return_errors = $emaildata = Mail::loadEmailTemplate('register_immediate', $replacements); - Mail::send($reg_options['email'], $emaildata['subject'], $emaildata['body'], null, 'register', $emaildata['is_html'], 0); + Mail::send((string) $reg_options['email'], $emaildata['subject'], $emaildata['body'], null, 'register', $emaildata['is_html'], 0); } // Send admin their notification. @@ -853,7 +871,7 @@ public static function registerMember(array &$reg_options, bool $return_errors = $emaildata = Mail::loadEmailTemplate('register_' . ($reg_options['require'] == 'activation' ? 'activate' : 'coppa'), $replacements); - Mail::send($reg_options['email'], $emaildata['subject'], $emaildata['body'], null, 'reg_' . $reg_options['require'] . $member_id, $emaildata['is_html'], 0); + Mail::send((string) $reg_options['email'], $emaildata['subject'], $emaildata['body'], null, 'reg_' . $reg_options['require'] . $member_id, $emaildata['is_html'], 0); } // Must be awaiting approval. else { @@ -866,7 +884,7 @@ public static function registerMember(array &$reg_options, bool $return_errors = $emaildata = Mail::loadEmailTemplate('register_pending', $replacements); - Mail::send($reg_options['email'], $emaildata['subject'], $emaildata['body'], null, 'reg_pending', $emaildata['is_html'], 0); + Mail::send((string) $reg_options['email'], $emaildata['subject'], $emaildata['body'], null, 'reg_pending', $emaildata['is_html'], 0); // Admin gets informed here... Mail::adminNotify('approval', $member_id, $reg_options['username']); diff --git a/Sources/Autolinker.php b/Sources/Autolinker.php index 4f021a16bed..443e0dd5ba5 100644 --- a/Sources/Autolinker.php +++ b/Sources/Autolinker.php @@ -530,12 +530,7 @@ public function makeLinks(string $string, bool $link_emails = true, bool $link_u continue; } - // Is this version of PHP capable of validating this email address? - $can_validate = \defined('FILTER_FLAG_EMAIL_UNICODE') || \strlen($url->path) == strspn(strtolower($url->path), 'abcdefghijklmnopqrstuvwxyz0123456789!#$%&\'*+-/=?^_`{|}~.@'); - - $flags = \defined('FILTER_FLAG_EMAIL_UNICODE') ? FILTER_FLAG_EMAIL_UNICODE : null; - - if (!$can_validate || filter_var($url->path, FILTER_VALIDATE_EMAIL, $flags) !== false) { + if (EmailAddress::create($url->path)->isValid()) { $placeholders[md5($url->path)] = $url->path; $placeholders[md5((string) $url)] = (string) $url; diff --git a/Sources/Avatar.php b/Sources/Avatar.php index 595fc301893..ac219e0f060 100644 --- a/Sources/Avatar.php +++ b/Sources/Avatar.php @@ -428,7 +428,7 @@ public function __construct( $email = User::$loaded[$this->id_member]->email; } - if (filter_var($email ?? '', FILTER_VALIDATE_EMAIL) !== false) { + if (EmailAddress::create($email ?? '')->isValid()) { $this->email = $email; } @@ -684,7 +684,7 @@ protected function constructGravatar(Url $url): bool // Do we need to override the embedded email address? || empty(Config::$modSettings['gravatarAllowExtraEmail']) || !isset($url->user, $url->host) - || filter_var($url->user . '@' . $url->host, FILTER_VALIDATE_EMAIL) === false + || !EmailAddress::create($url->user . '@' . $url->host)->isValid() ) { $url = new Url('gravatar://' . ($this->email ?? 'invalid'), true); } diff --git a/Sources/Db/Schema/v3_0/Members.php b/Sources/Db/Schema/v3_0/Members.php index 92075f44f11..a26fa707ad2 100644 --- a/Sources/Db/Schema/v3_0/Members.php +++ b/Sources/Db/Schema/v3_0/Members.php @@ -349,6 +349,13 @@ public function __construct() not_null: true, default: '', ), + 'email_address_ci' => new Column( + name: 'email_address_ci', + type: 'varchar', + size: 255, + not_null: true, + default: '', + ), ]; $this->indexes = [ @@ -501,6 +508,15 @@ public function __construct() ], ], ), + 'idx_email_address_ci' => new DbIndex( + name: 'idx_email_address_ci', + columns: [ + [ + 'name' => 'email_address_ci', + 'opclass' => 'varchar_pattern_ops', + ], + ], + ), ]; if (Db::$db->title === POSTGRE_TITLE) { diff --git a/Sources/EmailAddress.php b/Sources/EmailAddress.php new file mode 100644 index 00000000000..955631e105f --- /dev/null +++ b/Sources/EmailAddress.php @@ -0,0 +1,260 @@ +sendable();` + * + * The third use case for this class is to get a casefolded version of an email + * address, suitable for case-insensitive comparisons. This should only ever be + * used for internal processing, such as conducting a case-insensitive search. + * + * IMPORTANT: THE CASEFOLDED FORM OF AN EMAIL ADDRESS IS NOT THE "CORRECT" FORM. + * Most email providers treat JDoe@example.com and jdoe@example.com as aliases, + * but others treat them as separate addresses. Moreover, when the local part of + * the address contains international characters, casefolding can cause changes + * to the string that go beyond simply substituting one character with another. + * + * In order to get a casefolded version an email address, make a new instance of + * this class and then call its casefolded() method, like so: + * + * `$address = EmailAddress::create($address)->casefolded();` + */ +class EmailAddress implements \Stringable +{ + /******************* + * Public properties + *******************/ + + /** + * @var string + * + * The local part of the email address. + */ + public private(set) string $local_part; + + /** + * @var string + * + * The domain part of the email address. + */ + public private(set) string $domain_part; + + /** + * @var string + * + * Punycode encoded version of the domain. + */ + public private(set) string $ascii_domain_part; + + /**************** + * Public methods + ****************/ + + /** + * Constructor. + * + * If $sanitize is true, then any disallowed characters will be stripped + * from the address. The set of disallowed characters includes whitespace, + * certain ASCII punctuation characters, and anything that fits in the + * Unicode "other characters" category (e.g. control characters, private + * use characters, etc.). + * + * If the address is valid (either because it was already valid or because + * sanitizing successfully made it valid), then the domain part of the + * address will be normalized to the canonical form of the domain name. + * + * @param string $address The email address string. + * @param bool $sanitize Whether to sanitize the address. + */ + public function __construct(string $address, bool $sanitize = false) + { + // We need this. + if (!\function_exists('idn_to_ascii')) { + require_once Sapi::canonicalPath(Config::$sourcedir . '/Subs-Compat.php'); + } + + // Split into local and domain parts. + [$this->local_part, $this->domain_part] = array_pad(explode('@', $address, 2), 2, ''); + + // Sanitize while preserving allowed non-ASCII characters. + if ($sanitize) { + // Sanitize the local part using FILTER_SANITIZE_EMAIL. + $this->local_part = preg_replace_callback( + '/[^\x00-\x7F\pZ\pC]|%/u', + fn($matches) => rawurlencode($matches[0]), + $this->local_part, + ); + + $this->local_part = filter_var($this->local_part, FILTER_SANITIZE_EMAIL); + $this->local_part = rawurldecode($this->local_part); + + // The domain part is subject to URL character restrictions, which + // are a superset of the email character restrictions. + $this->domain_part = preg_replace_callback( + '/[^\x00-\x7F\pZ\pC]|%/u', + fn($matches) => rawurlencode($matches[0]), + $this->domain_part, + ); + + $this->domain_part = filter_var($this->domain_part, FILTER_SANITIZE_URL); + $this->domain_part = rawurldecode($this->domain_part); + } + + // Normalize the domain. + $this->domain_part = Utils::normalize($this->domain_part, 'kc_casefold'); + + // Get the Punycode encoded version of the domain. + $this->ascii_domain_part = !empty($this->domain_part) ? (idn_to_ascii($this->domain_part) ?: $this->domain_part) : $this->domain_part; + } + + /** + * Returns the email address as a string. + * + * If the input passed to the constructor was not a syntactically valid + * email address, this method will return an empty string. + * + * @return string The email address. + */ + public function __toString(): string + { + if (!$this->isValid()) { + return ''; + } + + return $this->local_part . '@' . $this->domain_part; + } + + /** + * Gets a version of this email address with mixed case Unicode in the local + * part and lowercase ASCII in the domain part. + * + * This form should be used when sending an email message to this address. + * + * This will typically be the same as the default form, but it will differ + * if the domain part of the address is an internationalized domain name. + * + * If the input passed to the constructor was not a syntactically valid + * email address, this method will return an empty string. + * + * @return string The sendable version of the email address. + */ + public function sendable(): string + { + if (!$this->isValid()) { + return ''; + } + + return $this->local_part . '@' . $this->ascii_domain_part; + } + + /** + * Gets a version of this email address with casefolded Unicode in the local + * part and lowercase ASCII in the domain part. + * + * This form should only be used when conducting a case-insensitive + * comparison. DO NOT TRY TO SEND ANYTHING TO A CASEFOLDED ADDRESS. + * + * If the input passed to the constructor was not a syntactically valid + * email address, this method will return an empty string. + * + * @return string A casefolded version of the email address. + */ + public function casefolded(): string + { + if (!$this->isValid()) { + return ''; + } + + return Utils::convertCase($this->local_part, 'fold') . '@' . $this->ascii_domain_part; + } + + /** + * Checks whether the email address is syntactically valid. + * + * @param bool $allow_unicode Whether to allow Unicode characters. + * Default: true. + * @return bool Whether the email address is syntactically valid. + */ + public function isValid(bool $allow_unicode = true): bool + { + if (empty($this->local_part) || empty($this->ascii_domain_part)) { + return false; + } + + return (bool) filter_var( + // As of PHP 8.4, FILTER_FLAG_EMAIL_UNICODE still doesn't understand + // Unicode in domain names. So to avoid spurious errors we must use + // the ASCII domain name when Unicode is allowed. But if Unicode is + // not allowed then we should check the version of the domain name + // that might contain Unicode. This seems backwards, but it's true. + $this->local_part . '@' . ($allow_unicode ? $this->ascii_domain_part : $this->domain_part), + FILTER_VALIDATE_EMAIL, + $allow_unicode ? FILTER_FLAG_EMAIL_UNICODE : 0, + ); + } + + /*********************** + * Public static methods + ***********************/ + + /** + * Convenience wrapper for constructor. + * + * @param string $address The email address string. + * @param bool $sanitize Whether to sanitize the address. + * @return self An instance of this class. + */ + public static function create(string $address, bool $sanitize = false): self + { + return new self($address, $sanitize); + } +} diff --git a/Sources/Mail.php b/Sources/Mail.php index c317526eb59..df9f64a9f92 100644 --- a/Sources/Mail.php +++ b/Sources/Mail.php @@ -71,26 +71,14 @@ public static function send( ?bool $hotmail_fix = null, bool $is_private = false, ): bool { - // Use sendmail if it's set or if no SMTP server is set. - $use_sendmail = empty(Config::$modSettings['mail_type']) || Config::$modSettings['smtp_host'] == ''; - - // Line breaks need to be \r\n only in windows or for SMTP. - // Starting with php 8x, line breaks need to be \r\n even for linux. - $line_break = (Sapi::isOS(Sapi::OS_WINDOWS) || !$use_sendmail || version_compare(PHP_VERSION, '8.0.0', '>=')) ? "\r\n" : "\n"; - // So far so good. $mail_result = true; // If the recipient list isn't an array, make it one. $to_array = \is_array($to) ? $to : [$to]; - // Make sure we actually have email addresses to send this to - foreach ($to_array as $k => $v) { - // This should never happen, but better safe than sorry - if (trim($v) == '') { - unset($to_array[$k]); - } - } + // Make sure we actually have email addresses to send this to. + $to_array = self::prepareAddresses($to_array); // Nothing left? Nothing else to do if (empty($to_array)) { @@ -126,12 +114,12 @@ public static function send( // Get rid of entities. $subject = strtr(Utils::htmlspecialcharsDecode($subject), ["\r" => '', "\n" => '']); // Make the message use the proper line breaks. - $message = str_replace(["\r", "\n"], ['', $line_break], $message); + $message = str_replace(["\r", "\n"], ['', "\r\n"], $message); // Make sure hotmail mails are sent as HTML so that HTML entities work. if ($hotmail_fix && !$send_html) { $send_html = true; - $message = strtr($message, [$line_break => '
' . $line_break]); + $message = strtr($message, ["\r\n" => '
' . "\r\n"]); $message = preg_replace('~(' . preg_quote(Config::$scripturl, '~') . '(?:[?/][\w\-_%\.,\?&;=#]+)?)~', '$1', $message); } @@ -141,16 +129,16 @@ public static function send( // Use real tabs. $message = strtr($message, [Utils::TAB_SUBSTITUTE => $send_html ? '' . "\t" . '' : "\t"]); - list(, $from_name) = self::mimespecialchars(addcslashes($from !== null ? $from : Utils::$context['forum_name'], '<>()\'\\"'), true, $hotmail_fix, $line_break); - list(, $subject) = self::mimespecialchars($subject, true, $hotmail_fix, $line_break); + list(, $from_name) = self::mimespecialchars(addcslashes($from !== null ? $from : Utils::$context['forum_name'], '<>()\'\\"'), true, $hotmail_fix, "\r\n"); + list(, $subject) = self::mimespecialchars($subject, true, $hotmail_fix, "\r\n"); // Construct the mail headers... - $headers = 'From: "' . $from_name . '" <' . (empty(Config::$modSettings['mail_from']) ? Config::$webmaster_email : Config::$modSettings['mail_from']) . '>' . $line_break; - $headers .= $from !== null ? 'Reply-To: <' . $from . '>' . $line_break : ''; - $headers .= 'Return-Path: ' . (empty(Config::$modSettings['mail_from']) ? Config::$webmaster_email : Config::$modSettings['mail_from']) . $line_break; - $headers .= 'Date: ' . gmdate('D, d M Y H:i:s') . ' -0000' . $line_break; - $headers .= 'Message-ID: <' . md5(Config::$scripturl . microtime()) . '-' . ($message_id ?? 0) . strstr(empty(Config::$modSettings['mail_from']) ? Config::$webmaster_email : Config::$modSettings['mail_from'], '@') . '>' . $line_break; - $headers .= 'X-Mailer: SMF' . $line_break; + $headers = 'From: "' . $from_name . '" <' . (empty(Config::$modSettings['mail_from']) ? Config::$webmaster_email : Config::$modSettings['mail_from']) . '>' . "\r\n"; + $headers .= $from !== null ? 'Reply-To: <' . $from . '>' . "\r\n" : ''; + $headers .= 'Return-Path: ' . (empty(Config::$modSettings['mail_from']) ? Config::$webmaster_email : Config::$modSettings['mail_from']) . "\r\n"; + $headers .= 'Date: ' . gmdate('D, d M Y H:i:s') . ' -0000' . "\r\n"; + $headers .= 'Message-ID: <' . md5(Config::$scripturl . microtime()) . '-' . ($message_id ?? 0) . strstr(empty(Config::$modSettings['mail_from']) ? Config::$webmaster_email : Config::$modSettings['mail_from'], '@') . '>' . "\r\n"; + $headers .= 'X-Mailer: SMF' . "\r\n"; // Pass this to the integration before we start modifying the output -- it'll make it easier later. if (\in_array(false, IntegrationHook::call('integrate_outgoing_email', [&$subject, &$message, &$headers, &$to_array]), true)) { @@ -164,41 +152,41 @@ public static function send( $mime_boundary = 'SMF-' . md5($message . time()); // Using mime, as it allows to send a plain unencoded alternative. - $headers .= 'Mime-Version: 1.0' . $line_break; - $headers .= 'content-type: multipart/alternative; boundary="' . $mime_boundary . '"' . $line_break; - $headers .= 'content-transfer-encoding: 7bit' . $line_break; + $headers .= 'Mime-Version: 1.0' . "\r\n"; + $headers .= 'content-type: multipart/alternative; boundary="' . $mime_boundary . '"' . "\r\n"; + $headers .= 'content-transfer-encoding: 7bit' . "\r\n"; // Sending HTML? Let's plop in some basic stuff, then. if ($send_html) { - $no_html_message = Utils::htmlspecialcharsDecode(strip_tags(strtr($orig_message, ['' => $line_break]))); + $no_html_message = Utils::htmlspecialcharsDecode(strip_tags(strtr($orig_message, ['' => "\r\n"]))); // But, then, dump it and use a plain one for dinosaur clients. - list(, $plain_message) = self::mimespecialchars($no_html_message, false, true, $line_break); - $message = $plain_message . $line_break . '--' . $mime_boundary . $line_break; + list(, $plain_message) = self::mimespecialchars($no_html_message, false, true, "\r\n"); + $message = $plain_message . "\r\n" . '--' . $mime_boundary . "\r\n"; // This is the plain text version. Even if no one sees it, we need it for spam checkers. - list($charset, $plain_charset_message, $encoding) = self::mimespecialchars($no_html_message, false, false, $line_break); - $message .= 'content-type: text/plain; charset=' . $charset . $line_break; - $message .= 'content-transfer-encoding: ' . $encoding . $line_break . $line_break; - $message .= $plain_charset_message . $line_break . '--' . $mime_boundary . $line_break; + list($charset, $plain_charset_message, $encoding) = self::mimespecialchars($no_html_message, false, false, "\r\n"); + $message .= 'content-type: text/plain; charset=' . $charset . "\r\n"; + $message .= 'content-transfer-encoding: ' . $encoding . "\r\n\r\n"; + $message .= $plain_charset_message . "\r\n" . '--' . $mime_boundary . "\r\n"; // This is the actual HTML message, prim and proper. If we wanted images, they could be inlined here (with multipart/related, etc.) - list($charset, $html_message, $encoding) = self::mimespecialchars($orig_message, false, $hotmail_fix, $line_break); - $message .= 'content-type: text/html; charset=' . $charset . $line_break; - $message .= 'content-transfer-encoding: ' . ($encoding == '' ? '7bit' : $encoding) . $line_break . $line_break; - $message .= $html_message . $line_break . '--' . $mime_boundary . '--'; + list($charset, $html_message, $encoding) = self::mimespecialchars($orig_message, false, $hotmail_fix, "\r\n"); + $message .= 'content-type: text/html; charset=' . $charset . "\r\n"; + $message .= 'content-transfer-encoding: ' . ($encoding == '' ? '7bit' : $encoding) . "\r\n\r\n"; + $message .= $html_message . "\r\n" . '--' . $mime_boundary . '--'; } // Text is good too. else { // Send a plain message first, for the older web clients. - list(, $plain_message) = self::mimespecialchars($orig_message, false, true, $line_break); - $message = $plain_message . $line_break . '--' . $mime_boundary . $line_break; + list(, $plain_message) = self::mimespecialchars($orig_message, false, true, "\r\n"); + $message = $plain_message . "\r\n" . '--' . $mime_boundary . "\r\n"; // Now add an encoded message using the forum's character set. - list($charset, $encoded_message, $encoding) = self::mimespecialchars($orig_message, false, false, $line_break); - $message .= 'content-type: text/plain; charset=' . $charset . $line_break; - $message .= 'content-transfer-encoding: ' . $encoding . $line_break . $line_break; - $message .= $encoded_message . $line_break . '--' . $mime_boundary . '--'; + list($charset, $encoded_message, $encoding) = self::mimespecialchars($orig_message, false, false, "\r\n"); + $message .= 'content-type: text/plain; charset=' . $charset . "\r\n"; + $message .= 'content-transfer-encoding: ' . $encoding . "\r\n\r\n"; + $message .= $encoded_message . "\r\n" . '--' . $mime_boundary . '--'; } // Are we using the mail queue, if so this is where we butt in... @@ -310,6 +298,8 @@ public static function addToQueue( // Ensure we tell obExit to flush. Utils::$context['flush_mail'] = true; + $to_array = self::prepareAddresses($to_array); + foreach ($to_array as $to) { // Will this insert go over MySQL's limit? $this_insert_len = \strlen($to) + \strlen($message) + \strlen($headers) + 700; @@ -498,6 +488,13 @@ public static function reduceQueue(bool|int $number = false, bool $override_limi $failed_emails = []; foreach ($emails as $email) { + $email['to'] = current(self::prepareAddresses([$email['to']])); + + // Can't send without a valid address! + if ($email['to'] === false) { + continue; + } + $result = $agent->send($email['to'], $email['subject'], $email['body'], $email['headers']); // Old emails should expire @@ -843,6 +840,24 @@ public static function loadEmailTemplate(string $template, array $replacements = * Internal static methods *************************/ + /** + * Processes a list of email addresses to weed out any invalid ones and to + * ensure the valid ones use the form with the best chance of delivery. + * + * @param array $addresses A list of email addresses. + * @return array Updated list of email addresses. + */ + protected static function prepareAddresses(array $addresses): array + { + $addresses = array_map(fn($address) => new EmailAddress((string) $address), $addresses); + + // Filter out invalid email addresses. + $addresses = array_filter($addresses, fn($address) => $address->isValid()); + + // Use the form that has the best chance of successful delivery. + return array_map(fn($address) => $address->sendable(), $addresses); + } + /** * Callback function for loadEmailTemplate on subject and body * Uses capture group 1 in array diff --git a/Sources/MailAgent/APIs/SMTP.php b/Sources/MailAgent/APIs/SMTP.php index 6430d52e51f..5dd71a18a2f 100644 --- a/Sources/MailAgent/APIs/SMTP.php +++ b/Sources/MailAgent/APIs/SMTP.php @@ -14,6 +14,7 @@ namespace SMF\MailAgent\APIs; use SMF\Config; +use SMF\EmailAddress; use SMF\ErrorHandler; use SMF\Lang; use SMF\MailAgent\MailAgent; @@ -163,6 +164,14 @@ public function connect(): bool */ public function send(string $to, string $subject, string $message, string $headers): bool { + if (($address = new EmailAddress($to))->isValid()) { + $to = $address->sendable(); + } else { + ErrorHandler::log(Lang::getTxt('mail_send_unable', [$to], file: 'General')); + + return false; + } + if (empty($this->socket)) { return false; } diff --git a/Sources/MailAgent/APIs/SendMail.php b/Sources/MailAgent/APIs/SendMail.php index f6c5359853b..47f1fad920c 100644 --- a/Sources/MailAgent/APIs/SendMail.php +++ b/Sources/MailAgent/APIs/SendMail.php @@ -14,6 +14,7 @@ namespace SMF\MailAgent\APIs; use SMF\Config; +use SMF\EmailAddress; use SMF\ErrorHandler; use SMF\Lang; use SMF\MailAgent\MailAgent; @@ -62,6 +63,14 @@ public function send(string $to, string $subject, string $message, string $heade { $mail_result = true; + if (($address = new EmailAddress($to))->isValid()) { + $to = $address->sendable(); + } else { + ErrorHandler::log(Lang::getTxt('mail_send_unable', [$to], file: 'General')); + + return false; + } + $subject = strtr($subject, ["\r" => '', "\n" => '']); if (!empty(Config::$modSettings['mail_strip_carriage'])) { @@ -81,7 +90,7 @@ function ($errno, $errstr, $errfile, $errline) { ); try { - if (!mail(strtr($to, ["\r" => '', "\n" => '']), $subject, $message, $headers)) { + if (!mail($to, $subject, $message, $headers)) { ErrorHandler::log(Lang::getTxt('mail_send_unable', [$to], file: 'General')); $mail_result = false; } diff --git a/Sources/Maintenance/Migration/v3_0/EmailAddressCi.php b/Sources/Maintenance/Migration/v3_0/EmailAddressCi.php new file mode 100644 index 00000000000..98ebedf84b6 --- /dev/null +++ b/Sources/Maintenance/Migration/v3_0/EmailAddressCi.php @@ -0,0 +1,64 @@ +getCurrentStructure(); + + return ( + !isset($existing_structure['columns']['email_address_ci']) + || !isset($existing_structure['indexes']['idx_email_address_ci']) + ); + } + + /** + * + */ + public function execute(): bool + { + $table = new Schema\v3_0\Members(); + + $table->addColumn($table->columns['email_address_ci']); + $table->addIndex($table->indexes['idx_email_address_ci']); + + $this->handleTimeout(); + + return true; + } +} diff --git a/Sources/Maintenance/Migration/v3_0/NormalizeBannedEmailAddresses.php b/Sources/Maintenance/Migration/v3_0/NormalizeBannedEmailAddresses.php new file mode 100644 index 00000000000..1e38d3edc31 --- /dev/null +++ b/Sources/Maintenance/Migration/v3_0/NormalizeBannedEmailAddresses.php @@ -0,0 +1,152 @@ +getMax(); + + // SMF has only ever allowed the '%' wildcard in email ban + // patterns, so we don't need to handle '_'. + $wildcard_replacements = [ + '\\%' => '%', + '%' => md5('%') . '.org', + ]; + + while (Maintenance::getCurrentStart() < $max) { + $set = [ + 'email_address' => [], + ]; + + $params = [ + 'ids' => [], + ]; + + $request = $this->query( + 'SELECT id_ban, email_address + FROM {db_prefix}ban_items + WHERE id_ban > {int:start} + ORDER BY id_ban ASC + LIMIT {int:limit}', + [ + 'limit' => $this->limit, + 'start' => Maintenance::getCurrentStart(), + ], + ); + + while ($row = Db::$db->fetch_assoc($request)) { + $params['ids'][] = (int) $row['id_ban']; + + // Can this pattern resolve to a valid email address once + // any wildcards are replaced with real strings? + $test = new EmailAddress(strtr($row['email_address'], $wildcard_replacements)); + + // If the pattern is valid, ensure it is casefolded. + if ($test->isValid()) { + $set['email_address'][$row['id_ban']] = '{string:ci_' . $row['id_ban'] . '}'; + + $params['ci_' . $row['id_ban']] = strtr($test->casefolded(), array_flip($wildcard_replacements)); + } + } + + Db::$db->free_result($request); + + // Build each column's complete SET statement. + foreach ($set as $column => $to_set) { + $statement = $column . ' = CASE'; + + foreach ($to_set as $id => $value) { + $statement .= "\n\t\t\t\t\t\t" . 'WHEN id_ban = ' . $id . ' THEN ' . $value; + } + + $statement .= "\n\t\t\t\t\t\t" . 'ELSE ' . $column; + $statement .= "\n\t\t\t\t\t" . 'END'; + + $set[$column] = $statement; + } + + // Perform the updates. + $this->query( + 'UPDATE {db_prefix}ban_items + SET + ' . implode(",\n\t\t\t\t", $set) . ' + WHERE id_ban IN ({array_int:ids})', + $params, + ); + + $this->handleTimeout(max($params['ids'])); + } + + return true; + } + + /****************** + * Internal methods + ******************/ + + /** + * Gets the maximum value of ban_items + * + * @return int + */ + private function getMax(): int + { + $request = $this->query( + 'SELECT MAX(id_ban) + FROM {db_prefix}ban_items', + ); + + $row = Db::$db->fetch_row($request); + + Db::$db->free_result($request); + + return (int) $row[0]; + } +} diff --git a/Sources/Maintenance/Migration/v3_0/NormalizeMemberEmailAddresses.php b/Sources/Maintenance/Migration/v3_0/NormalizeMemberEmailAddresses.php new file mode 100644 index 00000000000..06861b9c411 --- /dev/null +++ b/Sources/Maintenance/Migration/v3_0/NormalizeMemberEmailAddresses.php @@ -0,0 +1,147 @@ +getMax(); + + while (Maintenance::getCurrentStart() < $max) { + $set = [ + 'email_address' => [], + 'email_address_ci' => [], + ]; + + $params = [ + 'members' => [], + ]; + + $request = $this->query( + 'SELECT id_member, email_address + FROM {db_prefix}members + WHERE id_member > {int:start} + ORDER BY id_member ASC + LIMIT {int:limit}', + [ + 'limit' => $this->limit, + 'start' => Maintenance::getCurrentStart(), + ], + ); + + while ($row = Db::$db->fetch_assoc($request)) { + $params['members'][] = (int) $row['id_member']; + + $email = new EmailAddress($row['email_address'], true); + + if ($email->isValid()) { + // Normalize the domain. + $set['email_address'][$row['id_member']] = '{string:cs_' . $row['id_member'] . '}'; + $params['cs_' . $row['id_member']] = (string) $email; + + // Get a casefolded version for case-insensitive matching. + $set['email_address_ci'][$row['id_member']] = '{string:ci_' . $row['id_member'] . '}'; + $params['ci_' . $row['id_member']] = $email->casefolded(); + } + } + + Db::$db->free_result($request); + + // Build each column's complete SET statement. + foreach ($set as $column => $to_set) { + $statement = $column . ' = CASE'; + + foreach ($to_set as $id => $value) { + $statement .= "\n\t\t\t\t\t\t" . 'WHEN id_member = ' . $id . ' THEN ' . $value; + } + + $statement .= "\n\t\t\t\t\t\t" . 'ELSE ' . $column; + $statement .= "\n\t\t\t\t\t" . 'END'; + + $set[$column] = $statement; + } + + // Perform the updates. + $this->query( + 'UPDATE {db_prefix}members + SET + ' . implode(",\n\t\t\t\t", $set) . ' + WHERE id_member IN ({array_int:members})', + $params, + ); + + $this->handleTimeout(max($params['members'])); + } + + return true; + } + + /****************** + * Internal methods + ******************/ + + /** + * Gets the maximum value of id_member + * + * @return int + */ + private function getMax(): int + { + $request = $this->query( + 'SELECT MAX(id_member) + FROM {db_prefix}members', + ); + + $row = Db::$db->fetch_row($request); + + Db::$db->free_result($request); + + return (int) $row[0]; + } +} diff --git a/Sources/Maintenance/Tools/Install.php b/Sources/Maintenance/Tools/Install.php index 0a7b36fe6fc..ba9e24acbec 100644 --- a/Sources/Maintenance/Tools/Install.php +++ b/Sources/Maintenance/Tools/Install.php @@ -19,6 +19,7 @@ use SMF\Cookie; use SMF\Db\DatabaseApi as Db; use SMF\Db\Schema\Table; +use SMF\EmailAddress; use SMF\IP; use SMF\Lang; use SMF\Logging; @@ -29,6 +30,7 @@ use SMF\TaskRunner; use SMF\Themes\default\MaintenanceTemplate; use SMF\Time; +use SMF\Unicode\SpoofDetector; use SMF\Url; use SMF\User; use SMF\Utils; @@ -892,7 +894,7 @@ public function adminAccount(): bool Utils::$context['username'] = htmlspecialchars($_POST['username'] ?? ''); Utils::$context['email'] = htmlspecialchars($_POST['email'] ?? ''); - Utils::$context['server_email'] = htmlspecialchars($_POST['server_email'] ?? ''); + Utils::$context['server_email'] = htmlspecialchars($_POST['server_email'] ?? (!empty(Config::$webmaster_email) && Config::$webmaster_email !== $settingsDefs['webmaster_email']['default'] ? Config::$webmaster_email : '')); Utils::$context['require_db_confirm'] = empty(Config::$db_type); @@ -956,11 +958,6 @@ public function adminAccount(): bool return false; } - // Update the webmaster's email? - if (!empty($_POST['server_email']) && (empty(Config::$webmaster_email) || Config::$webmaster_email == $settingsDefs['webmaster_email']['default'])) { - $this->updateSettingsFile(['webmaster_email' => (string) $_POST['server_email']]); - } - // Normalize Unicode characters. $_POST['username'] = Utils::normalize($_POST['username']); @@ -995,14 +992,26 @@ public function adminAccount(): bool return false; } + // Is this email address valid? + $_POST['email'] = new EmailAddress($_POST['email']); + + if (!$_POST['email']->isValid() || \strlen((string) $_POST['email']) > 255) { + // One step back, this time fill out a proper admin email address. + Maintenance::$fatal_error = Lang::getTxt('error_valid_admin_email_needed', file: 'Maintenance'); + $this->logProgress(Maintenance::$fatal_error); + + return false; + } + + // Is this email address taken? $result = Db::$db->query( 'SELECT id_member, password_salt FROM {db_prefix}members - WHERE member_name = {string:username} OR email_address = {string:email} + WHERE member_name = {string:username} OR email_address_ci = {string:email} LIMIT 1', [ 'username' => $_POST['username'], - 'email' => $_POST['email'], + 'email' => $_POST['email']->casefolded(), 'db_error_skip' => true, ], ); @@ -1016,22 +1025,20 @@ public function adminAccount(): bool return false; } - if (empty($_POST['email']) || !filter_var($_POST['email'], FILTER_VALIDATE_EMAIL) || \strlen($_POST['email']) > 255) { - // One step back, this time fill out a proper admin email address. - Maintenance::$fatal_error = Lang::getTxt('error_valid_admin_email_needed', file: 'Maintenance'); - $this->logProgress(Maintenance::$fatal_error); - - return false; - } + // Update the webmaster's email? + $_POST['server_email'] = new EmailAddress($_POST['server_email'] ?? ''); - if (empty($_POST['server_email']) || !filter_var($_POST['server_email'], FILTER_VALIDATE_EMAIL) || \strlen($_POST['server_email']) > 255) { - // One step back, this time fill out a proper admin email address. + if ($_POST['server_email']->isValid() && \strlen((string) $_POST['server_email']) < 256) { + $this->updateSettingsFile(['webmaster_email' => (string) $_POST['server_email']]); + } else { + // One step back, this time fill out a proper webmaster email address. Maintenance::$fatal_error = Lang::getTxt('error_valid_server_email_needed', file: 'Maintenance'); $this->logProgress(Maintenance::$fatal_error); return false; } + // Create the admin account. if ($_POST['username'] != '') { Utils::$context['password_salt'] = bin2hex(random_bytes(16)); @@ -1066,13 +1073,15 @@ public function adminAccount(): bool 'secret_question' => 'string', 'additional_groups' => 'string', 'ignore_boards' => 'string', + 'spoofdetector_name' => 'string', + 'email_address_ci' => 'string', ], [ [ $_POST['username'], $_POST['username'], $_POST['password1'], - $_POST['email'], + (string) $_POST['email'], 1, 0, time(), @@ -1091,6 +1100,8 @@ public function adminAccount(): bool '', '', '', + Utils::htmlspecialchars(SpoofDetector::getSkeletonString(html_entity_decode($_POST['username'], ENT_QUOTES))), + $_POST['email']->casefolded(), ], ], ['id_member'], diff --git a/Sources/Maintenance/Tools/Upgrade.php b/Sources/Maintenance/Tools/Upgrade.php index 57efb8ffd8b..00c370d0777 100644 --- a/Sources/Maintenance/Tools/Upgrade.php +++ b/Sources/Maintenance/Tools/Upgrade.php @@ -176,6 +176,9 @@ class Upgrade extends ToolsBase implements ToolsInterface Migration\v3_0\DropModPrefs::class, Migration\v3_0\DropTimeOffset::class, Migration\v3_0\SpoofDetector::class, + Migration\v3_0\EmailAddressCi::class, + Migration\v3_0\NormalizeMemberEmailAddresses::class, + Migration\v3_0\NormalizeBannedEmailAddresses::class, Migration\v3_0\SearchResultsPrimaryKey::class, Migration\v3_0\MailType::class, Migration\v3_0\RemoveCookieTime::class, diff --git a/Sources/PackageManager/PackageManager.php b/Sources/PackageManager/PackageManager.php index 2c48b9ab8f2..1d6d3fc9c6c 100644 --- a/Sources/PackageManager/PackageManager.php +++ b/Sources/PackageManager/PackageManager.php @@ -16,6 +16,7 @@ use SMF\Cache\CacheApi; use SMF\Config; use SMF\Db\DatabaseApi as Db; +use SMF\EmailAddress; use SMF\ErrorHandler; use SMF\IntegrationHook; use SMF\ItemList; @@ -2680,7 +2681,7 @@ public function serverBrowse(): void if ($listing->exists('default-author')) { $default_author = Utils::htmlspecialchars($listing->fetch('default-author')); - if ($listing->exists('default-author/@email') && filter_var($listing->fetch('default-author/@email'), FILTER_VALIDATE_EMAIL)) { + if ($listing->exists('default-author/@email') && EmailAddress::create($listing->fetch('default-author/@email'))->isValid()) { $default_email = Utils::htmlspecialchars($listing->fetch('default-author/@email')); } } @@ -2803,7 +2804,7 @@ public function serverBrowse(): void $package['download']['link'] = '' . $package['name'] . ''; if ($thisPackage->exists('author') || isset($default_author)) { - if ($thisPackage->exists('author/@email') && filter_var($thisPackage->fetch('author/@email'), FILTER_VALIDATE_EMAIL)) { + if ($thisPackage->exists('author/@email') && EmailAddress::create($thisPackage->fetch('author/@email'))->isValid()) { $package['author']['email'] = $thisPackage->fetch('author/@email'); } elseif (isset($default_email)) { $package['author']['email'] = $default_email; diff --git a/Sources/Profile.php b/Sources/Profile.php index 0ddd3d12ceb..ec58b86c34a 100644 --- a/Sources/Profile.php +++ b/Sources/Profile.php @@ -1640,7 +1640,9 @@ public function validateEmail(string $email): bool|string return 'no_email'; } - if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + $email = new EmailAddress($email); + + if (!$email->isValid()) { return 'bad_email'; } @@ -1649,11 +1651,11 @@ public function validateEmail(string $email): bool|string 'SELECT id_member FROM {db_prefix}members WHERE id_member != {int:selected_member} - AND {column_ci:email_address} = {string:email_address} + AND email_address_ci = {string:email_address} LIMIT 1', [ 'selected_member' => $this->id, - 'email_address' => $email, + 'email_address' => $email->casefolded(), ], ); $return = Db::$db->num_rows($request) > 0 ? 'email_taken' : true; @@ -2355,7 +2357,7 @@ protected function prepareToSaveCustomFields(?string $area = null): void $cf_def['mask'] == 'email' && !empty($value) && ( - !filter_var($value, FILTER_VALIDATE_EMAIL) + !EmailAddress::create($value)->isValid() || \strlen($value) > 255 ) ) { @@ -2883,7 +2885,7 @@ protected function setAvatarGravatar(): void if ( empty($_POST['gravatarEmail']) || empty(Config::$modSettings['gravatarAllowExtraEmail']) - || !filter_var($_POST['gravatarEmail'], FILTER_VALIDATE_EMAIL) + || !EmailAddress::create($_POST['gravatarEmail'])->isValid() ) { $this->new_data['avatar'] = 'gravatar://'; } else { diff --git a/Sources/Security.php b/Sources/Security.php index 9579358084e..e59df225e36 100644 --- a/Sources/Security.php +++ b/Sources/Security.php @@ -574,9 +574,11 @@ public static function checkBans(User $user, bool $force_check = false): array } // Is their email address banned? - if (\strlen($user->email ?? '') > 0) { + $folded_email = EmailAddress::create($user->email ?? '')->casefolded(); + + if (\strlen($folded_email) > 0) { $ban_query[] = '({string:email} LIKE bi.email_address)'; - $ban_query_vars['email'] = $user->email; + $ban_query_vars['email'] = $folded_email; } // How about this user? diff --git a/Sources/Subscriptions/PayPal/Payment.php b/Sources/Subscriptions/PayPal/Payment.php index 98f5cbe2960..33c448c1378 100644 --- a/Sources/Subscriptions/PayPal/Payment.php +++ b/Sources/Subscriptions/PayPal/Payment.php @@ -17,6 +17,7 @@ use SMF\Config; use SMF\Db\DatabaseApi as Db; +use SMF\EmailAddress; use SMF\Lang; /** @@ -315,10 +316,10 @@ private function _findSubscription(): bool 'SELECT ls.id_member, ls.id_subscribe FROM {db_prefix}log_subscribed AS ls INNER JOIN {db_prefix}members AS mem ON (mem.id_member = ls.id_member) - WHERE mem.email_address = {string:payer_email} + WHERE mem.email_address_ci = {string:payer_email} LIMIT 1', [ - 'payer_email' => $_POST['payer_email'], + 'payer_email' => EmailAddress::create($_POST['payer_email'])->casefolded(), ], ); diff --git a/Sources/User.php b/Sources/User.php index a7a380d54e8..5c79f18da5a 100644 --- a/Sources/User.php +++ b/Sources/User.php @@ -1300,6 +1300,7 @@ class User implements \ArrayAccess 'tfa_secret' => 'string', 'tfa_backup' => 'string', 'spoofdetector_name' => 'string', + 'email_address_ci' => 'string', ]; /********************* @@ -3319,6 +3320,30 @@ public static function saveBatch(array $members): void break; + case 'email_address': + if (isset($member->email)) { + $email = new EmailAddress($member->email, true); + + // Don't update unless the email is valid. + if ($email->isValid()) { + $params[$column . '_' . $member->id] = (string) $email; + } + } + + break; + + case 'email_address_ci': + if (isset($member->email)) { + $email = new EmailAddress($member->email, true); + + // Don't update unless the email is valid. + if ($email->isValid()) { + $params[$column . '_' . $member->id] = $email->casefolded(); + } + } + + break; + case 'spoofdetector_name': if (isset($member->name)) { $params[$column . '_' . $member->id] = Utils::htmlspecialchars(Unicode\SpoofDetector::getSkeletonString(html_entity_decode($member->name, ENT_QUOTES))); @@ -3337,7 +3362,6 @@ public static function saveBatch(array $members): void $prop = match ($column) { 'member_name' => 'username', 'real_name' => 'name', - 'email_address' => 'email', 'usertitle' => 'title', 'instant_messages' => 'messages', 'id_theme' => 'theme', @@ -3744,101 +3768,141 @@ public static function delete(int|array $users, bool $protect_admins = false, bo } /** - * Finds members by email address, username, or real name. - * - * Searches for members whose username, display name, or e-mail address - * match the given pattern of array names. + * Finds members by email address, username, or display name. * * Searches only buddies if $buddies_only is set. * * @param string|array $names The names of members to search for. - * @param bool $use_wildcards Whether to use wildcards. Accepts wildcards - * '?' and '*' in the pattern if true. - * @param bool $buddies_only Whether to only search for the user's buddies. + * @param bool $use_wildcards Whether to accept wildcards in the pattern. + * Default: false. + * @param bool $buddies_only Whether to search only for this user's buddies. + * Default: false. * @param int $max The maximum number of results. + * Default: 500. + * @param bool $ids_only If true, return just the IDs of the found members. + * Default: false. * @return array Information about the matching members. */ - public static function find(string|array $names, bool $use_wildcards = false, bool $buddies_only = false, int $max = 500): array - { - // If it's not already an array, make it one. - if (!\is_array($names)) { - $names = explode(',', $names); + public static function find( + string|array $names, + bool $use_wildcards = false, + bool $buddies_only = false, + int $max = 500, + bool $ids_only = false, + ): array { + if ($use_wildcards) { + $member_name_query_pattern = '{column_ci:member_name} LIKE {string_ci:%s}'; + $real_name_query_pattern = '{column_ci:real_name} LIKE {string_ci:%s}'; + $email_query_pattern = 'email_address_ci LIKE {string:%s}'; + $wildcard_replacements = [ + '%' => '\\%', + '_' => '\\_', + '*' => '%', + '?' => '_', + '\'' => ''', + ]; + } else { + $member_name_query_pattern = '{column_ci:member_name} = {string_ci:%s}'; + $real_name_query_pattern = '{column_ci:real_name} = {string_ci:%s}'; + $email_query_pattern = 'email_address_ci = {string:%s}'; + $wildcard_replacements = [ + '\'' => ''', + ]; } - $maybe_email = false; - $names_list = []; + $where = []; - foreach (array_values($names) as $i => $name) { - // Trim, and fix wildcards for each name. - $names[$i] = trim(Utils::strtolower($name)); + $params = [ + 'buddy_list' => !empty(Config::$modSettings['enable_buddylist']) ? self::$me->buddies : [], + 'limit' => $max, + 'activated' => [self::ACTIVATED, self::ACTIVATED_BANNED], + ]; - $maybe_email |= strpos($name, '@') !== false; + $names = array_values( + array_filter( + array_map( + fn($name) => trim((string) $name), + \is_array($names) ? $names : explode(',', $names), + ), + fn($name) => \strlen($name) > 0, + ), + ); - // Make it so standard wildcards will work. (* and ?) - if ($use_wildcards) { - $names[$i] = strtr($names[$i], ['%' => '\\%', '_' => '\\_', '*' => '%', '?' => '_', '\'' => ''']); - } else { - $names[$i] = strtr($names[$i], ['\'' => ''']); - } + $can_search_emails = self::$me->allowedTo('moderate_forum'); - $names_list[] = '{string:lookup_name_' . $i . '}'; - $where_params['lookup_name_' . $i] = $names[$i]; - } + foreach ($names as $i => $name) { + if ($can_search_emails && str_contains($name, '@')) { + $email = new EmailAddress($name, true); - // What are we using to compare? - $comparison = $use_wildcards ? 'LIKE' : '='; + // If it's a valid email, search for it as one. + if ($email->isValid()) { + $where[] = \sprintf($email_query_pattern, 'lookup_email_' . $i); - // Nothing found yet. - $results = []; + $params['lookup_email_' . $i] = strtr( + $email->casefolded(), + $wildcard_replacements, + ); + } + // If it's invalid because a wildcard is in the domain part, + // then manually add it to our email search. + elseif ( + strpbrk($email->ascii_domain_part, '*?') !== false + && $email->local_part !== '' + ) { + $where[] = \sprintf($email_query_pattern, 'lookup_email_' . $i); + + $params['lookup_email_' . $i] = strtr( + Utils::convertCase($email->local_part, 'fold') . '@' . $email->ascii_domain_part, + $wildcard_replacements, + ); + } + } - // This ensures you can't search someone's email address if you can't see it. - if (($use_wildcards || $maybe_email) && self::$me->allowedTo('moderate_forum')) { - $email_condition = ' - OR (email_address ' . $comparison . ' \'' . implode('\') OR (email_address ' . $comparison . ' \'', $names) . '\')'; - } else { - $email_condition = ''; - } + $where[] = \sprintf($member_name_query_pattern, 'lookup_name_' . $i); + $where[] = \sprintf($real_name_query_pattern, 'lookup_name_' . $i); - // The {column_ci:} type folds the column for the engines that need it and - // leaves it alone for the ones that do not. - $member_name = '{column_ci:member_name}'; - $real_name = '{column_ci:real_name}'; + $params['lookup_name_' . $i] = strtr($name, $wildcard_replacements); + } - // Searches. - $member_name_search = $member_name . ' ' . $comparison . ' ' . implode(' OR ' . $member_name . ' ' . $comparison . ' ', $names_list); + $where = [ + '(' . implode(' OR ', $where) . ')', + 'is_activated IN ({array_int:activated})', + ]; - $real_name_search = $real_name . ' ' . $comparison . ' ' . implode(' OR ' . $real_name . ' ' . $comparison . ' ', $names_list); + if ($buddies_only) { + $where[] = 'id_member IN ({array_int:buddy_list})'; + } - // Search by username, display name, and email address. - $request = Db::$db->query( - 'SELECT id_member, member_name, real_name, email_address - FROM {db_prefix}members - WHERE (' . $member_name_search . ' - OR ' . $real_name_search . ' ' . $email_condition . ') - ' . ($buddies_only ? 'AND id_member IN ({array_int:buddy_list})' : '') . ' - AND is_activated IN ({array_int:activated}) - LIMIT {int:limit}', - array_merge($where_params, [ - 'buddy_list' => !empty(Config::$modSettings['enable_buddylist']) ? self::$me->buddies : [], - 'limit' => $max, - 'activated' => [self::ACTIVATED, self::ACTIVATED_BANNED], - ]), - ); + if ($ids_only) { + $request = Db::$db->query( + 'SELECT id_member + FROM {db_prefix}members + WHERE ' . implode(' AND ', $where) . ' + LIMIT {int:limit}', + $params, + ); + $found = array_map(fn($row) => $row['id_member'], Db::$db->fetch_all($request)); + Db::$db->free_result($request); + } else { + $found = array_map( + fn($member) => $member->format(), + self::loadCustom( + query_customizations: [ + 'where' => $where, + 'params' => $params, + ], + dataset: UserDataset::Minimal, + ), + ); - while ($row = Db::$db->fetch_assoc($request)) { - $results[$row['id_member']] = [ - 'id' => $row['id_member'], - 'name' => $row['real_name'], - 'username' => $row['member_name'], - 'email' => self::$me->allowedTo('moderate_forum') ? $row['email_address'] : '', - 'href' => Config::$scripturl . '?action=profile;u=' . $row['id_member'], - 'link' => '' . $row['real_name'] . '', - ]; + foreach ($found as $k => $formatted) { + if (!$formatted['show_email']) { + $found[$k]['email'] = ''; + } + } } - Db::$db->free_result($request); - // Return all the results. - return $results; + return $found; } /** @@ -4278,28 +4342,73 @@ protected function setProperties(bool $reset = false): void $key, [ // All the standard data. - 'additional_groups', 'alerts', 'attachment_height', - 'attachment_type', 'attachment_width', 'avatar', - 'avatar_original', 'birthdate', 'buddy_list', - 'dataset', 'date_registered', 'email_address', - 'filename', 'icons', 'id_attach', 'id_group', - 'id_member', 'id_msg_last_visit', 'id_post_group', - 'id_theme', 'ignore_boards', 'instant_messages', - 'is_activated', 'is_online', 'last_login', 'lngfile', - 'member_group', 'member_group_color', 'member_ip', - 'member_ip2', 'member_name', 'new_pm', 'options', - 'passwd', 'passwd_flood', 'password_salt', - 'personal_text', 'pm_ignore_list', 'pm_prefs', - 'pm_receive_from', 'post_group', 'post_group_color', - 'posts', 'primary_group', 'real_name', - 'secret_answer', 'secret_question', 'show_online', - 'signature', 'smiley_set', 'spoofdetector_name', - 'tfa_backup', 'tfa_secret', 'time_format', 'timezone', - 'total_time_logged_in', 'unread_messages', 'url', - 'usertitle', 'validation_code', 'warning', - 'website_title', 'website_url', + 'additional_groups', + 'alerts', + 'attachment_height', + 'attachment_type', + 'attachment_width', + 'avatar', + 'avatar_original', + 'birthdate', + 'buddy_list', + 'dataset', + 'date_registered', + 'email_address', + 'email_address_ci', + 'filename', + 'icons', + 'id_attach', + 'id_group', + 'id_member', + 'id_msg_last_visit', + 'id_post_group', + 'id_theme', + 'ignore_boards', + 'instant_messages', + 'is_activated', + 'is_online', + 'last_login', + 'lngfile', + 'member_group', + 'member_group_color', + 'member_ip', + 'member_ip2', + 'member_name', + 'new_pm', + 'options', + 'passwd', + 'passwd_flood', + 'password_salt', + 'personal_text', + 'pm_ignore_list', + 'pm_prefs', + 'pm_receive_from', + 'post_group', + 'post_group_color', + 'posts', + 'primary_group', + 'real_name', + 'secret_answer', + 'secret_question', + 'show_online', + 'signature', + 'smiley_set', + 'spoofdetector_name', + 'tfa_backup', + 'tfa_secret', + 'time_format', + 'timezone', + 'total_time_logged_in', + 'unread_messages', + 'url', + 'usertitle', + 'validation_code', + 'warning', + 'website_title', + 'website_url', // Obsolete data. Ignore if present. - 'mod_prefs', 'time_offset', + 'mod_prefs', + 'time_offset', ], ) ) { @@ -5419,8 +5528,11 @@ protected static function addQueryCustomizationsForLoadType(array &$query_custom { switch ($type) { case self::LOAD_BY_EMAIL: - $query_customizations['where'][] = 'mem.email_address IN ({array_string:users})'; - $query_customizations['params']['users'] = $users; + $query_customizations['where'][] = 'mem.email_address_ci IN ({array_string:users})'; + $query_customizations['params']['users'] = array_filter(array_map( + fn($email) => EmailAddress::create($email)->casefolded(), + $users, + )); break; case self::LOAD_BY_NAME: diff --git a/Sources/Utils.php b/Sources/Utils.php index f81c82cb926..1a084825fe7 100644 --- a/Sources/Utils.php +++ b/Sources/Utils.php @@ -303,7 +303,7 @@ class Utils 'normalize' => __CLASS__ . '::normalize', 'truncate' => __CLASS__ . '::truncate', 'json_encode' => __CLASS__ . '::jsonEncode', - 'json_decode' => 'smf_json_decode', + 'json_decode' => __CLASS__ . '::jsonDecode', 'random_int' => __CLASS__ . '::randomInt', 'random_bytes' => __CLASS__ . '::randomBytes', ];