validates_email_format_of 1.6.1 → 1.7.0

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