ruby 98 lines · 4 tabs

Soft Validation: Normalize + Validate Email

Shared by codesnips Jan 2026
4 tabs
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
4 files · ruby Explain with highlit

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

Share this code

Here's the card — post it anywhere.

Soft Validation: Normalize + Validate Email — share card
Link copied