0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-08 13:25:19 +08:00
discourse/app/models/user_email.rb
David Battersby 68b9d68576
FIX: handle email aliases when normalize emails is enabled (#42344)
With normalize_emails enabled an alias of an address already in use (dot or plus) can never be saved, but nothing checked for it up front. Adding a secondary email accepted the request and mailed a confirmation link, then failed on click with a raw "Email has already been taken". Incoming email from an alias hit the same error while staging a user, and bounced a generic "unrecognized error" reply.

Both are now caught before anything is written or mailed — the user gets a clear error, the sender gets a bounce telling them to send from the address they signed up with. The alias is still rejected; normalize_emails enforces
uniqueness on the normalized form.
2026-08-05 18:21:48 +04:00

138 lines
3.8 KiB
Ruby
Vendored

# frozen_string_literal: true
class UserEmail < ActiveRecord::Base
belongs_to :user
attr_accessor :skip_validate_email
attr_accessor :skip_validate_unique_email
attr_accessor :skip_normalize_email
before_validation :strip_downcase_email
before_validation :normalize_email
validates :email, presence: true
validates :email, email: true, if: :validate_email?
validates :primary, uniqueness: { scope: [:user_id] }, if: %i[user_id primary]
validate :user_id_not_changed, if: :primary
validate :unique_email, if: :validate_unique_email?
scope :secondary, -> { where(primary: false) }
before_save -> { destroy_email_tokens(email_was) }, if: :will_save_change_to_email?
after_destroy { destroy_email_tokens(email) }
def self.ensure_consistency!
user_ids_without_primary_email = DB.query_single <<~SQL
SELECT u.id
FROM users u
LEFT JOIN user_emails ue ON u.id = ue.user_id AND ue.primary = true
WHERE ue.id IS NULL;
SQL
user_ids_without_primary_email.each do |user_id|
UserEmail.create!(
user_id: user_id,
# 64 max character length of local-part for the email address https://datatracker.ietf.org/doc/html/rfc5321#section-4.5.3.1.1
email: "#{SecureRandom.alphanumeric(64)}@missing-primary-email.invalid",
primary: true,
)
end
end
# Strips dots from the local part and everything between "+" and "@", so that
# aliases of the same address share a single normalized form.
def self.normalize(email)
local_part, domain = email.to_s.split("@", 2)
return if local_part.blank? || domain.blank?
"#{local_part.gsub(".", "").gsub(/\+.*/, "")}@#{domain}"
end
def self.find_by_normalized(email)
normalized = normalize(Email.downcase(email))
where("lower(normalized_email) = ?", normalized).first if normalized.present?
end
def normalize_email
self.normalized_email = self.class.normalize(email)
end
private
def strip_downcase_email
if email
self.email = email.strip
self.email = email.downcase
end
end
def validate_email?
return false if skip_validate_email
email_changed?
end
def validate_unique_email?
return false if skip_validate_unique_email
will_save_change_to_email?
end
def normalize_emails?
return false if skip_normalize_email
SiteSetting.normalize_emails?
end
def unique_email
scope = self.class
scope = scope.where.not(id: id) if persisted?
email_exists =
if normalize_emails?
scope.where(
"lower(email) = ? OR lower(normalized_email) = ?",
email,
normalized_email,
).exists?
else
scope.where("lower(email) = ?", email).exists?
end
errors.add(:email, :taken) if email_exists
end
def user_id_not_changed
if will_save_change_to_user_id? && persisted?
errors.add(
:user_id,
I18n.t(
"activerecord.errors.models.user_email.attributes.user_id.reassigning_primary_email",
),
)
end
end
def destroy_email_tokens(email)
EmailToken.where(email: email).destroy_all
end
end
# == Schema Information
#
# Table name: user_emails
#
# id :integer not null, primary key
# email :string(513) not null
# normalized_email :string
# primary :boolean default(FALSE), not null
# created_at :datetime not null
# updated_at :datetime not null
# user_id :integer not null
#
# 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"
#