File: email_validator.rb

package info (click to toggle)
ruby-email-validator 2.2.4-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 180 kB
  • sloc: ruby: 1,051; makefile: 2
file content (165 lines) | stat: -rw-r--r-- 4,616 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
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
159
160
161
162
163
164
165
# Based on work from http://thelucid.com/2010/01/08/sexy-validation-in-edge-rails-rails-3/

# EmailValidator class
class EmailValidator < ActiveModel::EachValidator
  # rubocop:disable Style/ClassVars
  @@default_options = {
    :allow_nil => false,
    :domain => nil,
    :require_fqdn => nil,
    :mode => :loose
  }
  # rubocop:enable Style/ClassVars

  # EmailValidator::Error class
  class Error < StandardError
    def initialize(msg = 'EmailValidator error')
      super
    end
  end

  class << self
    def default_options
      @@default_options
    end

    def valid?(value, options = {})
      options = parse_options(options)
      return true if value.nil? && options[:allow_nil] == true
      return false if value.nil?
      # quickly fail if domain is required but doesn't match
      return false unless options[:domain].nil? || value[/^.*@#{regexp_safe_domain(options)}$/]
      !!(value =~ regexp(options))
    end

    def invalid?(value, options = {})
      !valid?(value, options)
    end

    # Refs:
    #  https://tools.ietf.org/html/rfc2822 : 3.2. Lexical Tokens, 3.4.1. Addr-spec specification
    #  https://tools.ietf.org/html/rfc5321 : 4.1.2.  Command Argument Syntax
    def regexp(options = {})
      options = parse_options(options)
      case options[:mode]
      when :loose
        loose_regexp(options)
      when :rfc
        rfc_regexp(options)
      when :strict
        options[:require_fqdn] = true
        strict_regexp(options)
      else
        fail EmailValidator::Error, "Validation mode '#{options[:mode]}' is not supported by EmailValidator"
      end
    end

    protected

    def loose_regexp(options = {})
      return /\A[^\s]+@[^\s]+\z/ if options[:domain].nil?
      /\A[^\s]+@#{domain_part_pattern(options)}\z/
    end

    def strict_regexp(options = {})
      /\A(?>#{local_part_pattern})@#{domain_part_pattern(options)}\z/i
    end

    def rfc_regexp(options = {})
      /\A(?>#{local_part_pattern})(?:@#{domain_part_pattern(options)})?\z/i
    end

    def alpha
      '[[:alpha:]]'
    end

    def alnum
      '[[:alnum:]]'
    end

    def alnumhy
      "(?:#{alnum}|-)"
    end

    def ipv4
      '\d{1,3}(?:\.\d{1,3}){3}'
    end

    def ipv6
      # only supporting full IPv6 addresses right now
      'IPv6:[[:xdigit:]]{1,4}(?::[[:xdigit:]]{1,4}){7}'
    end

    def address_literal
      "\\[(?:#{ipv4}|#{ipv6})\\]"
    end

    def host_label_pattern
      "#{label_is_correct_length}" \
      "#{alnum}(?:#{alnumhy}{,61}#{alnum})?"
    end

    # splitting this up into separate regex pattern for performance; let's not
    # try the "contains" pattern unless we have to
    def domain_label_pattern
      "#{host_label_pattern}\\.#{tld_label_pattern}"
    end

    # While, techincally, TLDs can be numeric-only, this is not allowed by ICANN
    # Ref: ICANN Application Guidebook for new TLDs (June 2012)
    #      says the following starting at page 64:
    #
    #      > The ASCII label must consist entirely of letters (alphabetic characters a-z)
    #
    #      -- https://newgtlds.icann.org/en/applicants/agb/guidebook-full-04jun12-en.pdf
    def tld_label_pattern
      "#{alpha}{1,64}"
    end

    def label_is_correct_length
      '(?=[^.]{1,63}(?:\.|$))'
    end

    def domain_part_is_correct_length
      '(?=.{1,255}$)'
    end

    def atom_char
      # The `atext` spec
      # We are looking at this without whitespace; no whitespace support here
      "[-#{alpha}#{alnum}+_!\"'#$%^&*{}/=?`|~]"
    end

    def local_part_pattern
      # the `dot-atom-text` spec, but with a 64 character limit
      "#{atom_char}(?:\\.?#{atom_char}){,63}"
    end

    def domain_part_pattern(options)
      return regexp_safe_domain(options) unless options[:domain].nil?
      return fqdn_pattern if options[:require_fqdn]
      "#{domain_part_is_correct_length}(?:#{address_literal}|(?:#{host_label_pattern}\\.)*#{tld_label_pattern})"
    end

    def fqdn_pattern
      "(?=.{1,255}$)(?:#{host_label_pattern}\\.)*#{domain_label_pattern}"
    end

    private

    def parse_options(options)
      # `:strict` mode enables `:require_fqdn`, unless it is already explicitly disabled
      options[:require_fqdn] = true if options[:require_fqdn].nil? && options[:mode] == :strict
      default_options.merge(options)
    end

    def regexp_safe_domain(options)
      options[:domain].sub(/\./, '\.')
    end
  end

  def validate_each(record, attribute, value)
    options = @@default_options.merge(self.options)
    record.errors.add(attribute, options[:message] || :invalid) unless self.class.valid?(value, options)
  end
end