email_address 0.0.1 → 0.0.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +1 -0
  3. data/README.md +99 -32
  4. data/email_address.gemspec +2 -1
  5. data/lib/email_address.rb +14 -142
  6. data/lib/email_address/address.rb +70 -0
  7. data/lib/email_address/config.rb +75 -0
  8. data/lib/email_address/domain_matcher.rb +90 -0
  9. data/lib/email_address/domain_parser.rb +71 -0
  10. data/lib/email_address/exchanger.rb +67 -0
  11. data/lib/email_address/host.rb +71 -1
  12. data/lib/email_address/local.rb +97 -0
  13. data/lib/email_address/validator.rb +135 -0
  14. data/lib/email_address/version.rb +1 -1
  15. data/test/email_address/test_address.rb +30 -0
  16. data/test/email_address/test_config.rb +13 -0
  17. data/test/email_address/test_domain_matcher.rb +15 -0
  18. data/test/email_address/test_domain_parser.rb +29 -0
  19. data/test/email_address/test_exchanger.rb +19 -0
  20. data/test/email_address/test_host.rb +43 -0
  21. data/test/email_address/test_local.rb +27 -0
  22. data/test/email_address/test_validator.rb +16 -0
  23. data/test/test_email_address.rb +12 -0
  24. metadata +40 -27
  25. data/lib/email_address/esp.rb +0 -4
  26. data/lib/email_address/providers/default.rb +0 -8
  27. data/lib/email_address/providers/google.rb +0 -8
  28. data/lib/email_providers/address.rb +0 -102
  29. data/lib/email_providers/config.rb +0 -36
  30. data/lib/email_providers/factory.rb +0 -17
  31. data/lib/email_providers/host.rb +0 -87
  32. data/lib/email_providers/mail_exchanger.rb +0 -60
  33. data/lib/email_providers/mailbox.rb +0 -44
  34. data/lib/email_providers/providers/default.rb +0 -55
  35. data/lib/email_providers/providers/google.rb +0 -27
  36. data/lib/email_providers/version.rb +0 -3
  37. data/test/email_address.rb +0 -52
  38. data/test/email_address/address.rb +0 -16
  39. data/test/email_address/config.rb +0 -13
  40. data/test/email_address/host.rb +0 -29
  41. data/test/email_address/mail_exchanger.rb +0 -9
