Class: EmailAddress::Host

Inherits:
Object
  • Object
show all
Defined in:
lib/email_address/host.rb

Overview

The EmailAddress Host is found on the right-hand side of the “@” symbol. It can be:

  • Host name (domain name with optional subdomain)

  • International Domain Name, in Unicode (Display) or Punycode (DNS) format

  • IP Address format, either IPv4 or IPv6, enclosed in square brackets. This is not Conventionally supported, but is part of the specification.

  • It can contain an optional comment, enclosed in parenthesis, either at beginning or ending of the host name. This is not well defined, so it not supported here, expect to parse it off, if found.

For matching and query capabilities, the host name is parsed into these parts (with example data for “subdomain.example.co.uk”):

  • host_name: “subdomain.example.co.uk”

  • dns_name: punycode(“subdomain.example.co.uk”)

  • subdomain: “subdomain”

  • registration_name: “example”

  • domain_name: “example.co.uk”

  • tld: “uk”

  • tld2: “co.uk” (the 1 or 2 term TLD we could guess)

  • ip_address: nil or “ipaddress” used in [ipaddress] syntax

The provider (Email Service Provider or ESP) is looked up according to the provider configuration rules, setting the config attribute to values of that provider.

Constant Summary collapse

MAX_HOST_LENGTH =
255
DNS_HOST_REGEX =

Sometimes, you just need a Regexp…

/ [\p{L}\p{N}]+ (?: (?: -{1,2} | \.) [\p{L}\p{N}]+ )*/x
IPV6_HOST_REGEX =

The IPv4 and IPv6 were lifted from Resolv::IPv?::Regex and tweaked to not A…z anchor at the edges.

