DEV: pull email address validation out to a new EmailAddressValidator

We validate the *format* of email addresses in many places with a match against
a regex, often with very slightly different syntax.

Adding a separate EmailAddressValidator simplifies the code in a few spots and
feels cleaner.

Deprecated the old location in case someone is using it in a plugin.

No functionality change is in this commit.

Note: the regex used at the moment does not support using address literals, e.g.:
* localpart@[192.168.0.1]
* localpart@[2001:db8::1]
This commit is contained in:
Michael Brown 2022-02-17 20:12:51 -05:00 committed by Michael Brown
parent e54b70460e
commit 3bf3b9a4a5
16 changed files with 51 additions and 31 deletions

View File

@ -546,7 +546,7 @@ class SessionController < ApplicationController
RateLimiter.new(nil, "forgot-password-min-#{request.remote_ip}", 3, 1.minute).performed!
user = if SiteSetting.hide_email_address_taken && !current_user&.staff?
raise Discourse::InvalidParameters.new(:login) if EmailValidator.email_regex !~ normalized_login_param
raise Discourse::InvalidParameters.new(:login) if !EmailAddressValidator.valid_value?(normalized_login_param)
User.real.where(staged: false).find_by_email(Email.downcase(normalized_login_param))
else
User.real.where(staged: false).find_by_username_or_email(normalized_login_param)

View File

@ -600,7 +600,7 @@ class UsersController < ApplicationController
return render json: success_json
end
if !(email =~ EmailValidator.email_regex)
if !EmailAddressValidator.valid_value?(email)
error = User.new.errors.full_message(:email, I18n.t(:'user.email.invalid'))
return render json: failed_json.merge(errors: [error])
end

View File

@ -37,7 +37,7 @@ module Jobs
def process_invites(invites)
invites.each do |invite|
if (EmailValidator.email_regex =~ invite[:email])
if EmailAddressValidator.valid_value?(invite[:email])
# email is valid
send_invite(invite)
@sent += 1

View File

@ -42,9 +42,9 @@ module Jobs
return skip(email, post, recipient_user, :group_smtp_topic_deleted)
end
cc_addresses = args[:cc_emails].map do |cc|
cc.match(EmailValidator.email_regex) ? cc : nil
end.compact
cc_addresses = args[:cc_emails].filter do |address|
EmailAddressValidator.valid_value?(address)
end
# There is a rare race condition causing the Imap::Sync class to create
# an incoming email and associated post/topic, which then kicks off

View File

@ -6,7 +6,7 @@ class EmailChangeRequest < ActiveRecord::Base
belongs_to :new_email_token, class_name: 'EmailToken', dependent: :destroy
belongs_to :requested_by, class_name: "User", foreign_key: :requested_by_user_id
validates :new_email, presence: true, format: { with: EmailValidator.email_regex }
validates :new_email, presence: true, format: { with: EmailAddressValidator.email_regex }
def self.states
@states ||= Enum.new(authorizing_old: 1, authorizing_new: 2, complete: 3)

View File

@ -1151,7 +1151,7 @@ class User < ActiveRecord::Base
end
def find_email
last_sent_email_address.present? && EmailValidator.email_regex =~ last_sent_email_address ? last_sent_email_address : email
last_sent_email_address.present? && EmailAddressValidator.valid_value?(last_sent_email_address) ? last_sent_email_address : email
end
def tl3_requirements

View File