@@ -0,0 +1,90 @@
1
+ module EmailAddress
2
+ ##############################################################################
3
+ # DomainMatcher - Matches a domain to a set of patterns
4
+ #
5
+ # Match Patterns
6
+ # hostname sub.domain.tld
7
+ # domain domain.tld
8
+ # registration domain
9
+ # tld .tld, .domain.tld
10
+ ##############################################################################
11
+ class DomainMatcher
12
+ attr_reader :host_name, :parts, :domain_name, :registration_name,
13
+ :tld, :subdomains, :ip_address
14
+
15
+ def self.matches?(domain, rule)
16
+ DomainMatcher.new(domain, rule).matches?
17
+ end
18
+
19
+ def initialize(host_name, rule=nil)
20
+ @host_name = host_name.downcase
21
+ @host = EmailAddress::Host.new(@host_name)
22
+ @rule = rule
23
+ matches?
24
+ end
25
+
26
+ def matches?(rule=nil)
27
+ rule ||= @rule
28
+ case rule
29
+ when String
30
+ rule_matches?(rule)
31
+ when Array
32
+ list_matches?(rule)
33
+ else
34
+ false
35
+ end
36
+ end
37
+
38
+ def rule_matches?(rule)
39
+ rule.downcase!
40
+ @host_name == rule || registration_name_matches?(rule) ||
41
+ domain_matches?(rule) || tld_matches?(rule)
42
+ end
43
+
44
+ def list_matches?(list)
45
+ list.each {|rule| return true if rule_matches?(rule) }
46
+ false
47
+ end
48
+
49
+ # Does "sub.example.com" match "example" registration name
50
+ def registration_name_matches?(rule)
51
+ rule.match(/\A(\w+)\z/) && @host.registration_name == rule.downcase ? true : false
52
+ end
53
+
54
+ # Does "sub.example.com" match "example.com" domain name
55
+ def domain_matches?(rule)
56
+ rule.match(/\A[^\.]+\.[^\.]+\z/) && @host.domain_name == rule.downcase ? true : false
57
+ end
58
+
59
+ # Does "sub.example.com" match ".com" and ".example.com" top level names?
60
+ def tld_matches?(rule)
61
+ rule.match(/\A\..+\z/) &&
62
+ ( @host_name[-rule.size, rule.size] == rule.downcase || ".#{@host_name}" == rule) \
63
+ ? true : false
64
+ end
65
+
66
+ # Does an IP of mail exchanger for "sub.example.com" match "xxx.xx.xx.xx/xx"?
67
+ def ip_cidr_matches?(rule)
68
+ return false unless rule.match(/\A\d.+\/\d+\z/) && @host.exchanger
69
+ @host.exchanger.in_cidr?(r) ? true : false
70
+ end
71
+
72
+ #def provider_matches?(rule)
73
+ # if rule.downcase.match(/\A\:(\w+)\z/)
74
+ # p ["PROVIDER", rule, @host_name, provider_by_domain ]
75
+ # prov = provider_by_domain
76
+ # prov && prov == $1 ? true : false
77
+ # end
78
+ #end
79
+
80
+ ## Match only by
81
+ #def provider_by_domain
82
+ # base = EmailAddress::Config.providers[:default]
83
+ # EmailAddress::Config.providers.first do |name, defn|
84
+ # defn = base.merge(defn)
85
+ # return name if matches?(defn[:domains])
86
+ # end
87
+ # nil
88
+ #end
89
+ end
90
+ end
@@ -0,0 +1,71 @@
1
+ require 'simpleidn'
2
+
3
+ module EmailAddress
4
+ ##############################################################################
5
+ # Builds hash of host name/domain name parts
6
+ # For: subdomain.example.co.uk
7
+ # host_name: "subdomain.example.co.uk"
8
+ # subdomains: "subdomain"
9
+ # registration_name: "example"
10
+ # domain_name: "example.co.uk"
11
+ # tld: "co.uk"
12
+ # ip_address: nil or "ipaddress" used in [ipaddress] syntax
13
+ ##############################################################################
14
+ class DomainParser
15
+ attr_reader :host_name, :parts, :domain_name, :registration_name,
16
+ :tld, :subdomains, :ip_address
17
+
18
+ def self.parse(domain)
19
+ EmailAddress::DomainParser.new(domain).parts
20
+ end
21
+
22
+ def initialize(host_name)
23
+ @host_name = host_name.downcase
24
+ parse_host(@host_name)
25
+ end
26
+
27
+ def parse_host(host)
28
+ @host_name = host.strip.downcase.gsub(' ', '').gsub(/\(.*\)/, '')
29
+ @subdomains = @registration_name = @domain_name = @tld = ''
30
+ @ip_address = nil
31
+
32
+ # IP Address: [127.0.0.1], [IPV6:.....]
33
+ if @host_name =~ /\A\[(.+)\]\z/
34
+ @ip_address = $1
35
+
36
+ # Subdomain only (root@localhost)
37
+ elsif @host_name.index('.').nil?
38
+ @subdomains = @host_name
39
+
40
+ # Split sub.domain from .tld: *.com, *.xx.cc, *.cc
41
+ elsif @host_name =~ /\A(.+)\.(\w{3,10})\z/ ||
42
+ @host_name =~ /\A(.+)\.(\w{1,3}\.\w\w)\z/ ||
43
+ @host_name =~ /\A(.+)\.(\w\w)\z/
44
+
45
+ @tld = $2;
46
+ sld = $1 # Second level domain
47
+ if sld =~ /\A(.+)\.(.+)\z/ # is subdomain? sub.example [.tld]
48
+ @subdomains = $1
49
+ @registration_name = $2
50
+ else
51
+ @registration_name = sld
52
+ @domain_name = sld + '.' + @tld
53
+ end
54
+ @domain_name = @registration_name + '.' + @tld
55
+ end
56
+ @parts = {host_name:@host_name, subdomains:@subdomains, domain_name:@domain_name,
57
+ registration_name:@registration_name, tld:@tld, ip_address:@ip_address}
58
+ end
59
+
60
+ # Returns provider based on configured domain name matches, or nil if unmatched
61
+ # For best results, # consider the Exchanger.provider result as well.
62
+ def provider
63
+ base = EmailAddress::Config.providers[:default]
64
+ EmailAddress::Config.providers.each do |name, defn|
65
+ defn = base.merge(defn)
66
+ return name if EmailAddress::DomainMatcher.matches?(@host_name, defn[:domains])
67
+ end
68
+ nil
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,67 @@
1
+ require 'resolv'
2
+ require 'netaddr'
3
+ require 'socket'
4
+
5
+ module EmailAddress
6
+ class Exchanger
7
+ include Enumerable
8
+
9
+ def initialize(host, options={})
10
+ @host = host
11
+ @options = options
12
+ end
13
+
14
+ def each(&block)
15
+ mxers.each do |m|
16
+ yield({host:m[0], ip:m[1], priority:m[2]})
17
+ end
18
+ end
19
+
20
+ # Returns the provider name based on the MX-er host names, or nil if not matched
21
+ def provider
22
+ base = EmailAddress::Config.providers[:default]
23
+ EmailAddress::Config.providers.each do |name, defn|
24
+ defn = base.merge(defn)
25
+ self.each do |m|
26
+ return name if DomainMatcher.matches?(m[:host], defn[:exchangers])
27
+ end
28
+ end
29
+ nil
30
+ end
31
+
32
+ def has_dns_a_record?
33
+ dns_a_record.size > 0 ? true : false
34
+ end
35
+
36
+ def dns_a_record
37
+ @_dns_a_record ||= Socket.gethostbyname(@host)
38
+ rescue SocketError # not found, but could also mean network not work
39
+ @_dns_a_record ||= []
40
+ end
41
+
42
+ # Returns: [["mta7.am0.yahoodns.net", "66.94.237.139", 1], ["mta5.am0.yahoodns.net", "67.195.168.230", 1], ["mta6.am0.yahoodns.net", "98.139.54.60", 1]]
43
+ # If not found, returns []
44
+ def mxers
45
+ @mxers ||= Resolv::DNS.open do |dns|
46
+ ress = dns.getresources(@host, Resolv::DNS::Resource::IN::MX)
47
+ ress.map { |r| [r.exchange.to_s, IPSocket::getaddress(r.exchange.to_s), r.preference] }
48
+ end
49
+ end
50
+
51
+ # Returns Array of domain names for the MX'ers, used to determine the Provider
52
+ def domains
53
+ mxers.map {|m| EmailAddress::DomainParser.new(m.first).domain_name}.sort.uniq
54
+ end
55
+
56
+ # Returns an array of MX IP address (String) for the given email domain
57
+ def mx_ips
58
+ mxers(domain).map {|m| m[1] }
59
+ end
60
+
61
+ # Given a cidr (ip/bits) and ip address, returns true on match. Caches cidr object.
62
+ def in_cidr?(cidr)
63
+ @cidr ||= NetAddr::CIDR.create(cidr)
64
+ mx_ips.first { |ip| @cider.matches?(ip) } ? true : false
65
+ end
66
+ end
67
+ end
@@ -1,6 +1,76 @@
1
+ require 'simpleidn'
2
+
1
3
  module EmailAddress
