validates_email_format_of 1.6.3 → 1.7.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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