FEATURE: enforce_canonical_emails site setting

FEATURE: enforce_canonical_emails site setting

The new enforce_canonical_emails site setting ensures that emails in the canonical form are unique.

This mean that if s.a.m+1@gmail.com is registered sam@gmail.com will not be allowed.

The commit contains a blanket “tag strip” (stripping everything after +) it also contains special handling of a “dot strip” for googlemail and gmail.

The setting only impacts new registrations after enforce_canonical_emails

The setting is default false so it will not impact any existing installs.

diff --git a/app/models/user_email.rb b/app/models/user_email.rb
index e6fdaa8..5703216 100644
--- a/app/models/user_email.rb
+++ b/app/models/user_email.rb
@@ -15,10 +15,27 @@ class UserEmail < ActiveRecord::Base
   validate :user_id_not_changed, if: :primary
   validate :unique_email
 
+  before_save :save_canonical
+
   scope :secondary, -> { where(primary: false) }
 
+  def self.canonical(email)
+    name, domain = email.split('@', 2)
+    name = name.gsub(/\+.*/, '')
+    if ['gmail.com', 'googlemail.com'].include?(domain.downcase)
+      name = name.gsub('.', '')
+    end
+    "#{name}@#{domain}".downcase
+  end
+
   private
 
+  def save_canonical
+    if SiteSetting.enforce_canonical_emails && self.will_save_change_to_email?
+      self.canonical_email = UserEmail.canonical(self.email)
+    end
+  end
+
   def strip_downcase_email
     if self.email
       self.email = self.email.strip
@@ -32,8 +49,14 @@ class UserEmail < ActiveRecord::Base
   end
 
   def unique_email
-    if self.will_save_change_to_email? && self.class.where("lower(email) = ?", email).exists?
-      self.errors.add(:email, :taken)
+    if self.will_save_change_to_email?
+      exists = self.class.where("lower(email) = ?", email).exists?
+      exists ||= SiteSetting.enforce_canonical_emails &&
+        self.class.where("canonical_email = ?", UserEmail.canonical(email)).exists?
+
+      if exists
+        self.errors.add(:email, :taken)
+      end
     end
   end
 
@@ -50,15 +73,17 @@ 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
+#  canonical_email :string
 #
 # Indexes
 #
+#  index_user_emails_on_canonical_email      (canonical_email) WHERE (canonical_email IS NOT NULL)
 #  index_user_emails_on_email                (lower((email)::text)) UNIQUE
 #  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 df6709c..ffd3928 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -1582,6 +1582,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>."
     email_domains_blacklist: "A pipe-delimited list of email domains that users are not allowed to register accounts with. Example: mailinator.com|trashmail.net"
     email_domains_whitelist: "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!"
+    enforce_canonical_emails: "Prior to creating users strip email to canonical form ensuring uniqueness. Setting will only take effect on new account registrations. When set user+1@domain.com will not be allowed to register if user+2@domain.com is already registered."
     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 c5b7a3d..e9175a3 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -459,6 +459,7 @@ login:
   email_domains_whitelist:
     default: ""
     type: list
+  enforce_canonical_emails: false
   auto_approve_email_domains:
     default: ""
     type: list
diff --git a/db/migrate/20200414034210_add_canonical_email_to_user_email.rb b/db/migrate/20200414034210_add_canonical_email_to_user_email.rb
new file mode 100644
index 0000000..7033f55
--- /dev/null
+++ b/db/migrate/20200414034210_add_canonical_email_to_user_email.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+class AddCanonicalEmailToUserEmail < ActiveRecord::Migration[6.0]
+  def change
+    add_column :user_emails, :canonical_email, :string, length: 513
+    add_index :user_emails, :canonical_email, where: 'canonical_email IS NOT NULL'
+  end
+end
diff --git a/spec/jobs/user_email_spec.rb b/spec/jobs/user_email_spec.rb
index e6def35..3cea6c3 100644
--- a/spec/jobs/user_email_spec.rb
+++ b/spec/jobs/user_email_spec.rb
@@ -672,4 +672,32 @@ describe Jobs::UserEmail do
     end
 
   end
+
+  context "canonical emails" do
+    it "correctly creates canonical emails" do
+      expect(UserEmail.canonical('s.a.m+1@gmail.com')).to eq('sam@gmail.com')
+      expect(UserEmail.canonical('sa.m+1@googlemail.com')).to eq('sam@googlemail.com')
+      expect(UserEmail.canonical('sa.m+1722@sam.com')).to eq('sa.m@sam.com')
+      expect(UserEmail.canonical('sa.m@sam.com')).to eq('sa.m@sam.com')
+    end
+
+    it "correctly bans non canonical emails" do
+
+      email = UserEmail.create!(email: 'sam@sam.com', user_id: user.id)
+      expect(email.canonical_email).to eq(nil)
+
+      email = UserEmail.create!(email: 'sam+1@sam.com', user_id: user.id)
+      expect(email.canonical_email).to eq(nil)
+
+      SiteSetting.enforce_canonical_emails = true
+
+      email = UserEmail.create!(email: 'Sam+5@sam.com', user_id: user.id)
+      expect(email.canonical_email).to eq('sam@sam.com')
+
+      expect do
+        UserEmail.create!(email: 'saM+3@sam.com', user_id: user.id)
+      end.to raise_error(ActiveRecord::RecordInvalid)
+
+    end
+  end
 end

GitHub sha: 6f9177e2

This commit has been mentioned on Discourse Meta. There might be relevant details there:

Shouldn’t this be a unique index?