4
+ ##############################################################################
5
+ # Hostname management for the email address
6
+ # IPv6/IPv6: [128.0.0.1], [IPv6:2001:db8:1ff::a0b:dbd0]
7
+ # Comments: (comment)example.com, example.com(comment)
8
+ # Internationalized: Unicode to Punycode
9
+ # Length: up to 255 characters
10
+ # Parts for: subdomain.example.co.uk
11
+ # host_name: "subdomain.example.co.uk"
12
+ # subdomain: "subdomain"
13
+ # registration_name: "example"
14
+ # domain_name: "example.co.uk"
15
+ # tld: "co.uk"
16
+ # ip_address: nil or "ipaddress" used in [ipaddress] syntax
17
+ ##############################################################################
2
18
  class Host
3
- def domain
19
+ attr_reader :host_name, :parts, :domain_name, :registration_name,
20
+ :tld, :subdomains, :ip_address
21
+
22
+ # host name -
23
+ # * full domain name after @ for email types
24
+ # * fully-qualified domain name
25
+ # host type -
26
+ # :email - email address domain
27
+ # :mx - email exchanger domain
28
+ def initialize(host_name, host_type=:email)
29
+ @host_name = host_name.downcase
30
+ @host_type = host_type
31
+ parse_host(@host_name)
4
32
  end
