validates_email_format_of 1.6.3 → 1.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,22 +1,106 @@
1
- # encoding: utf-8
2
- require 'validates_email_format_of/version'
1
+ require "validates_email_format_of/version"
3
2
 
4
3
  module ValidatesEmailFormatOf
5
4
  def self.load_i18n_locales
6
- require 'i18n'
7
- I18n.load_path += Dir.glob(File.expand_path(File.join(File.dirname(__FILE__), '..', 'config', 'locales', '*.yml')))
5
+ require "i18n"
6
+ I18n.load_path += Dir.glob(File.expand_path(File.join(File.dirname(__FILE__), "..", "config", "locales", "*.yml")))
8
7
  end
9
8
 
10
- require 'resolv'
9
+ require "resolv"
11
10
 
12
- LocalPartSpecialChars = /[\!\#\$\%\&\'\*\-\/\=\?\+\-\^\_\`\{\|\}\~]/
11
+ # Characters that are allowed in to appear in the local part unquoted
12
+ # https://www.rfc-editor.org/rfc/rfc5322#section-3.2.3
13
+ #
14
+ # An addr-spec is a specific Internet identifier that contains a
15
+ # locally interpreted string followed by the at-sign character ("@",
16
+ # ASCII value 64) followed by an Internet domain. The locally
17
+ # interpreted string is either a quoted-string or a dot-atom. If the
18
+ # string can be represented as a dot-atom (that is, it contains no
19
+ # characters other than atext characters or "." surrounded by atext
20
+ # characters), then the dot-atom form SHOULD be used and the quoted-
21
+ # string form SHOULD NOT be used. Comments and folding white space
22
+ # SHOULD NOT be used around the "@" in the addr-spec.
23
+ #
24
+ # atext = ALPHA / DIGIT /
25
+ # "!" / "#" / "$" / "%" / "&" / "'" / "*" /
26
+ # "+" / "-" / "/" / "=" / "?" / "^" / "_" /
27
+ # "`" / "{" / "|" / "}" / "~"
28
+ # dot-atom-text = 1*atext *("." 1*atext)
29
+ # dot-atom = [CFWS] dot-atom-text [CFWS]
30
+ ATEXT = /\A[A-Z0-9!\#$%&'*\-\/=?+\^_`{|}~]\z/i
31
+
32
+ # Characters that are allowed to appear unquoted in comments
33
+ # https://www.rfc-editor.org/rfc/rfc5322#section-3.2.2
34
+ #
35
+ # ctext = %d33-39 / %d42-91 / %d93-126
36
+ # ccontent = ctext / quoted-pair / comment
37
+ # comment = "(" *([FWS] ccontent) [FWS] ")"
38
+ # CFWS = (1*([FWS] comment) [FWS]) / FWS
39
+ CTEXT = /\A[#{Regexp.escape([33..39, 42..91, 93..126].map { |ascii_range| ascii_range.map(&:chr) }.flatten.join)}\s]/i
40
+
41
+ # https://www.rfc-editor.org/rfc/rfc5322#section-3.2.4
42
+ #
43
+ # Strings of characters that include characters other than those
44
+ # allowed in atoms can be represented in a quoted string format, where
45
+ # the characters are surrounded by quote (DQUOTE, ASCII value 34)
46
+ # characters.
47
+ #
48
+ # qtext = %d33 / ; Printable US-ASCII
49
+ # %d35-91 / ; characters not including
50
+ # %d93-126 / ; "\" or the quote character
51
+ # obs-qtext
52
+ #
53
+ # qcontent = qtext / quoted-pair
54
+ # quoted-string = [CFWS]
55
+ # DQUOTE *([FWS] qcontent) [FWS] DQUOTE
56
+ # [CFWS]
57
+ QTEXT = /\A[#{Regexp.escape([33..33, 35..91, 93..126].map { |ascii_range| ascii_range.map(&:chr) }.flatten.join)}\s]/i
58
+
59
+ IP_OCTET = /\A[0-9]+\Z/
60
+
61
+ # From https://datatracker.ietf.org/doc/html/rfc1035#section-2.3.1
62
+ #
63
+ # > The labels must follow the rules for ARPANET host names. They must
64
+ # > start with a letter, end with a letter or digit, and have as interior
65
+ # > characters only letters, digits, and hyphen. There are also some
66
+ # > restrictions on the length. Labels must be 63 characters or less.
67
+ #
68
+ # <label> | <subdomain> "." <label>
69
+ # <label> ::= <letter> [ [ <ldh-str> ] <let-dig> ]
70
+ # <ldh-str> ::= <let-dig-hyp> | <let-dig-hyp> <ldh-str>
71
+ # <let-dig-hyp> ::= <let-dig> | "-"
72
+ # <let-dig> ::= <letter> | <digit>
73
+ #
74
+ # Additionally, from https://datatracker.ietf.org/doc/html/rfc1123#section-2.1
75
+ #
76
+ # > One aspect of host name syntax is hereby changed: the
77
+ # > restriction on the first character is relaxed to allow either a
78
+ # > letter or a digit. Host software MUST support this more liberal
79
+ # > syntax.
80
+ DOMAIN_PART_LABEL = /\A[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9]?\Z/
13
81
 
14
- def self.validate_email_domain(email)
15
- domain = email.to_s.downcase.match(/\@(.+)/)[1]
82
+ # From https://tools.ietf.org/id/draft-liman-tld-names-00.html#rfc.section.2
83
+ #
84
+ # > A TLD label MUST be at least two characters long and MAY be as long as 63 characters -
85
+ # > not counting any leading or trailing periods (.). It MUST consist of only ASCII characters
86
+ # > from the groups "letters" (A-Z), "digits" (0-9) and "hyphen" (-), and it MUST start with an
87
+ # > ASCII "letter", and it MUST NOT end with a "hyphen". Upper and lower case MAY be mixed at random,
88
+ # > since DNS lookups are case-insensitive.
89
+ #
90
+ # tldlabel = ALPHA *61(ldh) ld
91
+ # ldh = ld / "-"
92
+ # ld = ALPHA / DIGIT
93
+ # ALPHA = %x41-5A / %x61-7A ; A-Z / a-z
94
+ # DIGIT = %x30-39 ; 0-9
95
+ DOMAIN_PART_TLD = /\A[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9]\Z/
96
+
97
+ def self.validate_email_domain(email, check_mx_timeout: 3)
98
+ domain = email.to_s.downcase.match(/@(.+)/)[1]
16
99
  Resolv::DNS.open do |dns|
100
+ dns.timeouts = check_mx_timeout
17
101
  @mx = dns.getresources(domain, Resolv::DNS::Resource::IN::MX) + dns.getresources(domain, Resolv::DNS::Resource::IN::A)
18
102
  end
19
- @mx.size > 0 ? true : false
103
+ @mx.size > 0
20
104
  end
21
105
 
22
106
  DEFAULT_MESSAGE = "does not appear to be valid"
@@ -25,7 +109,7 @@ module ValidatesEmailFormatOf
25
109
  ERROR_MX_MESSAGE_I18N_KEY = :email_address_not_routable
26
110
 
27
111
  def self.default_message
28
- defined?(I18n) ? I18n.t(ERROR_MESSAGE_I18N_KEY, :scope => [:activemodel, :errors, :messages], :default => DEFAULT_MESSAGE) : DEFAULT_MESSAGE
112
+ defined?(I18n) ? I18n.t(ERROR_MESSAGE_I18N_KEY, scope: [:activemodel, :errors, :messages], default: DEFAULT_MESSAGE) : DEFAULT_MESSAGE
29
113
  end
30
114
 
31
115
  # Validates whether the specified value is a valid email address. Returns nil if the value is valid, otherwise returns an array
@@ -34,57 +118,60 @@ module ValidatesEmailFormatOf
34
118
  # Configuration options:
35
119
  # * <tt>message</tt> - A custom error message (default is: "does not appear to be valid")
36
120
  # * <tt>check_mx</tt> - Check for MX records (default is false)
121
+ # * <tt>check_mx_timeout</tt> - Timeout in seconds for checking MX records before a `ResolvTimeout` is raised (default is 3)
37
122
  # * <tt>mx_message</tt> - A custom error message when an MX record validation fails (default is: "is not routable.")
38
123
  # * <tt>with</tt> The regex to use for validating the format of the email address (deprecated)
39
124
  # * <tt>local_length</tt> Maximum number of characters allowed in the local part (default is 64)
40
125
  # * <tt>domain_length</tt> Maximum number of characters allowed in the domain part (default is 255)
41
126
  # * <tt>generate_message</tt> Return the I18n key of the error message instead of the error message itself (default is false)
42
- def self.validate_email_format(email, options={})
43
- default_options = { :message => options[:generate_message] ? ERROR_MESSAGE_I18N_KEY : default_message,
44
- :check_mx => false,
45
- :mx_message => options[:generate_message] ? ERROR_MX_MESSAGE_I18N_KEY : (defined?(I18n) ? I18n.t(ERROR_MX_MESSAGE_I18N_KEY, :scope => [:activemodel, :errors, :messages], :default => DEFAULT_MX_MESSAGE) : DEFAULT_MX_MESSAGE),
46
- :domain_length => 255,
47
- :local_length => 64,
48
- :generate_message => false
49
- }
50
- opts = options.merge(default_options) {|key, old, new| old} # merge the default options into the specified options, retaining all specified options
51
-
52
- email = email.strip if email
53
-
54
- begin
55
- domain, local = email.reverse.split('@', 2)
56
- rescue
57
- return [ opts[:message] ]
58
- end
127
+ def self.validate_email_format(email, options = {})
128
+ default_options = {message: options[:generate_message] ? ERROR_MESSAGE_I18N_KEY : default_message,
129
+ check_mx: false,
130
+ check_mx_timeout: 3,
131
+ mx_message: if options[:generate_message]
132
+ ERROR_MX_MESSAGE_I18N_KEY
133
+ else
134
+ (defined?(I18n) ? I18n.t(ERROR_MX_MESSAGE_I18N_KEY, scope: [:activemodel, :errors, :messages], default: DEFAULT_MX_MESSAGE) : DEFAULT_MX_MESSAGE)
135
+ end,
136
+ domain_length: 255,
137
+ local_length: 64,
138
+ generate_message: false}
139
+ opts = options.merge(default_options) { |key, old, new| old } # merge the default options into the specified options, retaining all specified options
140
+
141
+ begin
142
+ domain, local = email.reverse.split("@", 2)
143
+ rescue
144
+ return [opts[:message]]
145
+ end
59
146
 
60
- # need local and domain parts
61
- return [ opts[:message] ] unless local and not local.empty? and domain and not domain.empty?
147
+ # need local and domain parts
148
+ return [opts[:message]] unless local && !local.empty? && domain && !domain.empty?
62
149
 
63
- # check lengths
64
- return [ opts[:message] ] unless domain.length <= opts[:domain_length] and local.length <= opts[:local_length]
150
+ # check lengths
151
+ return [opts[:message]] unless domain.length <= opts[:domain_length] && local.length <= opts[:local_length]
65
152
 
66
- local.reverse!
67
- domain.reverse!
153
+ local.reverse!
154
+ domain.reverse!
68
155
 
69
- if opts.has_key?(:with) # holdover from versions <= 1.4.7
70
- return [ opts[:message] ] unless email =~ opts[:with]
71
- else
72
- return [ opts[:message] ] unless self.validate_local_part_syntax(local) and self.validate_domain_part_syntax(domain)
73
- end
156
+ if opts.has_key?(:with) # holdover from versions <= 1.4.7
157
+ return [opts[:message]] unless email&.match?(opts[:with])
158
+ else
159
+ return [opts[:message]] unless validate_local_part_syntax(local) && validate_domain_part_syntax(domain)
160
+ end
74
161
 
75
- if opts[:check_mx] and !self.validate_email_domain(email)
76
- return [ opts[:mx_message] ]
77
- end
162
+ if opts[:check_mx] && !validate_email_domain(email, check_mx_timeout: opts[:check_mx_timeout])
163
+ return [opts[:mx_message]]
164
+ end
78
165
 
79
- return nil # represents no validation errors
166
+ nil # represents no validation errors
80
167
  end
81
168
 
82
-
83
169
  def self.validate_local_part_syntax(local)
84
170
  in_quoted_pair = false
85
171
  in_quoted_string = false
172
+ comment_depth = 0
86
173
 
87
- (0..local.length-1).each do |i|
174
+ (0..local.length - 1).each do |i|
88
175
  ord = local[i].ord
89
176
 
90
177
  # accept anything if it's got a backslash before it
@@ -93,9 +180,26 @@ module ValidatesEmailFormatOf
93
180
  next
94
181
  end
95
182
 
183
+ if in_quoted_string
184
+ next if QTEXT.match?(local[i])
185
+ end
186
+
187
+ # opening paren to show we are going into a comment (CFWS)
188
+ if ord == 40
189
+ comment_depth += 1
190
+ next
191
+ end
192
+
193
+ # closing paren
194
+ if ord == 41
195
+ comment_depth -= 1
196
+ return false if comment_depth < 0
197
+ next
198
+ end
199
+
96
200
  # backslash signifies the start of a quoted pair
97
- if ord == 92 and i < local.length - 1
98
- return false if not in_quoted_string # must be in quoted string per http://www.rfc-editor.org/errata_search.php?rfc=3696
201
+ if ord == 92 && i < local.length - 1
202
+ return false if !in_quoted_string # must be in quoted string per http://www.rfc-editor.org/errata_search.php?rfc=3696
99
203
  in_quoted_pair = true
100
204
  next
101
205
  end
@@ -106,45 +210,53 @@ module ValidatesEmailFormatOf
106
210
  next
107
211
  end
108
212
 
109
- next if local[i,1] =~ /[a-z0-9]/i
110
- next if local[i,1] =~ LocalPartSpecialChars
213
+ if comment_depth > 0
214
+ next if CTEXT.match?(local[i])
215
+ elsif ATEXT.match?(local[i, 1])
216
+ next
217
+ end
111
218
 
112
219
  # period must be followed by something
113
220
  if ord == 46
114
- return false if i == 0 or i == local.length - 1 # can't be first or last char
115
- next unless local[i+1].ord == 46 # can't be followed by a period
221
+ return false if i == 0 || i == local.length - 1 # can't be first or last char
222
+ next unless local[i + 1].ord == 46 # can't be followed by a period
116
223
  end
117
224
 
118
225
  return false
119
226
  end
120
227
 
121
228
  return false if in_quoted_string # unbalanced quotes
229
+ return false unless comment_depth.zero? # unbalanced comment parens
122
230
 
123
- return true
231
+ true
124
232
  end
125
233
 
126
234
  def self.validate_domain_part_syntax(domain)
127
- parts = domain.downcase.split('.', -1)
235
+ parts = domain.downcase.split(".", -1)
128
236
 
129
237
  return false if parts.length <= 1 # Only one domain part
130
238
 
131
- # Empty parts (double period) or invalid chars
132
- return false if parts.any? {
133
- |part|
134
- part.nil? or
135
- part.empty? or
136
- not part =~ /\A[[:alnum:]\-]+\Z/ or
137
- part[0,1] == '-' or part[-1,1] == '-' # hyphen at beginning or end of part
138
- }
139
-
140
239
  # ipv4
141
- return true if parts.length == 4 and parts.all? { |part| part =~ /\A[0-9]+\Z/ and part.to_i.between?(0, 255) }
142
-
143
- return false if parts[-1].length < 2 or not parts[-1] =~ /[a-z\-]/ # TLD is too short or does not contain a char or hyphen
240
+ return true if parts.length == 4 && parts.all? { |part| part =~ IP_OCTET && part.to_i.between?(0, 255) }
241
+
242
+ # From https://datatracker.ietf.org/doc/html/rfc3696#section-2 this is the recommended, pragmatic way to validate a domain name:
243
+ #
244
+ # > It is likely that the better strategy has now become to make the "at least one period" test,
245
+ # > to verify LDH conformance (including verification that the apparent TLD name is not all-numeric),
246
+ # > and then to use the DNS to determine domain name validity, rather than trying to maintain
247
+ # > a local list of valid TLD names.
248
+ #
249
+ # We do a little bit more but not too much and validate the tokens but do not check against a list of valid TLDs.
250
+ parts.each do |part|
251
+ return false if part.nil? || part.empty?
252
+ return false if part.length > 63
253
+ return false unless DOMAIN_PART_LABEL.match?(part)
254
+ end
144
255
 
145
- return true
256
+ return false unless DOMAIN_PART_TLD.match?(parts[-1])
257
+ true
146
258
  end
147
259
  end
148
260
 
149
- require 'validates_email_format_of/active_model' if defined?(::ActiveModel) && !(ActiveModel::VERSION::MAJOR < 2 || (2 == ActiveModel::VERSION::MAJOR && ActiveModel::VERSION::MINOR < 1))
150
- require 'validates_email_format_of/railtie' if defined?(::Rails::Railtie)
261
+ require "validates_email_format_of/active_model" if defined?(::ActiveModel) && !(ActiveModel::VERSION::MAJOR < 2 || (ActiveModel::VERSION::MAJOR == 2 && ActiveModel::VERSION::MINOR < 1))
262
+ require "validates_email_format_of/railtie" if defined?(::Rails::Railtie)
data/rakefile.rb CHANGED
@@ -1 +1,4 @@
1
- require 'bundler/gem_tasks'
1
+ require "bundler/gem_tasks"
2
+ require "standard/rake"
3
+
4
+ task default: [:spec, "standard:fix"]
@@ -1,14 +1,12 @@
1
- require "#{File.expand_path(File.dirname(__FILE__))}/spec_helper"
2
- if defined?(ActiveModel)
3
- require "validates_email_format_of/rspec_matcher"
1
+ require "#{__dir__}/spec_helper"
2
+ require "validates_email_format_of/rspec_matcher"
4
3
 
5
- class Person
6
- attr_accessor :email_address
7
- include ::ActiveModel::Validations
8
- validates_email_format_of :email_address
9
- end
4
+ class Person
5
+ attr_accessor :email_address
6
+ include ::ActiveModel::Validations
7
+ validates_email_format_of :email_address
8
+ end
10
9
 
11
- describe Person do
12
- it { should validate_email_format_of(:email_address) }
13
- end
10
+ describe Person do
11
+ it { should validate_email_format_of(:email_address) }
14
12
  end
data/spec/spec_helper.rb CHANGED
@@ -1,4 +1,7 @@
1
- require 'active_model' if Gem.loaded_specs.keys.include?('activemodel')
1
+ require "active_model"
2
+ require "active_support"
3
+ require "pry"
4
+ require "byebug"
2
5
 
3
6
  RSpec::Matchers.define :have_errors_on_email do
4
7
  match do |actual|
@@ -6,7 +9,7 @@ RSpec::Matchers.define :have_errors_on_email do
6
9
  expect(actual).not_to be_empty, "#{actual} should not be empty"
7
10
  expect(actual.size).to eq(@reasons.size), "#{actual} should have #{@reasons.size} elements"
8
11
  @reasons.each do |reason|
9
- reason = "#{"Email " if defined?(ActiveModel)}#{reason}"
12
+ reason = "Email #{reason}"
10
13
  expect(actual).to include(reason), "#{actual} should contain #{reason}"
11
14
  end
12
15
  end
@@ -17,6 +20,6 @@ RSpec::Matchers.define :have_errors_on_email do
17
20
  (@reasons ||= []) << reason
18
21
  end
19
22
  match_when_negated do |actual|
20
- expect(actual).to (defined?(ActiveModel) ? be_empty : be_nil)
23
+ expect(actual).to(be_empty)
21
24
  end
22
25
  end