/\[IPv6:
(?: (?:(?x-mi:
(?:[0-9A-Fa-f]{1,4}:){7}
   [0-9A-Fa-f]{1,4}
)) |
(?:(?x-mi:
(?: (?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?) ::
(?: (?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)
)) |
(?:(?x-mi:
(?: (?:[0-9A-Fa-f]{1,4}:){6,6})
(?: \d+)\.(?: \d+)\.(?: \d+)\.(?: \d+)
)) |
(?:(?x-mi:
(?: (?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?) ::
(?: (?:[0-9A-Fa-f]{1,4}:)*)
(?: \d+)\.(?: \d+)\.(?: \d+)\.(?: \d+)
)))\]/ix
IPV4_HOST_REGEX =
/\[((?x-mi:0
|1(?:[0-9][0-9]?)?
|2(?:[0-4][0-9]?|5[0-5]?|[6-9])?
|[3-9][0-9]?))\.((?x-mi:0
|1(?:[0-9][0-9]?)?
|2(?:[0-4][0-9]?|5[0-5]?|[6-9])?
|[3-9][0-9]?))\.((?x-mi:0
|1(?:[0-9][0-9]?)?
|2(?:[0-4][0-9]?|5[0-5]?|[6-9])?
|[3-9][0-9]?))\.((?x-mi:0
|1(?:[0-9][0-9]?)?
|2(?:[0-4][0-9]?|5[0-5]?|[6-9])?
|[3-9][0-9]?))\]/x
CANONICAL_HOST_REGEX =

Matches conventional host name and punycode: domain.tld, x–punycode.tld

/\A #{DNS_HOST_REGEX} \z/x
STANDARD_HOST_REGEX =

Matches Host forms: DNS name, IPv4, or IPv6 formats

/\A (?: #{DNS_HOST_REGEX}
| #{IPV4_HOST_REGEX} | #{IPV6_HOST_REGEX}) \z/ix

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(host_name, config = {}, locale = "en") ⇒ Host

host name -

* host type - :email for an email host, :mx for exchanger host


85
86
87
88
89
90
91
92
# File 'lib/email_address/host.rb', line 85

def initialize(host_name, config = {}, locale = "en")
  @original = host_name ||= ""
  @locale = locale
  config[:host_type] ||= :email
  @config = config.is_a?(Hash) ? Config.new(config) : config
  @error = @error_message = nil
  parse(host_name)
end

Instance Attribute Details

#commentObject

Returns the value of attribute comment.



33
34
35
# File 'lib/email_address/host.rb', line 33

def comment
  @comment
end

#configObject

Returns the value of attribute config.



33
34
35
# File 'lib/email_address/host.rb', line 33

def config
  @config
end

#dns_nameObject

Returns the value of attribute dns_name.



33
34
35
# File 'lib/email_address/host.rb', line 33

def dns_name
  @dns_name
end

#domain_nameObject

Returns the value of attribute domain_name.



33
34
35
# File 'lib/email_address/host.rb', line 33

def domain_name
  @domain_name
end

#error_messageObject

Returns the value of attribute error_message.



33
34
35
# File 'lib/email_address/host.rb', line 33

def error_message
  @error_message
end

#host_nameObject

Returns the value of attribute host_name.



32
33
34
# File 'lib/email_address/host.rb', line 32

def host_name
  @host_name
end

#ip_addressObject

Returns the value of attribute ip_address.



33
34
35
# File 'lib/email_address/host.rb', line 33

def ip_address
  @ip_address
end

#localeObject

Returns the value of attribute locale.



33
34
35
# File 'lib/email_address/host.rb', line 33

def locale
  @locale
end

#providerObject

Returns the value of attribute provider.



33
34
35
# File 'lib/email_address/host.rb', line 33

def provider
  @provider
end

#reasonObject

Returns the value of attribute reason.



33
34
35
# File 'lib/email_address/host.rb', line 33

def reason
  @reason
end

#registration_nameObject

Returns the value of attribute registration_name.



33
34
35
# File 'lib/email_address/host.rb', line 33

def registration_name
  @registration_name
end

#subdomainsObject

Returns the value of attribute subdomains.



33
34
35
# File 'lib/email_address/host.rb', line 33

def subdomains
  @subdomains
end

#tldObject

Returns the value of attribute tld.



33
34
35
# File 'lib/email_address/host.rb', line 33

def tld
  @tld
end

#tld2Object

Returns the value of attribute tld2.



33
34
35
# File 'lib/email_address/host.rb', line 33

def tld2
  @tld2
end

Instance Method Details

#canonicalObject

The canonical host name is the simplified, DNS host name



109
110
111
# File 'lib/email_address/host.rb', line 109

def canonical
  dns_name
end

#connect(timeout = nil) ⇒ Object

Connects to host to test it can receive email. This should NOT be performed as an email address check, but is provided to assist in problem resolution. If you abuse this, you could be blocked by the ESP.

timeout is the number of seconds to wait before timing out the request and returns false as the connection was unsuccessful.

> NOTE: As of Ruby 3.1, Net::SMTP was moved from the standard library to the > ‘net-smtp’ gem. In order to avoid adding that dependency for this experimental > feature, please add the gem to your Gemfile and require it to use this feature.



474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
# File 'lib/email_address/host.rb', line 474

def connect(timeout = nil)
  smtp = Net::SMTP.new(host_name || ip_address)
  smtp.open_timeout = timeout || @config[:host_timeout]
  smtp.start(@config[:helo_name] || "localhost")
  smtp.finish
  true
rescue Net::SMTPFatalError => e
  set_error(:server_not_available, e.to_s)
rescue SocketError => e
  set_error(:server_not_available, e.to_s)
rescue Net::OpenTimeout => e
  set_error(:server_not_available, e.to_s)
ensure
  smtp.finish if smtp&.started?
end

#dmarcObject

Returns a hash of the domain’s DMARC (en.wikipedia.org/wiki/DMARC) settings.



373
374
375
# File 'lib/email_address/host.rb', line 373

def dmarc
  dns_name ? txt_hash("_dmarc." + dns_name) : {}
end

#dns_a_recordObject

Returns: [official_hostname, alias_hostnames, address_family, *address_list]



328
329
330
331
332
333
# File 'lib/email_address/host.rb', line 328

def dns_a_record
  @_dns_a_record = "0.0.0.0" if @config[:dns_lookup] == :off
  @_dns_a_record ||= Addrinfo.getaddrinfo(dns_name, 80) # Port 80 for A rec, 25 for MX
rescue SocketError # not found, but could also mean network not work
  @_dns_a_record ||= []
end

#dns_enabled?Boolean

True if the :dns_lookup setting is enabled

Returns:

  • (Boolean)


321
322
323
324
325
# File 'lib/email_address/host.rb', line 321

def dns_enabled?
  return false if @config[:dns_lookup] == :off
  return false if @config[:host_validation] == :syntax
  true
end

#domain_matches?(rule) ⇒ Boolean

Does domain == rule or glob matches? (also tests the DNS (punycode) name) Requires optionally starts with a “@”.

Returns:

  • (Boolean)


301
302
303
304
305
306
# File 'lib/email_address/host.rb', line 301

def domain_matches?(rule)
  rule = $1 if rule =~ /\A@(.+)/
  return rule if domain_name && File.fnmatch?(rule, domain_name)
  return rule if dns_name && File.fnmatch?(rule, dns_name)
  false
end

#errorObject

The inverse of valid? – Returns nil (falsey) if valid, otherwise error message



498
499
500
# File 'lib/email_address/host.rb', line 498

def error
  valid? ? nil : @error_message
end

#exchangersObject

Returns an array of Exchanger hosts configured in DNS. The array will be empty if none are configured.



337
338
339
340
# File 'lib/email_address/host.rb', line 337

def exchangers
  # return nil if @config[:host_type] != :email || !self.dns_enabled?
  @_exchangers ||= Exchanger.cached(dns_name, @config)
end

#find_providerObject

:nodoc:



208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/email_address/host.rb', line 208

def find_provider # :nodoc:
  return provider if provider

  Config.providers.each do |provider, config|
    if config[:host_match] && matches?(config[:host_match])
      return set_provider(provider, config)
    end
  end

  return set_provider(:default) unless dns_enabled?

  self.provider ||= set_provider(:default)
end

#fqdn?Boolean

Is this a fully-qualified domain name?

Returns:

  • (Boolean)


243
244
245
# File 'lib/email_address/host.rb', line 243

def fqdn?
  tld ? true : false
end

#fully_qualified_domain_name(host_part) ⇒ Object



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
# File 'lib/email_address/host.rb', line 183

def fully_qualified_domain_name(host_part)
  dn = @config[:address_fqdn_domain]
  if !dn
    if (host_part.nil? || host_part <= " ") && @config[:host_local] && @config[:host_auto_append]
      "localhost"
    else
      host_part
    end
  elsif host_part.nil? || host_part <= " "
    dn
  elsif !host_part.include?(".")
    host_part + "." + dn
  else
    host_part
  end
end

#hosted_providerObject



234
235
236
# File 'lib/email_address/host.rb', line 234

def hosted_provider
  Exchanger.cached(dns_name).provider
end

#hosted_service?Boolean

True if host is hosted at the provider, not a public provider host name

Returns:

  • (Boolean)


201
202
203
204
205
206
# File 'lib/email_address/host.rb', line 201

def hosted_service?
  return false unless registration_name
  find_provider
  return false unless config[:host_match]
  !matches?(config[:host_match])
end

#ip?Boolean

Returns:

  • (Boolean)


247
248
249
# File 'lib/email_address/host.rb', line 247

def ip?
  !!ip_address
end

#ip_matches?(cidr) ⇒ Boolean

True if the host is an IP Address form, and that address matches the passed CIDR string (“10.9.8.0/24” or “2001:.…/64”)

Returns:

  • (Boolean)


310
311
312
313
314
# File 'lib/email_address/host.rb', line 310

def ip_matches?(cidr)
  return false unless ip_address
  net = IPAddr.new(cidr)
  net.include?(IPAddr.new(ip_address))
end

#ipv4?Boolean

Returns:

  • (Boolean)


251
252
253
# File 'lib/email_address/host.rb', line 251

def ipv4?
  ip? && ip_address.include?(".")
end

#ipv6?Boolean

Returns:

  • (Boolean)


255
256
257
# File 'lib/email_address/host.rb', line 255

def ipv6?
  ip? && ip_address.include?(":")
end

#localhost?Boolean

Returns:

  • (Boolean)


458
459
460
461
462
# File 'lib/email_address/host.rb', line 458

def localhost?
  return true if host_name == "localhost"
  return false unless ip_address
  IPAddr.new(ip_address).loopback?
end

#matches?(rules) ⇒ Boolean

Takes a email address string, returns true if it matches a rule Rules of the follow formats are evaluated:

  • “example.” => registration name

  • “.com” => top-level domain name

  • “google” => email service provider designation

  • “@goog*.com” => Glob match

  • IPv4 or IPv6 or CIDR Address

Returns:

  • (Boolean)


270
271
272
273
274
275
276
277
278
279
280
281
282
# File 'lib/email_address/host.rb', line 270

def matches?(rules)
  rules = Array(rules)
  return false if rules.empty?
  rules.each do |rule|
    return rule if rule == domain_name || rule == dns_name
    return rule if registration_name_matches?(rule)
    return rule if tld_matches?(rule)
    return rule if domain_matches?(rule)
    return rule if self.provider && provider_matches?(rule)
    return rule if ip_matches?(rule)
  end
  false
end

#mungeObject

Returns the munged version of the name, replacing everything after the initial two characters with “*****” or the configured “munge_string”.



115
116
117
# File 'lib/email_address/host.rb', line 115

def munge
  host_name.sub(/\A(.{1,2}).*/) { |m| $1 + @config[:munge_string] }
end

#nameObject Also known as: to_s

Returns the String representation of the host name (or IP)



95
96
97
98
99
100
101
102
103
104
105
# File 'lib/email_address/host.rb', line 95

def name
  if ipv4?
    "[#{ip_address}]"
  elsif ipv6?
    "[IPv6:#{ip_address}]"
  elsif @config[:host_encoding] && @config[:host_encoding] == :unicode
    ::SimpleIDN.to_unicode(host_name)
  else
    dns_name
  end
end

#parse(host) ⇒ Object

Parsing



123
124
125
126
127
128
129
130
131
132
133
# File 'lib/email_address/host.rb', line 123

def parse(host) # :nodoc:
  host = parse_comment(host)

  if host =~ /\A\[IPv6:(.+)\]/i
    self.ip_address = $1
  elsif host =~ /\A\[(\d{1,3}(\.\d{1,3}){3})\]/ # IPv4
    self.ip_address = $1
  else
    self.host_name = host
  end
end

#parse_comment(host) ⇒ Object

:nodoc:



135
136
137
138
139
140
141
142
143
# File 'lib/email_address/host.rb', line 135

def parse_comment(host) # :nodoc:
  if host =~ /\A\((.+?)\)(.+)/ # (comment)domain.tld
    self.comment, host = $1, $2
  end
  if host =~ /\A(.+)\((.+?)\)\z/ # domain.tld(comment)
    host, self.comment = $1, $2
  end
  host
end

#partsObject

Returns a hash of the parts of the host name after parsing.



228
229
230
231
232
# File 'lib/email_address/host.rb', line 228

def parts
  {host_name: host_name, dns_name: dns_name, subdomain: subdomains,
   registration_name: registration_name, domain_name: domain_name,
   tld2: tld2, tld: tld, ip_address: ip_address}
end

#provider_matches?(rule) ⇒ Boolean

Returns:

  • (Boolean)


295
296
297
# File 'lib/email_address/host.rb', line 295

def provider_matches?(rule)
  rule.to_s =~ /\A[\w\-]*\z/ && self.provider && self.provider == rule.to_sym
end

#registration_name_matches?(rule) ⇒ Boolean

Does “example.” match any tld?

Returns:

  • (Boolean)


285
286
287
# File 'lib/email_address/host.rb', line 285

def registration_name_matches?(rule)
  rule == "#{registration_name}."
end

#set_error(err, reason = nil) ⇒ Object



490
491
492
493
494
495
# File 'lib/email_address/host.rb', line 490

def set_error(err, reason = nil)
  @error = err
  @reason = reason
  @error_message = Config.error_message(err, locale)
  false
end

#set_provider(name, provider_config = {}) ⇒ Object

:nodoc:



222
223
224
225
# File 'lib/email_address/host.rb', line 222

def set_provider(name, provider_config = {}) # :nodoc:
  config.configure(provider_config)
  @provider = name
end

#tld_matches?(rule) ⇒ Boolean

Does “sub.example.com” match “.com” and “.example.com” top level names? Matches TLD (uk) or TLD2 (co.uk)

Returns:

  • (Boolean)


291
292
293
# File 'lib/email_address/host.rb', line 291

def tld_matches?(rule)
  rule.match(/\A\.(.+)\z/) && ($1 == tld || $1 == tld2) # ? true : false
end

#txt(alternate_host = nil) ⇒ Object

Returns a DNS TXT Record



343
344
345
346
347
348
349
350
351
352
353
354
355
356
# File 'lib/email_address/host.rb', line 343

def txt(alternate_host = nil)
  return nil unless dns_enabled?
  Resolv::DNS.open do |dns|
    dns.timeouts = @config[:dns_timeout] if @config[:dns_timeout]
    records = begin
      dns.getresources(alternate_host || dns_name,
        Resolv::DNS::Resource::IN::TXT)
    rescue Resolv::ResolvTimeout
      []
    end

    records.empty? ? nil : records.map(&:data).join(" ")
  end
end

#txt_hash(alternate_host = nil) ⇒ Object

Parses TXT record pairs into a hash



359
360
361
362
363
364
365
366
367
368
369
# File 'lib/email_address/host.rb', line 359

def txt_hash(alternate_host = nil)
  fields = {}
  record = txt(alternate_host)
  return fields unless record

  record.split(/\s*;\s*/).each do |pair|
    (n, v) = pair.split(/\s*=\s*/)
    fields[n.to_sym] = v
  end
  fields
end

#valid?(rules = {}) ⇒ Boolean

Returns true if the host name is valid according to the current configuration

Returns:

  • (Boolean)


382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
# File 'lib/email_address/host.rb', line 382

def valid?(rules = {})
  host_validation = rules[:host_validation] || @config[:host_validation] || :mx
  dns_lookup = rules[:dns_lookup] || host_validation
  self.error_message = nil
  if host_name && !host_name.empty? && !@config[:host_size].include?(host_name.size)
    return set_error(:invalid_host)
  end
  if ip_address
    valid_ip?
  elsif !valid_format?
    false
  elsif dns_lookup == :connect
    valid_mx? && connect
  elsif dns_lookup == :mx
    valid_mx?
  elsif dns_lookup == :a
    valid_dns?
  else
    true
  end
end

#valid_dns?Boolean

True if the host name has a DNS A Record

Returns:

  • (Boolean)


405
406
407
408
# File 'lib/email_address/host.rb', line 405

def valid_dns?
  return true unless dns_enabled?
  dns_a_record.size > 0 || set_error(:domain_unknown)
end

#valid_format?Boolean

True if the host_name passes Regular Expression match and size limits.

Returns:

  • (Boolean)


429
430
431
432
433
434
435
436
437
438
439
# File 'lib/email_address/host.rb', line 429

def valid_format?
  if host_name =~ CANONICAL_HOST_REGEX && to_s.size <= MAX_HOST_LENGTH
    if localhost?
      return @config[:host_local] ? true : set_error(:domain_no_localhost)
    end

    return true if !@config[:host_fqdn]
    return true if host_name.include?(".") # require FQDN
  end
  set_error(:domain_invalid)
end

#valid_ip?Boolean

Returns true if the IP address given in that form of the host name is a potentially valid IP address. It does not check if the address is reachable.

Returns:

  • (Boolean)


444
445
446
447
448
449
450
451
452
453
454
455
456
# File 'lib/email_address/host.rb', line 444

def valid_ip?
  if !@config[:host_allow_ip]
    bool = set_error(:ip_address_forbidden)
  elsif ip_address.include?(":")
    bool = ip_address.match(Resolv::IPv6::Regex) ? true : set_error(:ipv6_address_invalid)
  elsif ip_address.include?(".")
    bool = ip_address.match(Resolv::IPv4::Regex) ? true : set_error(:ipv4_address_invalid)
  end
  if bool && (localhost? && !@config[:host_local])
    bool = set_error(:ip_address_no_localhost)
  end
  bool
end

#valid_mx?Boolean

True if the host name has valid MX servers configured in DNS

Returns:

  • (Boolean)


411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
# File 'lib/email_address/host.rb', line 411

def valid_mx?
  return true unless dns_enabled?
  if exchangers.nil?
    set_error(:domain_unknown)
  elsif exchangers.mx_ips.size > 0
    if localhost? && !@config[:host_local]
      set_error(:domain_no_localhost)
    else
      true
    end
  elsif @config[:dns_timeout].nil? && valid_dns?
    set_error(:domain_does_not_accept_email)
  else
    set_error(:domain_unknown)
  end
end