module EmailNormalization
extend ActiveSupport::Concern
included do
attr_accessor :soft_warnings
before_validation :normalize_email
validate :soft_validate_email
validates :email,
presence: true,
format: { with: URI::MailTo::EMAIL_REGEXP, message: "is not a valid address" }
end
private
def normalize_email
return if email.blank?
self.email = email.strip.downcase
self.canonical_email = EmailPolicy.canonicalize(email)
end
def soft_validate_email
self.soft_warnings = []
return if email.blank?
warnings = EmailPolicy.warnings_for(email)
self.soft_warnings.concat(warnings)
end
end
class User < ApplicationRecord
include EmailNormalization
validates :canonical_email, uniqueness: { case_sensitive: false }
scope :by_email, ->(address) do
where(canonical_email: EmailPolicy.canonicalize(address.to_s.strip.downcase))
end
def risky_email?
soft_warnings.present?
end
def disposable_email?
soft_warnings.to_a.include?(:disposable_domain)
end
end
module EmailPolicy
DISPOSABLE_DOMAINS = %w[mailinator.com guerrillamail.com 10minutemail.com tempmail.com].freeze
SUSPICIOUS_TLDS = %w[.test .invalid .localhost].freeze
module_function
def canonicalize(address)
local, domain = address.split("@", 2)
return address if domain.nil?
local = local.split("+", 2).first
if %w[gmail.com googlemail.com].include?(domain)
local = local.delete(".")
domain = "gmail.com"
end
"#{local}@#{domain}"
end
def warnings_for(address)
domain = address.split("@", 2).last.to_s
warnings = []
warnings << :disposable_domain if DISPOSABLE_DOMAINS.include?(domain)
warnings << :suspicious_tld if SUSPICIOUS_TLDS.any? { |tld| domain.end_with?(tld) }
warnings << :plus_addressing if address.include?("+")
warnings
end
end
class AddCanonicalEmailToUsers < ActiveRecord::Migration[7.1]
def up
enable_extension "citext" unless extension_enabled?("citext")
add_column :users, :canonical_email, :citext
execute <<~SQL
UPDATE users
SET canonical_email = lower(trim(email))
WHERE canonical_email IS NULL
SQL
change_column_null :users, :canonical_email, false
add_index :users, :canonical_email, unique: true
end
def down
remove_index :users, :canonical_email
remove_column :users, :canonical_email
end
end
This snippet demonstrates a two-phase approach to handling user-supplied email addresses in a Rails application: normalization first, then validation, with a distinction between hard failures and soft warnings. In EmailNormalization concern, the email is treated as untrusted input that must be cleaned into a canonical form before any comparison or persistence. The normalize_email callback runs before_validation so that downcasing, whitespace stripping, and dotless-Gmail folding happen before uniqueness and format checks see the value. This ordering matters because a uniqueness index or validation that runs against un-normalized input will let John@Gmail.com and john@gmail.com coexist, defeating the point of the constraint.
The concern separates two ideas that are easy to conflate. A format validation is a hard rule: a syntactically broken address blocks the save entirely. The soft_validate_email step, by contrast, records advisory concerns — a disposable-domain match or a suspicious top-level domain — into a soft_warnings accessor without adding to errors. This lets the record persist while surfacing risk signals the application can act on later, which is the right trade-off for signup flows where being too strict costs real conversions.
In User model, the concern is mixed in and the canonical column is protected by a real database guarantee. The model relies on a normalized value for its uniqueness check, and canonical_email is derived so lookups stay stable even when the display email varies. The EmailPolicy service centralizes the domain knowledge — disposable providers, plus-addressing, and Gmail dot-folding — so the rules live in one testable place rather than scattered across callbacks.
The migration backs the invariant with a citext column and a unique index, so uniqueness holds even under a race where two requests pass the ActiveRecord check simultaneously. The citext type makes comparisons case-insensitive at the storage layer, complementing the application-side downcasing. A pitfall worth noting: normalization must be idempotent, since callbacks can run more than once; each transform here yields the same output when applied to already-normalized input. This pattern is worth reaching for whenever an identity field must be both flexible for humans and strictly unique for the system.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.