Skip to content

Commit 243e32f

Browse files
committed
pull out dns and use a class level cache
1 parent ba74dd7 commit 243e32f

File tree

9 files changed

+151
-120
lines changed

9 files changed

+151
-120
lines changed

.github/workflows/ci.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ jobs:
55
strategy:
66
fail-fast: false
77
matrix:
8-
gemfile: [ activemodel6, activemodel7 ]
9-
ruby: [3.1, 3.2, 3.3]
8+
gemfile: [activemodel6, activemodel7, activemodel8]
9+
ruby: [3.1, 3.2, 3.3, 3.4]
1010
runs-on: ubuntu-latest
1111
env:
1212
BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile

gemfiles/activemodel8.gemfile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
source 'https://rubygems.org'
2+
3+
gem 'activemodel', '~> 8.0'
4+
5+
gemspec path: '../'

lib/valid_email2/address.rb

Lines changed: 7 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
# frozen_string_literal:true
22

33
require "valid_email2"
4-
require "resolv"
54
require "mail"
6-
require "valid_email2/dns_records_cache"
5+
require "valid_email2/dns"
76

87
module ValidEmail2
98
class Address
@@ -29,11 +28,10 @@ def self.permitted_multibyte_characters_regex=(val)
2928
@permitted_multibyte_characters_regex = val
3029
end
3130

32-
def initialize(address, dns_timeout = 5, dns_nameserver = nil)
31+
def initialize(address, dns = Dns.new)
3332
@parse_error = false
3433
@raw_address = address
35-
@dns_timeout = dns_timeout
36-
@dns_nameserver = dns_nameserver
34+
@dns = dns
3735

3836
begin
3937
@address = Mail::Address.new(address)
@@ -103,14 +101,14 @@ def valid_mx?
103101
return false unless valid?
104102
return false if null_mx?
105103

106-
mx_or_a_servers.any?
104+
@dns.mx_servers(address.domain).any? || @dns.a_servers(address.domain).any?
107105
end
108106

109107
def valid_strict_mx?
110108
return false unless valid?
111109
return false if null_mx?
112110

113-
mx_servers.any?
111+
@dns.mx_servers(address.domain).any?
114112
end
115113

116114
private
@@ -126,7 +124,7 @@ def domain_is_in?(domain_list)
126124
end
127125

128126
def mx_server_is_in?(domain_list)
129-
mx_servers.any? { |mx_server|
127+
@dns.mx_servers(address.domain).any? { |mx_server|
130128
return false unless mx_server.respond_to?(:exchange)
131129

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

148-
def resolv_config
149-
@resolv_config ||= begin
150-
config = Resolv::DNS::Config.default_config_hash
151-
config[:nameserver] = @dns_nameserver if @dns_nameserver
152-
config
153-
end
154-
155-
@resolv_config
156-
end
157-
158-
def mx_servers
159-
@mx_servers_cache ||= ValidEmail2::DnsRecordsCache.new
160-
161-
@mx_servers_cache.fetch(address.domain.downcase) do
162-
Resolv::DNS.open(resolv_config) do |dns|
163-
dns.timeouts = @dns_timeout
164-
dns.getresources(address.domain, Resolv::DNS::Resource::IN::MX)
165-
end
166-
end
167-
end
168-
169146
def null_mx?
147+
mx_servers = @dns.mx_servers(address.domain)
170148
mx_servers.length == 1 && mx_servers.first.preference == 0 && mx_servers.first.exchange.length == 0
171149
end
172-
173-
def mx_or_a_servers
174-
@mx_or_a_servers_cache ||= ValidEmail2::DnsRecordsCache.new
175-
176-
@mx_or_a_servers_cache.fetch(address.domain.downcase) do
177-
Resolv::DNS.open(resolv_config) do |dns|
178-
dns.timeouts = @dns_timeout
179-
(mx_servers.any? && mx_servers) ||
180-
dns.getresources(address.domain, Resolv::DNS::Resource::IN::A)
181-
end
182-
end
183-
end
184150
end
185151
end

lib/valid_email2/dns.rb

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
require "resolv"
2+
3+
module ValidEmail2
4+
class Dns
5+
MAX_CACHE_SIZE = 1_000
6+
CACHE = {}
7+
8+
CacheEntry = Struct.new(:records, :cached_at, :ttl)
9+
10+
def self.prune_cache
11+
entries_sorted_by_cached_at_asc = CACHE.sort_by { |key, data| data.cached_at }
12+
entries_to_remove = entries_sorted_by_cached_at_asc.first(CACHE.size - MAX_CACHE_SIZE)
13+
entries_to_remove.each { |key, _value| CACHE.delete(key) }
14+
end
15+
16+
def self.clear_cache
17+
CACHE.clear
18+
end
19+
20+
def initialize(dns_timeout = 5, dns_nameserver = nil)
21+
@dns_timeout = dns_timeout
22+
@dns_nameserver = dns_nameserver
23+
end
24+
25+
def mx_servers(domain)
26+
fetch(domain, Resolv::DNS::Resource::IN::MX)
27+
end
28+
29+
def a_servers(domain)
30+
fetch(domain, Resolv::DNS::Resource::IN::A)
31+
end
32+
33+
private
34+
35+
def prune_cache
36+
self.class.prune_cache
37+
end
38+
39+
def fetch(domain, type)
40+
prune_cache if CACHE.size > MAX_CACHE_SIZE
41+
42+
domain = domain.downcase
43+
cache_key = [domain, type]
44+
cache_entry = CACHE[cache_key]
45+
46+
if cache_entry && Time.now - cache_entry.cached_at < cache_entry.ttl
47+
return cache_entry.records
48+
else
49+
CACHE.delete(cache_key)
50+
end
51+
52+
records = Resolv::DNS.open(resolv_config) do |dns|
53+
dns.timeouts = @dns_timeout
54+
dns.getresources(domain, type)
55+
end
56+
57+
if records.any?
58+
ttl = records.map(&:ttl).min
59+
CACHE[cache_key] = CacheEntry.new(records: records, cached_at: Time.now, ttl: ttl)
60+
end
61+
62+
records
63+
end
64+
65+
def resolv_config
66+
config = Resolv::DNS::Config.default_config_hash
67+
config[:nameserver] = @dns_nameserver if @dns_nameserver
68+
config
69+
end
70+
end
71+
end

lib/valid_email2/dns_records_cache.rb

Lines changed: 0 additions & 37 deletions
This file was deleted.

lib/valid_email2/email_validator.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
require "valid_email2/address"
2+
require "logger" # Fix concurrent-ruby removing logger dependency which Rails itself does not have
23
require "active_model"
34
require "active_model/validations"
45

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

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

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

0 commit comments

Comments
 (0)