Skip to content

Commit

Permalink
pull out dns and use a class level cache
Browse files Browse the repository at this point in the history
  • Loading branch information
phoet committed Feb 12, 2025
1 parent ba74dd7 commit 243e32f
Show file tree
Hide file tree
Showing 9 changed files with 151 additions and 120 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ jobs:
strategy:
fail-fast: false
matrix:
gemfile: [ activemodel6, activemodel7 ]
ruby: [3.1, 3.2, 3.3]
gemfile: [activemodel6, activemodel7, activemodel8]
ruby: [3.1, 3.2, 3.3, 3.4]
runs-on: ubuntu-latest
env:
BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile
Expand Down
5 changes: 5 additions & 0 deletions gemfiles/activemodel8.gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
source 'https://rubygems.org'

gem 'activemodel', '~> 8.0'

gemspec path: '../'
48 changes: 7 additions & 41 deletions lib/valid_email2/address.rb
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
# frozen_string_literal:true

require "valid_email2"
require "resolv"
require "mail"
require "valid_email2/dns_records_cache"
require "valid_email2/dns"

module ValidEmail2
class Address
Expand All @@ -29,11 +28,10 @@ def self.permitted_multibyte_characters_regex=(val)
@permitted_multibyte_characters_regex = val
end

def initialize(address, dns_timeout = 5, dns_nameserver = nil)
def initialize(address, dns = Dns.new)
@parse_error = false
@raw_address = address
@dns_timeout = dns_timeout
@dns_nameserver = dns_nameserver
@dns = dns

begin
@address = Mail::Address.new(address)
Expand Down Expand Up @@ -103,14 +101,14 @@ def valid_mx?
return false unless valid?
return false if null_mx?

mx_or_a_servers.any?
@dns.mx_servers(address.domain).any? || @dns.a_servers(address.domain).any?
end

def valid_strict_mx?
return false unless valid?
return false if null_mx?

mx_servers.any?
@dns.mx_servers(address.domain).any?
end

private
Expand All @@ -126,7 +124,7 @@ def domain_is_in?(domain_list)
end

def mx_server_is_in?(domain_list)
mx_servers.any? { |mx_server|
@dns.mx_servers(address.domain).any? { |mx_server|
return false unless mx_server.respond_to?(:exchange)

mx_server = mx_server.exchange.to_s
Expand All @@ -145,41 +143,9 @@ def address_contain_multibyte_characters?
@raw_address.each_char.any? { |char| char.bytesize > 1 && char !~ self.class.permitted_multibyte_characters_regex }
end

def resolv_config
@resolv_config ||= begin
config = Resolv::DNS::Config.default_config_hash
config[:nameserver] = @dns_nameserver if @dns_nameserver
config
end

@resolv_config
end

def mx_servers
@mx_servers_cache ||= ValidEmail2::DnsRecordsCache.new

@mx_servers_cache.fetch(address.domain.downcase) do
Resolv::DNS.open(resolv_config) do |dns|
dns.timeouts = @dns_timeout
dns.getresources(address.domain, Resolv::DNS::Resource::IN::MX)
end
end
end

def null_mx?
mx_servers = @dns.mx_servers(address.domain)
mx_servers.length == 1 && mx_servers.first.preference == 0 && mx_servers.first.exchange.length == 0
end

def mx_or_a_servers
@mx_or_a_servers_cache ||= ValidEmail2::DnsRecordsCache.new

@mx_or_a_servers_cache.fetch(address.domain.downcase) do
Resolv::DNS.open(resolv_config) do |dns|
dns.timeouts = @dns_timeout
(mx_servers.any? && mx_servers) ||
dns.getresources(address.domain, Resolv::DNS::Resource::IN::A)
end
end
end
end
end
71 changes: 71 additions & 0 deletions lib/valid_email2/dns.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
require "resolv"

module ValidEmail2
class Dns
MAX_CACHE_SIZE = 1_000
CACHE = {}

CacheEntry = Struct.new(:records, :cached_at, :ttl)

def self.prune_cache
entries_sorted_by_cached_at_asc = CACHE.sort_by { |key, data| data.cached_at }
entries_to_remove = entries_sorted_by_cached_at_asc.first(CACHE.size - MAX_CACHE_SIZE)
entries_to_remove.each { |key, _value| CACHE.delete(key) }
end

def self.clear_cache
CACHE.clear
end

def initialize(dns_timeout = 5, dns_nameserver = nil)
@dns_timeout = dns_timeout
@dns_nameserver = dns_nameserver
end

def mx_servers(domain)
fetch(domain, Resolv::DNS::Resource::IN::MX)
end

def a_servers(domain)
fetch(domain, Resolv::DNS::Resource::IN::A)
end

private

def prune_cache
self.class.prune_cache
end

def fetch(domain, type)
prune_cache if CACHE.size > MAX_CACHE_SIZE

domain = domain.downcase
cache_key = [domain, type]
cache_entry = CACHE[cache_key]

if cache_entry && Time.now - cache_entry.cached_at < cache_entry.ttl
return cache_entry.records
else
CACHE.delete(cache_key)
end

records = Resolv::DNS.open(resolv_config) do |dns|
dns.timeouts = @dns_timeout
dns.getresources(domain, type)
end

if records.any?
ttl = records.map(&:ttl).min
CACHE[cache_key] = CacheEntry.new(records: records, cached_at: Time.now, ttl: ttl)
end

records
end

def resolv_config
config = Resolv::DNS::Config.default_config_hash
config[:nameserver] = @dns_nameserver if @dns_nameserver
config
end
end
end
37 changes: 0 additions & 37 deletions lib/valid_email2/dns_records_cache.rb

This file was deleted.

4 changes: 3 additions & 1 deletion lib/valid_email2/email_validator.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require "valid_email2/address"
require "logger" # Fix concurrent-ruby removing logger dependency which Rails itself does not have
require "active_model"
require "active_model/validations"

Expand All @@ -12,7 +13,8 @@ def validate_each(record, attribute, value)
return unless value.present?
options = default_options.merge(self.options)

addresses = sanitized_values(value).map { |v| ValidEmail2::Address.new(v, options[:dns_timeout], options[:dns_nameserver]) }
dns = ValidEmail2::Dns.new(options[:dns_timeout], options[:dns_nameserver])
addresses = sanitized_values(value).map { |v| ValidEmail2::Address.new(v, dns) }

error(record, attribute) && return unless addresses.all?(&:valid?)

Expand Down
Loading

0 comments on commit 243e32f

Please sign in to comment.