33
+
34
+ def to_s
35
+ @host_name
36
+ end
37
+
38
+ def parse_host(host)
39
+ @parser = EmailAddress::DomainParser.new(host)
40
+ @parts = @parser.parts
41
+ @parts.each { |k,v| instance_variable_set("@#{k}", v) }
42
+ end
43
+
44
+ # The host name to send to DNS lookup, Punycode-escaped
45
+ def dns_host_name
46
+ @dns_host_name ||= ::SimpleIDN.to_ascii(@host_name)
47
+ end
48
+
49
+ def normalize
50
+ dns_host_name
51
+ end
52
+
53
+ # The canonical host name is the simplified, DNS host name
54
+ def canonical
55
+ dns_host_name
56
+ end
57
+
58
+ def exchanger
59
+ return nil unless @host_type == :email
60
+ @exchanger = EmailAddress::Exchanger.new(@host_name)
61
+ end
62
+
63
+ def provider
64
+ @provider ||= @parser.provider
65
+ if !@provider && EmailAddress::Config.options[:check_dns]
66
+ @provider = exchanger.provider
67
+ end
68
+ @provider ||= :unknown
69
+ end
70
+
71
+ def matches?(*names)
72
+ DomainMatcher.matches?(@host_name, names.flatten)
73
+ end
74
+
5
75
  end
6
76
  end
@@ -1,4 +1,101 @@
1
1
  module EmailAddress
2
+ ##############################################################################
3
+ # EmailAddress Local part consists of
4
+ # - comments
5
+ # - mailbox
6
+ # - tag
7
+ #-----------------------------------------------------------------------------
8
+ # Parsing id provider-dependent, but RFC allows:
9
+ # Chars: A-Z a-z 0-9 . ! # $ % ' * + - / = ? ^G _ { | } ~
10
+ # Quoted: space ( ) , : ; < > @ [ ]
11
+ # Quoted-Backslash-Escaped: \ "
12
+ # Quote local part or dot-separated sub-parts x."y".z
13
+ # (comment)mailbox | mailbox(comment)
14
+ # 8-bit/UTF-8: allowed but mail-system defined
15
+ # RFC 5321 also warns that "a host that expects to receive mail SHOULD avoid
16
+ # defining mailboxes where the Local-part requires (or uses) the Quoted-string form".
17
+ # Postmaster: must always be case-insensitive
18
+ # Case: sensitive, but usually treated as equivalent
19
+ # Local Parts: comment, mailbox tag
20
+ # Length: up to 64 characters
21
+ ##############################################################################
2
22
  class Local