@ -10,7 +10,7 @@ module Email
def self.is_valid?(email)
return false unless String === email
!!(EmailValidator.email_regex =~ email)
EmailAddressValidator.valid_value?(email)
end
def self.downcase(email)

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
class EmailAddressValidator
def self.valid_value?(email)
email.match? email_regex
end
def self.email_regex
/\A[a-zA-Z0-9!#\$%&'*+\/=?\^_`{|}~\-]+(?:\.[a-zA-Z0-9!#\$%&'\*+\/=?\^_`{|}~\-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9\-]*[a-zA-Z0-9])?$\z/
end
end

View File

@ -6,7 +6,8 @@ class EmailSettingValidator
end
def valid_value?(val)
!val.present? || !!(EmailValidator.email_regex =~ val)
return true if val.blank?
EmailAddressValidator.valid_value?(val)
end
def error_message

View File

@ -3,7 +3,7 @@
class EmailValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unless value =~ EmailValidator.email_regex
if !EmailAddressValidator.valid_value?(value)
if Invite === record && attribute == :email
record.errors.add(:base, I18n.t(:'invite.invalid_email', email: CGI.escapeHTML(value)))
else
@ -12,7 +12,7 @@ class EmailValidator < ActiveModel::EachValidator
invalid = true
end
unless EmailValidator.allowed?(value)
if !EmailValidator.allowed?(value)
record.errors.add(attribute, I18n.t(:'user.email.not_allowed'))
invalid = true
end
@ -51,7 +51,11 @@ class EmailValidator < ActiveModel::EachValidator
end
def self.email_regex
/\A[a-zA-Z0-9!#\$%&'*+\/=?\^_`{|}~\-]+(?:\.[a-zA-Z0-9!#\$%&'\*+\/=?\^_`{|}~\-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9\-]*[a-zA-Z0-9])?$\z/
Discourse.deprecate(
"EmailValidator.email_regex is deprecated. Please use EmailAddressValidator instead.",
output_in_test: true,
drop_from: '2.9.0',
)
EmailAddressValidator.email_regex
end
end

View File

@ -452,7 +452,7 @@ class BulkImport::Base
user_email[:email] ||= random_email
user_email[:email].downcase!
# unique email
user_email[:email] = random_email until user_email[:email] =~ EmailValidator.email_regex && !@emails.has_key?(user_email[:email])
user_email[:email] = random_email until EmailAddressValidator.valid_value?(user_email[:email]) && !@emails.has_key?(user_email[:email])
user_email
end

View File

@ -320,7 +320,7 @@ class ImportScripts::Base
opts[:username] = UserNameSuggester.suggest(opts[:username].presence || opts[:name].presence || opts[:email])
end
unless opts[:email][EmailValidator.email_regex]
if !EmailAddressValidator.valid_value?(opts[:email])
opts[:email] = fake_email
puts "Invalid email '#{original_email}' for '#{opts[:username]}'. Using '#{opts[:email]}'"
end

View File

@ -76,7 +76,7 @@ class ImportScripts::Drupal < ImportScripts::Base
create_users(users, total: user_count, offset: offset) do |user|
email = user["email"].presence || fake_email
email = fake_email unless email[EmailValidator.email_regex]
email = fake_email if !EmailAddressValidator.valid_value?(email)
username = @htmlentities.decode(user["username"]).strip

View File

@ -151,7 +151,7 @@ EOM
create_users(users, total: user_count, offset: offset) do |user|
email = user["email"].presence || fake_email
email = fake_email unless email[EmailValidator.email_regex]
email = fake_email if !EmailAddressValidator.valid_value?(email)
password = [user["password"].presence, user["salt"].presence].compact.join(":")

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
require 'rails_helper'
describe EmailAddressValidator do
it 'should match valid emails' do
['test@discourse.org', 'good_user@discourse.org', 'incoming+%{reply_key}@discourse.org'].each do |email|
expect(EmailAddressValidator.valid_value?(email)).to eq(true)
end
end
it 'should not match invalid emails' do
['testdiscourse.org', 'frank@invalid_host.contoso.com', 'frank@invalid_host.com', 'test@discourse.org; a@discourse.org', 'random', ''].each do |email|
expect(EmailAddressValidator.valid_value?(email)).to eq(false)
end
end
end

View File

@ -57,17 +57,4 @@ describe EmailValidator do
expect(EmailValidator.can_auto_approve_user?("foobar@googlemail.com")).to eq(true)
end
end
context '.email_regex' do
it 'should match valid emails' do
expect(!!('test@discourse.org' =~ EmailValidator.email_regex)).to eq(true)
end
it 'should not match invalid emails' do
['testdiscourse.org', 'test@discourse.org; a@discourse.org', 'random'].each do |email|
expect(!!(email =~ EmailValidator.email_regex)).to eq(false)
end
end
end
end