Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a696a78
Fixes an incorrect alias in Utils::$smcFunc
Sesquipedalian Sep 4, 2026
b365000
Implements SMF\EmailAddress class
Sesquipedalian Sep 1, 2026
e79ada9
Sends email to the form of the address with the best deliverability
Sesquipedalian Sep 2, 2026
4534a78
Validates the to address in MailAgent APIs
Sesquipedalian Sep 2, 2026
0d7f41a
Always uses "\r\n" line breaks in email messages
Sesquipedalian Sep 2, 2026
83222f2
Uses SMF\EmailAddress to validate email addresses
Sesquipedalian Sep 3, 2026
e266b8f
Adds email_address_ci column to members table
Sesquipedalian Sep 2, 2026
c293727
Adds migration step to populate email_address_ci
Sesquipedalian Sep 2, 2026
ea840f0
Enables saving email_address_ci in SMF\User
Sesquipedalian Sep 4, 2026
17f44c4
Uses email_address_ci to load members by email in SMF\User
Sesquipedalian Sep 4, 2026
c007903
Refactors SMF\User::find() to use email_address_ci
Sesquipedalian Sep 4, 2026
e544436
Uses email_address_ci to prevent duplicate email addresses
Sesquipedalian Sep 4, 2026
70a2a7d
Uses email_address_ci when searching by email address
Sesquipedalian Sep 4, 2026
f142598
Uses email_address_ci when sorting by email address
Sesquipedalian Sep 4, 2026
517b25a
Adds migration step to normalize banned email addresses
Sesquipedalian Sep 4, 2026
065d908
Uses casefolded email addresses when working with bans
Sesquipedalian Sep 4, 2026
c5acb77
Protects against idn_to_ascii() failure in EmailAddress::__construct()
Sesquipedalian Sep 5, 2026
1251891
Sets email_address_ci when registering a new member
Sesquipedalian Sep 5, 2026
715455f
Sets admin's spoofdetector_name and email_address_ci during install
Sesquipedalian Sep 5, 2026
fe454d4
Queries correct table in NormalizeBannedEmailAddresses::getMax()
Sesquipedalian Sep 6, 2026
7ee5156
Uses correct param when searching by email in memberlist
Sesquipedalian Sep 6, 2026
027976e
Protects against uninitialized $user->email in Security::checkBans()
Sesquipedalian Sep 6, 2026
19ac8af
Escapes literal `%` and `_` in email ban patterns
Sesquipedalian Sep 6, 2026
038970f
Requires moderate_forum permission to search by email in User::find()
Sesquipedalian Sep 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Languages/en_US/General.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
11 changes: 7 additions & 4 deletions Sources/Actions/Activate.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -309,18 +310,20 @@ 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);
}

// Ummm... don't even dare try to take someone else's email!!
$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(),
],
);

Expand All @@ -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);
Expand Down
35 changes: 26 additions & 9 deletions Sources/Actions/Admin/Bans.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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}',
Expand Down Expand Up @@ -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,
Expand All @@ -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));
Expand Down Expand Up @@ -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',
Expand Down
5 changes: 2 additions & 3 deletions Sources/Actions/Admin/Maintenance.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] : '';
Expand Down
22 changes: 16 additions & 6 deletions Sources/Actions/Admin/Members.php
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ public function view(): void
'type' => 'string',
],
'email' => [
'db_fields' => ['email_address'],
'db_fields' => ['email_address_ci'],
'type' => 'string',
],
'website' => [
Expand Down Expand Up @@ -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), ['%' => '\\%', '_' => '\\_', '*' => '%', '?' => '_']));

Expand Down Expand Up @@ -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' => [
Expand Down Expand Up @@ -938,8 +948,8 @@ function onSelectChange()
],
],
'sort' => [
'default' => 'email_address',
'reverse' => 'email_address DESC',
'default' => 'email_address_ci',
'reverse' => 'email_address_ci DESC',
],
],
'ip' => [
Expand Down
17 changes: 9 additions & 8 deletions Sources/Actions/Admin/News.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));
}
}

Expand Down Expand Up @@ -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);
Expand All @@ -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,
);

Expand Down Expand Up @@ -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();
}
}
}
Expand Down
10 changes: 6 additions & 4 deletions Sources/Actions/Admin/Subscriptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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...
Expand Down
2 changes: 1 addition & 1 deletion Sources/Actions/Groups.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
26 changes: 22 additions & 4 deletions Sources/Actions/Memberlist.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 . ')';
}
}
Expand All @@ -547,15 +555,23 @@ 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(*)
FROM {db_prefix}members AS mem
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,
);
Expand Down Expand Up @@ -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}',
Expand Down
3 changes: 2 additions & 1 deletion Sources/Actions/Post2.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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';
}
}
Expand Down
Loading