23
+ attr_accessor :mailbox, :comment, :tag, :local
24
+ ROLE_NAMES = %w(info marketing sales support abuse noc security postmaster
25
+ hostmaster usenet news webmaster www uucp ftp)
26
+
27
+ def initialize(local, provider=nil)
28
+ @provider = EmailAddress::Config.provider(provider || :default)
29
+ parse(local)
30
+ end
31
+
32
+ def parse(local)
33
+ @local = local =~ /\A"(.)"\z/ ? $1 : local
34
+ @local.gsub!(/\\(.)/, '\1') # Unescape
35
+ @local.downcase! unless @provider[:case_sensitive]
36
+ @local.gsub!(' ','') unless @provider[:keep_space]
37
+
38
+ @mailbox = @local
39
+ @comment = @tag = nil
40
+ parse_comment
41
+ parse_tag
42
+ end
43
+
44
+ def to_s
45
+ normalize
46
+ end
47
+
48
+ def normalize
49
+ m = @mailbox
50
+ m+= @provider[:tag_separator] + @tag if @tag && !@tag.empty?
51
+ m+= "(#{@comment})" if @comment && !@comment.empty? && @provider[:keep_comment]
52
+ format(m)
53
+ end
54
+
55
+ def normalize!
56
+ parse(normalize)
57
+ end
58
+
59
+ def canonical
60
+ m= @mailbox.downcase
61
+ if @provider[:canonical_mailbox]
62
+ m = @provider[:canonical_mailbox].call(m)
63
+ end
64
+ format(m)
65
+ end
66
+
67
+ def canonicalize!
68
+ parse(canonical)
69
+ end
70
+
71
+ def format(m)
72
+ m = m.gsub(/([\\\"])/, '\\\1') # Escape \ and "
73
+ if m =~ /[ \"\(\),:'<>@\[\\\]]/ # Space and "(),:;<>@[\]
74
+ m = %Q("#{m}")
75
+ end
76
+ m
77
+ end
78
+
79
+ def parse_comment
80
+ if @mailbox =~ /\A\((.+?)\)(.+)\z/
81
+ (@comment, @mailbox) = [$1, $2]
82
+ elsif @mailbox =~ /\A(.+)\((.+?)\)\z/
83
+ (@mailbox, @comment) = [$1, $2]
84
+ else
85
+ @comment = '';
86
+ @mailbox = @local
87
+ end
88
+ end
89
+
90
+ def parse_tag
91
+ return unless @provider[:tag_separator]
92
+ parts = @mailbox.split(@provider[:tag_separator], 2)
93
+ (@mailbox, @tag) = *parts if parts.size > 1
94
+ end
95
+
96
+ # RFC2142 - Mailbox Names for Common Services, Rules, and Functions
97
+ def role?
98
+ ROLE_NAMES.include?(@mailbox)
99
+ end
3
100
  end
4
101
  end
@@ -0,0 +1,135 @@
1
+ module EmailAddress
2
+ class Validator
3
+ LEGIBLE_LOCAL_REGEX = /\A[a-z0-9]+(([\.\-\_\'\+][a-z0-9]+)+)?\z/
4
+ DOT_ATOM_REGEX = /[\w\!\#\$\%\&\'\*\+\-\/\=\?\^\`\{\|\}\~]/
5
+
6
+ def self.validate(address, options={})
7
+ EmailAddress::Validator.new(address, options).valid?
8
+ end
9
+
10
+ def initialize(address, options={})
11
+ @address = address
12
+ @local = address.local
13
+ @host = address.host
14
+ @options = options
15
+ @rules = EmailAddress::Config.provider(@host.provider)
16
+ @errors = []
17
+ end
18
+
19
+ def valid?
20
+ return false unless valid_sizes?
21
+ if @rules[:valid_mailbox] && ! @rules[:valid_mailbox].call(@local.to_s)
22
+ #p ["VALIDATOR", @local.to_s, @rules[:valid_mailbox]]
23
+ return invalid(:mailbox_validator)
24
+ else
25
+ return false unless valid_local?
26
+ end
27
+ return invalid(:mx) unless valid_mx? || (valid_dns? && @options[:allow_dns_a])
28
+ true
29
+ end
30
+
31
+ def mailbox_validator(v)
32
+ return true unless v
33
+ if v.is_a?(Proc)
34
+ return invalid(:mailbox_proc) unless @rules[:valid_mailbox].call(@local)
35
+ elsif v == :legible
36
+ return legible?
37
+ elsif v == :rfc
38
+ return rfc_compliant?
39
+ end
40
+ end
41
+
42
+ # True if the DNS A record or MX records are defined
43
+ # Why A record? Some domains are misconfigured with only the A record.
44
+ def valid_dns?
45
+ @host.exchanger.has_dns_a_record?
46
+ end
47
+
48
+ # True if the DNS MX records have been defined. More strict than #valid?
49
+ def valid_mx?
50
+ @host.exchanger.mxers.size > 0
51
+ end
52
+
53
+ # Allows single, simple punctua3Nz=Xj/7c9 tion character between words
54
+ def legible?
55
+ @local.to_s =~ LEGIBLE_LOCAL_REGEX
56
+ end
57
+
58
+ def valid_sizes?
59
+ return invalid(:address_size) unless @rules[:address_size].include?(@address.to_s.size)
60
+ return invalid(:domain_size ) unless @rules[:domain_size ].include?(@host.to_s.size)
61
+ return invalid(:local_size ) unless @rules[:local_size ].include?(@local.to_s.size)
62
+ return invalid(:mailbox_size) unless @rules[:mailbox_size].include?(@local.mailbox.size)
63
+ true
64
+ end
65
+
66
+ def valid_local?
67
+ return invalid(:mailbox) unless valid_local_part?(@local.mailbox)
68
+ return invalid(:comment) unless @local.comment.empty? || valid_local_part?(@local.comment)
69
+ if @local.tag
70
+ @local.tag.split(@rules[:tag_separator]).each do |t|
71
+ return invalid(:tag, t) unless valid_local_part?(t)
72
+ end
73
+ end
74
+ true
75
+ end
76
+
77
+ # Valid within a mailbox, tag, comment
78
+ def valid_local_part?(p)
79
+ p =~ LEGIBLE_LOCAL_REGEX
80
+ end
81
+
82
+
83
+ def invalid(reason, *info)
84
+ @errors << reason
85
+ #p "INVALID ----> #{reason} for #{@local.to_s}@#{@host.to_s} #{info.inspect}"
86
+ false
87
+ end
88
+
89
+ def valid_google_local?
90
+ true
91
+ end
92
+
93
+ ############################################################################
94
+ # RFC5322 Rules (Oct 2008):
95
+ #---------------------------------------------------------------------------
96
+ # addr-spec = local-part "@" domain
97
+ # local-part = dot-atom / quoted-string / obs-local-part
98
+ # domain = dot-atom / domain-literal / obs-domain
99
+ # domain-literal = [CFWS] "[" *([FWS] dtext) [FWS] "]" [CFWS]
100
+ # dtext = %d33-90 / ; Printable US-ASCII
101
+ # %d94-126 / ; characters not including
102
+ # obs-dtext ; "[", "]", or "\"
103
+ # atext = ALPHA / DIGIT / ; Printable US-ASCII
104
+ # "!" / "#" / ; characters not including
105
+ # "$" / "%" / ; specials. Used for atoms.
106
+ # "&" / "'" /
107
+ # "*" / "+" /
108
+ # "-" / "/" /
109
+ # "=" / "?" /
110
+ # "^" / "_" /
111
+ # "`" / "{" /
112
+ # "|" / "}" /
113
+ # "~"
114
+ # atom = [CFWS] 1*atext [CFWS]
115
+ # dot-atom-text = 1*atext *("." 1*atext)
116
+ # dot-atom = [CFWS] dot-atom-text [CFWS]
117
+ # specials = "(" / ")" / ; Special characters that do
118
+ # "<" / ">" / ; not appear in atext
119
+ # "[" / "]" /
120
+ # ":" / ";" /
121
+ # "@" / "\" /
122
+ # "," / "." /
123
+ # DQUOTE
124
+ # qtext = %d33 / ; Printable US-ASCII
125
+ # %d35-91 / ; characters not including
126
+ # %d93-126 / ; "\" or the quote character
127
+ # obs-qtext
128
+ # qcontent = qtext / quoted-pair
129
+ # quoted-string = [CFWS]
130
+ # DQUOTE *([FWS] qcontent) [FWS] DQUOTE
131
+ # [CFWS]
132
+ ############################################################################
133
+
134
+ end
135
+ end