diff --git a/app/jobs/onceoff/migrate_normalized_emails.rb b/app/jobs/onceoff/migrate_normalized_emails.rb new file mode 100644 index 00000000000..4b1ff23c0fe --- /dev/null +++ b/app/jobs/onceoff/migrate_normalized_emails.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Jobs + class MigrateNormalizedEmails < ::Jobs::Onceoff + def execute_onceoff(args) + ::UserEmail.find_each do |user_email| + user_email.update(normalized_email: user_email.normalize_email) + end + end + end +end diff --git a/app/models/user_email.rb b/app/models/user_email.rb index 6ab955c95a7..4bb2e64e9ab 100644 --- a/app/models/user_email.rb +++ b/app/models/user_email.rb @@ -7,6 +7,7 @@ class UserEmail < ActiveRecord::Base attr_accessor :skip_validate_unique_email before_validation :strip_downcase_email + before_validation :normalize_email validates :email, presence: true validates :email, email: true, if: :validate_email? @@ -17,6 +18,14 @@ class UserEmail < ActiveRecord::Base scope :secondary, -> { where(primary: false) } + def normalize_email + self.normalized_email = if self.email.present? + username, domain = self.email.split('@', 2) + username = username.gsub('.', '').gsub(/\+.*/, '') + "#{username}@#{domain}" + end + end + private def strip_downcase_email @@ -37,9 +46,13 @@ class UserEmail < ActiveRecord::Base end def unique_email - if self.class.where("lower(email) = ?", email).exists? - self.errors.add(:email, :taken) + email_exists = if SiteSetting.normalize_emails? + self.class.where("lower(email) = ? OR lower(normalized_email) = ?", email, normalized_email).exists? + else + self.class.where("lower(email) = ?", email).exists? end + + self.errors.add(:email, :taken) if email_exists end def user_id_not_changed @@ -55,16 +68,18 @@ end # # Table name: user_emails # -# id :integer not null, primary key -# user_id :integer not null -# email :string(513) not null -# primary :boolean default(FALSE), not null -# created_at :datetime not null -# updated_at :datetime not null +# id :integer not null, primary key +# user_id :integer not null +# email :string(513) not null +# primary :boolean default(FALSE), not null +# created_at :datetime not null +# updated_at :datetime not null +# normalized_email :string # # Indexes # # index_user_emails_on_email (lower((email)::text)) UNIQUE +# index_user_emails_on_normalized_email (lower((normalized_email)::text)) # index_user_emails_on_user_id (user_id) # index_user_emails_on_user_id_and_primary (user_id,primary) UNIQUE WHERE "primary" # diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 191dcf3f4a9..6db14aa86bb 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -1649,6 +1649,7 @@ en: allow_index_in_robots_txt: "Specify in robots.txt that this site is allowed to be indexed by web search engines. In exceptional cases you can permanently override robots.txt." blocked_email_domains: "A pipe-delimited list of email domains that users are not allowed to register accounts with. Example: mailinator.com|trashmail.net" allowed_email_domains: "A pipe-delimited list of email domains that users MUST register accounts with. WARNING: Users with email domains other than those listed will not be allowed!" + normalize_emails: "Check if normalized email is unique. Normalized email removes all dots from the username and everything between + and @ symbols." auto_approve_email_domains: "Users with email addresses from this list of domains will be automatically approved." hide_email_address_taken: "Don't inform users that an account exists with a given email address during signup and from the forgot password form." log_out_strict: "When logging out, log out ALL sessions for the user on all devices" diff --git a/config/site_settings.yml b/config/site_settings.yml index 2b319294446..9ea8cf78497 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -510,6 +510,8 @@ login: default: "" type: list list_type: simple + normalize_emails: + default: false auto_approve_email_domains: default: "" type: list diff --git a/db/migrate/20211013092406_add_normalized_email_to_user_email.rb b/db/migrate/20211013092406_add_normalized_email_to_user_email.rb new file mode 100644 index 00000000000..76d022674f6 --- /dev/null +++ b/db/migrate/20211013092406_add_normalized_email_to_user_email.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class AddNormalizedEmailToUserEmail < ActiveRecord::Migration[6.1] + def change + add_column :user_emails, :normalized_email, :string + execute "CREATE INDEX index_user_emails_on_normalized_email ON user_emails (LOWER(normalized_email))" + end + + def down + execute "DROP INDEX index_user_emails_on_normalized_email" + drop_column :user_emails, :normalized_email, :string + end +end diff --git a/spec/models/user_email_spec.rb b/spec/models/user_email_spec.rb index ac821c7a5df..84440b9ce7c 100644 --- a/spec/models/user_email_spec.rb +++ b/spec/models/user_email_spec.rb @@ -26,6 +26,32 @@ describe UserEmail do end end + describe 'normalized_email' do + it 'checks if normalized email is unique' do + SiteSetting.normalize_emails = true + + user_email = user.user_emails.create(email: "a.b+c@example.com", primary: false) + expect(user_email.normalized_email).to eq("ab@example.com") + expect(user_email).to be_valid + + user_email = user.user_emails.create(email: "a.b+d@example.com", primary: false) + expect(user_email.normalized_email).to eq("ab@example.com") + expect(user_email).not_to be_valid + end + + it 'does not check uniqueness if email normalization is not enabled' do + SiteSetting.normalize_emails = false + + user_email = user.user_emails.create(email: "a.b+c@example.com", primary: false) + expect(user_email.normalized_email).to eq("ab@example.com") + expect(user_email).to be_valid + + user_email = user.user_emails.create(email: "a.b+d@example.com", primary: false) + expect(user_email.normalized_email).to eq("ab@example.com") + expect(user_email).to be_valid + end + end + context "indexes" do it "allows only one primary email" do expect {