email_address 0.0.1 → 0.0.2

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