FEATURE: Add email normalization rules setting (#14593)

FEATURE: Add email normalization rules setting (#14593)

When this setting is turned on, it will check that normalized emails are unique. Normalized emails are emails without any dots or plus aliases.

This setting can be used to block use of aliases of the same email address.

diff --git a/app/jobs/onceoff/migrate_normalized_emails.rb b/app/jobs/onceoff/migrate_normalized_emails.rb
new file mode 100644
index 0000000..4b1ff23
--- /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 6ab955c..4bb2e64 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 191dcf3..6db14aa 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 <a href='%{base_path}/admin/customize/robots'>override robots.txt</a>."
     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 2b31929..9ea8cf7 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 0000000..76d0226
--- /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 ac821c7..84440b9 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 {

GitHub sha: 3ea893715726c3516b3b222c77f3cc9d8ee3c1c6

This commit appears in #14593 which was approved by eviltrout and ZogStriP. It was merged by nbianca.