users: move emails to separate table.

* Move emails from users table to email_addresses table.
* Validate that addresses are formatted correctly and are unique across
  users. Existing invalid emails are grandfathered in.
* Add is_verified flag (the address has been confirmed by the user).
* Add is_deliverable flag (an undeliverable address is an address that bounces).
* Normalize addresses to prevent registering multiple accounts with the
  same email address (using tricks like Gmail's plus addressing).
This commit is contained in:
evazion
2020-03-10 21:36:16 -05:00
parent 41304d6add
commit 258f4a8b95
22 changed files with 285 additions and 36 deletions

View File

@@ -3,10 +3,15 @@ class PasswordResetsController < ApplicationController
def create
@user = User.find_by_name(params.dig(:user, :name))
UserMailer.password_reset(@user).deliver_later
flash[:notice] = "Password reset email sent. Check your email"
respond_with(@user, location: new_session_path)
if @user.can_receive_email?
UserMailer.password_reset(@user).deliver_later
flash[:notice] = "Password reset email sent. Check your email"
respond_with(@user, location: new_session_path)
else
flash[:notice] = "Password not reset. This account does not have a valid, verified email address"
respond_with(@user)
end
end
def show

View File

@@ -4,6 +4,7 @@ class UsersController < ApplicationController
def new
@user = User.new
@user.email_address = EmailAddress.new
respond_with(@user)
end
@@ -110,7 +111,7 @@ class UsersController < ApplicationController
def user_params(context)
permitted_params = %i[
password old_password password_confirmation email
password old_password password_confirmation
comment_threshold default_image_size favorite_tags blacklisted_tags
time_zone per_page custom_style theme
@@ -123,7 +124,10 @@ class UsersController < ApplicationController
enable_safe_mode enable_desktop_mode disable_post_tooltips
]
permitted_params << :name if context == :create
if context == :create
permitted_params += [:name, { email_address_attributes: [:address] }]
end
permitted_params << :level if CurrentUser.is_admin?
params.require(:user).permit(permitted_params)

View File

@@ -0,0 +1,76 @@
module EmailNormalizer
module_function
IGNORE_DOTS = %w[gmail.com]
IGNORE_PLUS_ADDRESSING = %w[gmail.com hotmail.com outlook.com live.com]
IGNORE_MINUS_ADDRESSING = %w[yahoo.com]
CANONICAL_DOMAINS = {
"googlemail.com" => "gmail.com",
"hotmail.co.uk" => "outlook.com",
"hotmail.co.jp" => "outlook.com",
"hotmail.co.th" => "outlook.com",
"hotmail.com" => "outlook.com",
"hotmail.ca" => "outlook.com",
"hotmail.de" => "outlook.com",
"hotmail.es" => "outlook.com",
"hotmail.fr" => "outlook.com",
"hotmail.it" => "outlook.com",
"live.com.au" => "outlook.com",
"live.com.ar" => "outlook.com",
"live.com.mx" => "outlook.com",
"live.co.uk" => "outlook.com",
"live.com" => "outlook.com",
"live.ca" => "outlook.com",
"live.cl" => "outlook.com",
"live.cn" => "outlook.com",
"live.de" => "outlook.com",
"live.fr" => "outlook.com",
"live.it" => "outlook.com",
"live.jp" => "outlook.com",
"live.nl" => "outlook.com",
"live.se" => "outlook.com",
"msn.com" => "outlook.com",
"yahoo.com.au" => "yahoo.com",
"yahoo.com.ar" => "yahoo.com",
"yahoo.com.br" => "yahoo.com",
"yahoo.com.cn" => "yahoo.com",
"yahoo.com.hk" => "yahoo.com",
"yahoo.com.mx" => "yahoo.com",
"yahoo.com.ph" => "yahoo.com",
"yahoo.com.sg" => "yahoo.com",
"yahoo.com.tw" => "yahoo.com",
"yahoo.com.vn" => "yahoo.com",
"yahoo.co.id" => "yahoo.com",
"yahoo.co.kr" => "yahoo.com",
"yahoo.co.jp" => "yahoo.com",
"yahoo.co.uk" => "yahoo.com",
"yahoo.ca" => "yahoo.com",
"yahoo.cn" => "yahoo.com",
"yahoo.de" => "yahoo.com",
"yahoo.es" => "yahoo.com",
"yahoo.fr" => "yahoo.com",
"yahoo.it" => "yahoo.com",
"ymail.com" => "yahoo.com",
"126.com" => "163.com",
"aim.com" => "aol.com",
"gmx.com" => "gmx.net",
"gmx.at" => "gmx.net",
"gmx.ch" => "gmx.net",
"gmx.de" => "gmx.net",
"gmx.fr" => "gmx.net",
"gmx.us" => "gmx.net",
}
def normalize(address)
return nil unless address.count("@") == 1
name, domain = address.downcase.split("@")
domain = CANONICAL_DOMAINS.fetch(domain, domain)
name = name.delete(".") if domain.in?(IGNORE_DOTS)
name = name.gsub(/\+.*\z/, "") if domain.in?(IGNORE_PLUS_ADDRESSING)
name = name.gsub(/-.*\z/, "") if domain.in?(IGNORE_MINUS_ADDRESSING)
"#{name}@#{domain}"
end
end

View File

@@ -12,7 +12,7 @@ class SpamDetector
attr_accessor :record, :user, :user_ip, :content, :comment_type
rakismet_attrs author: proc { user.name },
author_email: proc { user.email },
author_email: proc { user.email_address&.address },
blog_lang: "en",
blog_charset: "UTF-8",
comment_type: :comment_type,

View File

@@ -29,7 +29,7 @@ class UserDeletion
end
def clear_user_settings
user.email = nil
user.email_address = nil
user.last_logged_in_at = nil
user.last_forum_read_at = nil
user.favorite_tags = ''

View File

@@ -8,11 +8,10 @@ class UserEmailChange
end
def process
if User.authenticate(user.name, password).nil?
user.errors[:base] << "Password was incorrect"
if User.authenticate(user.name, password)
user.update(email_address_attributes: { address: new_email })
else
user.email = new_email
user.save
user.errors[:base] << "Password was incorrect"
end
end
end

View File

@@ -4,11 +4,11 @@ class UserMailer < ApplicationMailer
def dmail_notice(dmail)
@dmail = dmail
mail(:to => "#{dmail.to.name} <#{dmail.to.email}>", :subject => "#{Danbooru.config.app_name} - Message received from #{dmail.from.name}")
mail to: dmail.to.email_with_name, subject: "#{Danbooru.config.app_name} - Message received from #{dmail.from.name}"
end
def password_reset(user)
@user = user
mail to: "#{@user.name} <#{@user.email}>", subject: "#{Danbooru.config.app_name} password reset request"
mail to: @user.email_with_name, subject: "#{Danbooru.config.app_name} password reset request"
end
end

View File

@@ -148,7 +148,7 @@ class Dmail < ApplicationRecord
end
def send_email
if is_recipient? && !is_deleted? && to.receive_email_notifications? && to.email =~ /@/
if is_recipient? && !is_deleted? && to.receive_email_notifications? && to.can_receive_email?
UserMailer.dmail_notice(self).deliver_now
end
end

View File

@@ -0,0 +1,15 @@
class EmailAddress < ApplicationRecord
# https://www.regular-expressions.info/email.html
EMAIL_REGEX = /\A[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\z/
belongs_to :user
validates :address, presence: true, confirmation: true, format: { with: EMAIL_REGEX }
validates :normalized_address, uniqueness: true
validates :user_id, uniqueness: true
def address=(value)
self.normalized_address = EmailNormalizer.normalize(value) || address
super
end
end

View File

@@ -72,7 +72,6 @@ class User < ApplicationRecord
after_initialize :initialize_attributes, if: :new_record?
validates :name, user_name: true, on: :create
validates_uniqueness_of :email, :case_sensitive => false, :if => ->(rec) { rec.email.present? && rec.saved_change_to_email? }
validates_length_of :password, :minimum => 5, :if => ->(rec) { rec.new_record? || rec.password.present?}
validates_inclusion_of :default_image_size, :in => %w(large original)
validates_inclusion_of :per_page, in: (1..PostSets::Post::MAX_PER_PAGE)
@@ -82,7 +81,6 @@ class User < ApplicationRecord
validate :validate_sock_puppets, :on => :create, :if => -> { Danbooru.config.enable_sock_puppet_validation? }
before_validation :normalize_blacklisted_tags
before_validation :set_per_page
before_validation :normalize_email
before_create :encrypt_password_on_create
before_update :encrypt_password_on_update
before_create :promote_to_admin_if_first_user
@@ -111,6 +109,7 @@ class User < ApplicationRecord
has_one :api_key
has_one :token_bucket
has_one :email_address, dependent: :destroy
has_many :notes, foreign_key: :creator_id
has_many :note_versions, :foreign_key => "updater_id"
has_many :dmails, -> {order("dmails.id desc")}, :foreign_key => "owner_id"
@@ -124,6 +123,7 @@ class User < ApplicationRecord
has_many :tag_implications, foreign_key: :creator_id
belongs_to :inviter, class_name: "User", optional: true
accepts_nested_attributes_for :email_address, reject_if: :all_blank, allow_destroy: true
enum theme: { light: 0, dark: 100 }, _suffix: true
# UserDeletion#rename renames deleted users to `user_<1234>~`. Tildes
@@ -366,8 +366,12 @@ class User < ApplicationRecord
end
module EmailMethods
def normalize_email
self.email = nil if email.blank?
def email_with_name
"#{name} <#{email_address.address}>"
end
def can_receive_email?
email_address.present? && email_address.is_verified? && email_address.is_deliverable?
end
end
@@ -515,7 +519,7 @@ class User < ApplicationRecord
if id == CurrentUser.user.id
attributes += BOOLEAN_ATTRIBUTES
attributes += %i[
updated_at email last_logged_in_at last_forum_read_at
updated_at last_logged_in_at last_forum_read_at
comment_threshold default_image_size
favorite_tags blacklisted_tags time_zone per_page
custom_style favorite_count api_regen_multiplier

View File

@@ -25,7 +25,13 @@
<div class="input">
<label>Email</label>
<p>
<%= CurrentUser.user.email.presence || "<em>blank</em>".html_safe %> <%= link_to "Change your email", new_maintenance_user_email_change_path %>
<% if @user.email_address.present? %>
<%= @user.email_address.address %>
<% else %>
<em>blank</em>
<% end %>
- <%= link_to "Change your email", new_maintenance_user_email_change_path %>
</p>
</div>

View File

@@ -15,7 +15,9 @@
<div id="p3">
<%= edit_form_for(@user, html: { id: "signup-form" }) do |f| %>
<%= f.input :name, as: :string %>
<%= f.input :email, required: false, as: :email, hint: "Optional" %>
<%= f.simple_fields_for :email_address do |fe| %>
<%= fe.input :address, label: "Email", required: false, as: :email, hint: "Optional" %>
<% end %>
<%= f.input :password %>
<%= f.input :password_confirmation %>