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',
];