Module: ValidatesEmailFormatOf

Defined in:
lib/validates_email_format_of.rb,
lib/validates_email_format_of/railtie.rb,
lib/validates_email_format_of/version.rb

Defined Under Namespace

Classes: Railtie

Constant Summary collapse

ATEXT_SYMBOLS =
/[!\#$%&'*\-\/=?+\^_`{|}~]/
ATEXT =

Characters that are allowed in to appear in the local part unquoted www.rfc-editor.org/rfc/rfc5322#section-3.4.1

An addr-spec is a specific Internet identifier that contains a locally interpreted string followed by the at-sign character (“@”, ASCII value 64) followed by an Internet domain. The locally interpreted string is either a quoted-string or a dot-atom. If the string can be represented as a dot-atom (that is, it contains no characters other than atext characters or “.” surrounded by atext characters), then the dot-atom form SHOULD be used and the quoted- string form SHOULD NOT be used. Comments and folding white space SHOULD NOT be used around the “@” in the addr-spec.

dot-atom-text   =   1*atext *("." 1*atext)
dot-atom        =   [CFWS] dot-atom-text [CFWS]
/\A[A-Z0-9#{ATEXT_SYMBOLS}]\z/i
CTEXT =

Characters that are allowed to appear unquoted in comments www.rfc-editor.org/rfc/rfc5322#section-3.2.2

ctext = %d33-39 / %d42-91 / %d93-126 ccontent = ctext / quoted-pair / comment comment = “(” *([FWS] ccontent) [FWS] “)” CFWS = (1*( comment) [FWS]) / FWS

/\A[#{Regexp.escape([33..39, 42..91, 93..126].map { |ascii_range| ascii_range.map(&:chr) }.flatten.join)}\s]/i
QTEXT =

www.rfc-editor.org/rfc/rfc5322#section-3.2.4

Strings of characters that include characters other than those allowed in atoms can be represented in a quoted string format, where the characters are surrounded by quote (DQUOTE, ASCII value 34) characters.

qtext = %d33 / ; Printable US-ASCII

%d35-91 /          ;  characters not including
%d93-126 /         ;  "\" or the quote character
obs-qtext

qcontent = qtext / quoted-pair quoted-string = [CFWS]

DQUOTE *([FWS] qcontent) [FWS] DQUOTE
[CFWS]
/\A[#{Regexp.escape([33..33, 35..91, 93..126].map { |ascii_range| ascii_range.map(&:chr) }.flatten.join)}\s]/i
IP_OCTET =
/\A[0-9]+\Z/
DOMAIN_PART_LABEL =

From datatracker.ietf.org/doc/html/rfc1035#section-2.3.1

> The labels must follow the rules for ARPANET host names. They must > start with a letter, end with a letter or digit, and have as interior > characters only letters, digits, and hyphen. There are also some > restrictions on the length. Labels must be 63 characters or less.

<label> | <subdomain> “.” <label> <label> ::= <letter> [ [ <ldh-str> ] <let-dig> ] <ldh-str> ::= <let-dig-hyp> | <let-dig-hyp> <ldh-str> <let-dig-hyp> ::= <let-dig> | “-” <let-dig> ::= <letter> | <digit>

/\A[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9]?\Z/
DOMAIN_PART_TLD =

From tools.ietf.org/id/draft-liman-tld-names-00.html#rfc.section.2

> A TLD label MUST be at least two characters long and MAY be as long as 63 characters - > not counting any leading or trailing periods (.). It MUST consist of only ASCII characters > from the groups “letters” (A-Z), “digits” (0-9) and “hyphen” (-), and it MUST start with an > ASCII “letter”, and it MUST NOT end with a “hyphen”. Upper and lower case MAY be mixed at random, > since DNS lookups are case-insensitive.

tldlabel = ALPHA *61(ldh) ld ldh = ld / “-” ld = ALPHA / DIGIT ALPHA = %x41-5A / %x61-7A ; A-Z / a-z DIGIT = %x30-39 ; 0-9

/\A[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9]\Z/
DEFAULT_MESSAGE =
"does not appear to be valid"
DEFAULT_MX_MESSAGE =
"is not routable"
ERROR_MESSAGE_I18N_KEY =
:invalid_email_address
ERROR_MX_MESSAGE_I18N_KEY =
:email_address_not_routable
VERSION =
"1.7.0"

Class Method Summary collapse

Class Method Details

.default_messageObject



102
103
104
# File 'lib/validates_email_format_of.rb', line 102

def self.default_message
  defined?(I18n) ? I18n.t(ERROR_MESSAGE_I18N_KEY, scope: [:activemodel, :errors, :messages], default: DEFAULT_MESSAGE) : DEFAULT_MESSAGE
end

.load_i18n_localesObject



4
5
6
7
# File 'lib/validates_email_format_of.rb', line 4

def self.load_i18n_locales
  require "i18n"
  I18n.load_path += Dir.glob(File.expand_path(File.join(File.dirname(__FILE__), "..", "config", "locales", "*.yml")))
end

.validate_domain_part_syntax(domain) ⇒ Object



225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
# File 'lib/validates_email_format_of.rb', line 225

def self.validate_domain_part_syntax(domain)
  parts = domain.downcase.split(".", -1)

  return false if parts.length <= 1 # Only one domain part

  # ipv4
  return true if parts.length == 4 && parts.all? { |part| part =~ IP_OCTET && part.to_i.between?(0, 255) }

  # From https://datatracker.ietf.org/doc/html/rfc3696#section-2 this is the recommended, pragmatic way to validate a domain name:
  #
  # > It is likely that the better strategy has now become to make the "at least one period" test,
  # > to verify LDH conformance (including verification that the apparent TLD name is not all-numeric),
  # > and then to use the DNS to determine domain name validity, rather than trying to maintain
  # > a local list of valid TLD names.
  #
  # We do a little bit more but not too much and validate the tokens but do not check against a list of valid TLDs.
  parts.each do |part|
    return false if part.nil? || part.empty?
    return false if part.length > 63
    return false unless DOMAIN_PART_LABEL.match?(part)
  end

  return false unless DOMAIN_PART_TLD.match?(parts[-1])
  true
end

.validate_email_domain(email, check_mx_timeout: 3) ⇒ Object



88
89
90
91
92
93
94
95
# File 'lib/validates_email_format_of.rb', line 88

def self.validate_email_domain(email, check_mx_timeout: 3)
  domain = email.to_s.downcase.match(/@(.+)/)[1]
  Resolv::DNS.open do |dns|
    dns.timeouts = check_mx_timeout
    @mx = dns.getresources(domain, Resolv::DNS::Resource::IN::MX) + dns.getresources(domain, Resolv::DNS::Resource::IN::A)
  end
  @mx.size > 0
end

.validate_email_format(email, options = {}) ⇒ Object

Validates whether the specified value is a valid email address. Returns nil if the value is valid, otherwise returns an array containing one or more validation error messages.

Configuration options:

  • message - A custom error message (default is: “does not appear to be valid”)

  • check_mx - Check for MX records (default is false)

  • check_mx_timeout - Timeout in seconds for checking MX records before a ‘ResolvTimeout` is raised (default is 3)

  • mx_message - A custom error message when an MX record validation fails (default is: “is not routable.”)

  • with The regex to use for validating the format of the email address (deprecated)

  • local_length Maximum number of characters allowed in the local part (default is 64)

  • domain_length Maximum number of characters allowed in the domain part (default is 255)

  • generate_message Return the I18n key of the error message instead of the error message itself (default is false)



118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
# File 'lib/validates_email_format_of.rb', line 118

def self.validate_email_format(email, options = {})
  default_options = {message: options[:generate_message] ? ERROR_MESSAGE_I18N_KEY : default_message,
                     check_mx: false,
                     check_mx_timeout: 3,
                     mx_message: if options[:generate_message]
                                   ERROR_MX_MESSAGE_I18N_KEY
                                 else
                                   (defined?(I18n) ? I18n.t(ERROR_MX_MESSAGE_I18N_KEY, scope: [:activemodel, :errors, :messages], default: DEFAULT_MX_MESSAGE) : DEFAULT_MX_MESSAGE)
                                 end,
                     domain_length: 255,
                     local_length: 64,
                     generate_message: false}
  opts = options.merge(default_options) { |key, old, new| old } # merge the default options into the specified options, retaining all specified options

  begin
    domain, local = email.reverse.split("@", 2)
  rescue
    return [opts[:message]]
  end

  # need local and domain parts
  return [opts[:message]] unless local && !local.empty? && domain && !domain.empty?

  # check lengths
  return [opts[:message]] unless domain.length <= opts[:domain_length] && local.length <= opts[:local_length]

  local.reverse!
  domain.reverse!

  if opts.has_key?(:with) # holdover from versions <= 1.4.7
    return [opts[:message]] unless email&.match?(opts[:with])
  else
    return [opts[:message]] unless validate_local_part_syntax(local) && validate_domain_part_syntax(domain)
  end

  if opts[:check_mx] && !validate_email_domain(email, check_mx_timeout: opts[:check_mx_timeout])
    return [opts[:mx_message]]
  end

  nil # represents no validation errors
end

.validate_local_part_syntax(local) ⇒ Object



160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
# File 'lib/validates_email_format_of.rb', line 160

def self.validate_local_part_syntax(local)
  in_quoted_pair = false
  in_quoted_string = false
  comment_depth = 0

  (0..local.length - 1).each do |i|
    ord = local[i].ord

    # accept anything if it's got a backslash before it
    if in_quoted_pair
      in_quoted_pair = false
      next
    end

    if in_quoted_string
      next if QTEXT.match?(local[i])
    end

    # opening paren to show we are going into a comment (CFWS)
    if ord == 40
      comment_depth += 1
      next
    end

    # closing paren
    if ord == 41
      comment_depth -= 1
      return false if comment_depth < 0
      next
    end

    # backslash signifies the start of a quoted pair
    if ord == 92 && i < local.length - 1
      return false if !in_quoted_string # must be in quoted string per http://www.rfc-editor.org/errata_search.php?rfc=3696
      in_quoted_pair = true
      next
    end

    # double quote delimits quoted strings
    if ord == 34
      in_quoted_string = !in_quoted_string
      next
    end

    if comment_depth > 0
      next if CTEXT.match?(local[i])
    elsif ATEXT.match?(local[i, 1])
      next
    end

    # period must be followed by something
    if ord == 46
      return false if i == 0 || i == local.length - 1 # can't be first or last char
      next unless local[i + 1].ord == 46 # can't be followed by a period
    end

    return false
  end

  return false if in_quoted_string # unbalanced quotes
  return false unless comment_depth.zero? # unbalanced comment parens

  true
end