deliverable 0.1.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6939d9e02d7269dc2704b727f9aaf27b2374e271d88c03e48c126bfe6695132a
4
+ data.tar.gz: e0eca76ecdfc1478bfe3c507555c4b59054ac898a658499a573b86478f87c684
5
+ SHA512:
6
+ metadata.gz: 101133007279b3c6610dcad60d8c9dcb0a86a8ff9d5f7aa215d29cbe3cba1d0daf53bcd75f32afdc07ac56e154f019fd537dc4b6b15a7f2670cacaa9ae474c58
7
+ data.tar.gz: d7af7df7bb8ac409e211103eca6851ab63142cc9dc38fece9ae7cd9e3b2d00fea250466c97e8ea69ebc9702712bece6f1e5f0b182b2e42e971bc6adc862c7aba
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Erik Strömberg
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # deliverable
2
+
3
+ Honest email verification for Ruby. Tells you when an address is deliverable, when it's a fake, and — crucially — when it doesn't know.
4
+
5
+ ```ruby
6
+ Deliverable.verify("ada@example.com")
7
+ # => #<Deliverable::Result valid? classification="valid" score=95 ...>
8
+ ```
9
+
10
+ ```
11
+ $ deliverable ada@example.com
12
+ VALID ada@example.com score 95
13
+
14
+ ✓ syntax
15
+ ✓ mx record
16
+ ✓ smtp deliverable
17
+ ✗ disposable
18
+ ✗ accepts any email
19
+ ```
20
+
21
+ ## Why another one
22
+
23
+ Most email verification gems run a syntax regex, check for an MX record, optionally do an SMTP `RCPT TO`, and return a boolean. That falls apart in practice:
24
+
25
+ - **Multi-port SMTP fallback.** Many mail servers reject port 25 from residential / cloud IPs but accept 587 or 465. `deliverable` tries `25 → 587 → 465` and only gives up when all three fail.
26
+ - **Catch-all detection.** A server that says yes to `ada@example.com` and *also* says yes to `definitely-does-not-exist-8482@example.com` isn't actually verifying anything. `deliverable` probes a fake address per domain and downgrades the result to `risky` when it catches one.
27
+ - **Problematic-server awareness.** Gmail, Outlook, Office 365, Mimecast, and Proofpoint actively defeat SMTP probing. Other gems return false positives or false negatives on these. `deliverable` recognises them and returns `risky` with `smtp_failed_assumption: true` instead of pretending to know.
28
+ - **Three-way classification.** `valid`, `risky`, `invalid` — not a boolean. Risky is where the money is: it's the address that *might* bounce.
29
+
30
+ ## Install
31
+
32
+ ```ruby
33
+ gem "deliverable"
34
+ ```
35
+
36
+ Or:
37
+
38
+ ```sh
39
+ gem install deliverable
40
+ ```
41
+
42
+ ## Library
43
+
44
+ ```ruby
45
+ require "deliverable"
46
+
47
+ Deliverable.configure do |c|
48
+ c.sender_email = "verify@yourdomain.com"
49
+ c.sender_domain = "yourdomain.com"
50
+ end
51
+
52
+ result = Deliverable.verify("ada@example.com")
53
+
54
+ result.valid? # => true
55
+ result.classification # => "valid" | "risky" | "invalid"
56
+ result.score # => 0..100
57
+ result.errors # => []
58
+ result.warnings # => []
59
+ result.checks # => { syntax:, mx_record:, smtp_deliverable:, disposable:, typo_suggestion:, accepts_any_email: }
60
+ result.to_h # full hash for logging / serialization
61
+ ```
62
+
63
+ Skip the SMTP probe (faster, lower confidence):
64
+
65
+ ```ruby
66
+ Deliverable.verify("ada@example.com", smtp: false)
67
+ ```
68
+
69
+ ## CLI
70
+
71
+ ```
72
+ $ deliverable ada@example.com
73
+ $ deliverable ada@example.com --no-smtp
74
+ $ deliverable ada@example.com --json
75
+ $ deliverable ada@example.com --timeout 5
76
+ $ deliverable ada@gmial.com # suggests gmail.com via Levenshtein
77
+ ```
78
+
79
+ Exit codes:
80
+
81
+ | Code | Meaning |
82
+ |------|---------|
83
+ | 0 | valid |
84
+ | 1 | risky |
85
+ | 2 | invalid |
86
+ | 64 | usage error |
87
+
88
+ ## Sender identity
89
+
90
+ SMTP servers want to know who's asking. `deliverable` issues `EHLO sender_domain` and `MAIL FROM:<sender_email>` during the probe. There is no default — running an SMTP probe without configuring a sender raises `Deliverable::SenderNotConfigured`. Lying about your identity to a stranger's mail server tends to cause silent rejections, so the gem refuses to do it for you.
91
+
92
+ Configure once:
93
+
94
+ ```ruby
95
+ Deliverable.configure do |c|
96
+ c.sender_email = "verify@yourdomain.com"
97
+ c.sender_domain = "yourdomain.com"
98
+ end
99
+ ```
100
+
101
+ Or per-call:
102
+
103
+ ```ruby
104
+ Deliverable.verify("ada@gmail.com", sender_email: "verify@you.com", sender_domain: "you.com")
105
+ ```
106
+
107
+ The CLI reads `DELIVERABLE_SENDER_EMAIL` and `DELIVERABLE_SENDER_DOMAIN` from the environment, or accepts `--sender` and `--helo` flags. If you don't need the SMTP probe, pass `smtp: false` (library) or `--no-smtp` (CLI) and no sender is required.
108
+
109
+ ## Notes on accuracy
110
+
111
+ SMTP verification is a probabilistic signal, not a guarantee. Servers can lie, greylist, or reject all probes from your IP regardless of recipient. `deliverable` returns `classification: "risky"` whenever it had to make an assumption — that's the "I don't know" answer most other gems hide as `valid`.
112
+
113
+ If you're verifying at scale, expect to be IP-blocked by major providers (Gmail, Outlook). Either rotate sender IPs, accept that bulk gmail/outlook checks return `risky`, or use a paid API.
114
+
115
+ ## When you outgrow this gem
116
+
117
+ `deliverable` is built and maintained by [PeopleDB](https://peopledb.co). If you need more than per-address SMTP probing — bulk verification, deliverability data merged with profile information from multiple sources, or contact lookups by LinkedIn / GitHub identifier — the [PeopleDB API](https://peopledb.co) does that.
118
+
119
+ ## License
120
+
121
+ MIT.
data/exe/deliverable ADDED
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "optparse"
4
+ require "json"
5
+ require "deliverable"
6
+
7
+ options = {
8
+ smtp: true,
9
+ json: false,
10
+ timeout: 10,
11
+ sender_email: ENV["DELIVERABLE_SENDER_EMAIL"],
12
+ sender_domain: ENV["DELIVERABLE_SENDER_DOMAIN"]
13
+ }
14
+
15
+ parser = OptionParser.new do |opts|
16
+ opts.banner = "Usage: deliverable EMAIL [options]"
17
+ opts.separator ""
18
+ opts.separator "Verifies whether an email address is deliverable."
19
+ opts.separator ""
20
+ opts.separator "Options:"
21
+
22
+ opts.on("--no-smtp", "Skip SMTP probe (syntax + MX only)") { options[:smtp] = false }
23
+ opts.on("--json", "Emit JSON instead of human output") { options[:json] = true }
24
+ opts.on("--timeout N", Integer, "SMTP timeout in seconds (default 10)") { |n| options[:timeout] = n }
25
+ opts.on("--sender EMAIL", "Sender email used in MAIL FROM") { |e| options[:sender_email] = e }
26
+ opts.on("--helo DOMAIN", "Domain used in HELO/EHLO") { |d| options[:sender_domain] = d }
27
+ opts.on("-v", "--version", "Show version") { puts Deliverable::VERSION; exit 0 }
28
+ opts.on("-h", "--help", "Show this help") { puts opts; exit 0 }
29
+ end
30
+
31
+ parser.parse!
32
+
33
+ def smtp_mark(value, pass, fail_, skip)
34
+ case value
35
+ when true then pass
36
+ when false then fail_
37
+ else skip
38
+ end
39
+ end
40
+
41
+ def smtp_note(value)
42
+ case value
43
+ when nil then "skipped"
44
+ when true then nil
45
+ when false then "rejected"
46
+ end
47
+ end
48
+
49
+ def catchall_mark(value, pass, warn, skip)
50
+ case value
51
+ when true then warn
52
+ when false then pass
53
+ else skip
54
+ end
55
+ end
56
+
57
+ def catchall_note(value)
58
+ case value
59
+ when nil then "not checked"
60
+ when true then "yes"
61
+ when false then "no"
62
+ end
63
+ end
64
+
65
+ email = ARGV.shift
66
+ if email.nil? || email.empty?
67
+ warn parser
68
+ exit 64
69
+ end
70
+
71
+ begin
72
+ result = Deliverable.verify(
73
+ email,
74
+ smtp: options[:smtp],
75
+ timeout: options[:timeout],
76
+ sender_email: options[:sender_email],
77
+ sender_domain: options[:sender_domain]
78
+ )
79
+ rescue Deliverable::SenderNotConfigured
80
+ warn "deliverable: SMTP probing requires a sender identity."
81
+ warn ""
82
+ warn " Pass --sender verify@yourdomain.com --helo yourdomain.com,"
83
+ warn " set DELIVERABLE_SENDER_EMAIL and DELIVERABLE_SENDER_DOMAIN,"
84
+ warn " or run with --no-smtp to skip the probe."
85
+ exit 64
86
+ end
87
+
88
+ if options[:json]
89
+ puts JSON.pretty_generate(result.to_h)
90
+ else
91
+ color = case result.classification
92
+ when "valid" then "\e[32m"
93
+ when "risky" then "\e[33m"
94
+ else "\e[31m"
95
+ end
96
+ reset = "\e[0m"
97
+ dim = "\e[2m"
98
+
99
+ puts "#{color}#{result.classification.upcase}#{reset} #{result.email} #{dim}score #{result.score}#{reset}"
100
+ puts
101
+
102
+ green = "\e[32m"
103
+ red = "\e[31m"
104
+ yellow = "\e[33m"
105
+ pass = "#{green}✓#{reset}"
106
+ fail_ = "#{red}✗#{reset}"
107
+ warn = "#{yellow}!#{reset}"
108
+ skip = "#{dim}·#{reset}"
109
+
110
+ rows = []
111
+ rows << ["syntax", result.checks[:syntax] ? pass : fail_, nil]
112
+ rows << ["MX record", result.checks[:mx_record] ? pass : fail_, nil]
113
+ rows << ["SMTP deliverable", smtp_mark(result.checks[:smtp_deliverable], pass, fail_, skip), smtp_note(result.checks[:smtp_deliverable])]
114
+ rows << ["disposable", result.checks[:disposable] ? warn : pass, result.checks[:disposable] ? "yes" : "no"]
115
+ rows << ["catch-all", catchall_mark(result.checks[:accepts_any_email], pass, warn, skip), catchall_note(result.checks[:accepts_any_email])]
116
+ if result.checks[:typo_suggestion]
117
+ rows << ["typo suggestion", warn, result.checks[:typo_suggestion]]
118
+ end
119
+
120
+ width = rows.map { |label, _, _| label.length }.max
121
+ rows.each do |label, mark, note|
122
+ line = " #{mark} #{label.ljust(width)}"
123
+ line += " #{dim}#{note}#{reset}" if note
124
+ puts line
125
+ end
126
+
127
+ if result.errors.any?
128
+ puts
129
+ result.errors.each { |e| puts " \e[31m!\e[0m #{e}" }
130
+ end
131
+
132
+ if result.warnings.any?
133
+ puts
134
+ result.warnings.each { |w| puts " \e[33m!\e[0m #{w}" }
135
+ end
136
+ end
137
+
138
+ case result.classification
139
+ when "valid" then exit 0
140
+ when "risky" then exit 1
141
+ else exit 2
142
+ end
@@ -0,0 +1,36 @@
1
+ module Deliverable
2
+ class Result
3
+ attr_reader :email, :score, :score_details, :errors, :warnings, :checks
4
+
5
+ def initialize(email:, score:, score_details:, classification:, errors:, warnings:, checks:)
6
+ @email = email
7
+ @score = score
8
+ @score_details = score_details
9
+ @classification = classification
10
+ @errors = errors
11
+ @warnings = warnings
12
+ @checks = checks
13
+ end
14
+
15
+ def valid?
16
+ @classification != "invalid"
17
+ end
18
+
19
+ def classification
20
+ @classification
21
+ end
22
+
23
+ def to_h
24
+ {
25
+ email: @email,
26
+ valid: valid?,
27
+ classification: @classification,
28
+ score: @score,
29
+ score_details: @score_details,
30
+ errors: @errors,
31
+ warnings: @warnings,
32
+ checks: @checks
33
+ }
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,374 @@
1
+ require "resolv"
2
+ require "net/smtp"
3
+ require "timeout"
4
+ require "valid_email2"
5
+
6
+ module Deliverable
7
+ class Verifier
8
+ PROBLEMATIC_SERVER_PATTERNS = [
9
+ /migadu\.com$/,
10
+ /gmail\.com$/,
11
+ /google\.com$/,
12
+ /googlemail\.com$/,
13
+ /outlook\.com$/,
14
+ /office365\.com$/,
15
+ /protection\.outlook\.com$/,
16
+ /proofpoint\.com$/,
17
+ /mimecast\.com$/
18
+ ].freeze
19
+
20
+ SMTP_PORTS = [25, 587, 465].freeze
21
+
22
+ COMMON_DOMAINS = %w[
23
+ gmail.com yahoo.com hotmail.com outlook.com
24
+ aol.com icloud.com live.com msn.com
25
+ proton.me protonmail.com fastmail.com
26
+ ].freeze
27
+
28
+ def initialize(email, smtp: true, timeout: 10, sender_email: nil, sender_domain: nil, logger: nil)
29
+ @email = email&.strip&.downcase
30
+ @smtp = smtp
31
+ @timeout = timeout
32
+ @sender_email = sender_email || Deliverable.config.sender_email
33
+ @sender_domain = sender_domain || Deliverable.config.sender_domain
34
+ @logger = logger || Deliverable.config.logger
35
+ @errors = []
36
+ @warnings = []
37
+ @details = {
38
+ syntax_valid: false,
39
+ mx_verified: false,
40
+ smtp_verified: false,
41
+ smtp_failed_assumption: false,
42
+ dns_failed_assumption: false,
43
+ has_warnings: false,
44
+ accepts_any_email: false
45
+ }
46
+ @accepts_any_email = nil
47
+ end
48
+
49
+ def call
50
+ ok = run_checks
51
+ score = calculate_score
52
+ Result.new(
53
+ email: @email,
54
+ score: score,
55
+ score_details: @details.dup,
56
+ classification: classification(ok),
57
+ errors: @errors,
58
+ warnings: @warnings,
59
+ checks: {
60
+ syntax: @details[:syntax_valid],
61
+ mx_record: @details[:mx_verified],
62
+ smtp_deliverable: @smtp ? @smtp_result : nil,
63
+ disposable: disposable?,
64
+ typo_suggestion: typo_suggestion,
65
+ accepts_any_email: @accepts_any_email
66
+ }
67
+ )
68
+ end
69
+
70
+ private
71
+
72
+ def run_checks
73
+ return false unless validate_syntax
74
+
75
+ check_disposable
76
+ check_typo
77
+
78
+ return false unless validate_mx
79
+ return false if @smtp && !validate_smtp
80
+
81
+ true
82
+ end
83
+
84
+ def validate_syntax
85
+ if @email.nil? || @email.empty?
86
+ @errors << "Email address is required"
87
+ return false
88
+ end
89
+
90
+ unless ValidEmail2::Address.new(@email).valid?
91
+ @errors << "Email address format is invalid"
92
+ return false
93
+ end
94
+
95
+ @details[:syntax_valid] = true
96
+ true
97
+ end
98
+
99
+ def validate_mx
100
+ domain = extract_domain
101
+ return false unless domain
102
+
103
+ unless mx_or_a_record?(domain)
104
+ @errors << "Email domain does not accept mail (no MX record found)"
105
+ return false
106
+ end
107
+
108
+ true
109
+ end
110
+
111
+ def mx_or_a_record?(domain)
112
+ resolver = Resolv::DNS.new
113
+ mx = resolver.getresources(domain, Resolv::DNS::Resource::IN::MX)
114
+
115
+ if mx.empty?
116
+ a = resolver.getresources(domain, Resolv::DNS::Resource::IN::A)
117
+ if a.any?
118
+ @details[:mx_verified] = true
119
+ return true
120
+ end
121
+ return false
122
+ end
123
+
124
+ @details[:mx_verified] = true
125
+ true
126
+ rescue Resolv::ResolvError, Resolv::ResolvTimeout
127
+ false
128
+ rescue StandardError => e
129
+ log "DNS lookup failed for #{domain}: #{e.message}"
130
+ @details[:dns_failed_assumption] = true
131
+ true
132
+ end
133
+
134
+ def validate_smtp
135
+ require_sender!
136
+ result = smtp_deliverable_check
137
+ unless result
138
+ @errors << "Email address does not exist on the mail server"
139
+ end
140
+ result
141
+ end
142
+
143
+ def require_sender!
144
+ missing = []
145
+ missing << "sender_email" if @sender_email.nil? || @sender_email.empty?
146
+ missing << "sender_domain" if @sender_domain.nil? || @sender_domain.empty?
147
+ return if missing.empty?
148
+
149
+ raise SenderNotConfigured, <<~MSG.strip
150
+ SMTP probing requires #{missing.join(" and ")}. Configure with:
151
+
152
+ Deliverable.configure do |c|
153
+ c.sender_email = "verify@yourdomain.com"
154
+ c.sender_domain = "yourdomain.com"
155
+ end
156
+
157
+ Or pass sender_email:/sender_domain: to Deliverable.verify, or skip the
158
+ SMTP probe with smtp: false.
159
+ MSG
160
+ end
161
+
162
+ def smtp_deliverable_check
163
+ return @smtp_result unless @smtp_result.nil?
164
+
165
+ domain = extract_domain
166
+ @smtp_result = false
167
+ return @smtp_result unless domain
168
+
169
+ mail_server = pick_mail_server(domain)
170
+ return @smtp_result unless mail_server
171
+
172
+ outcome = probe_ports(mail_server)
173
+
174
+ case outcome
175
+ when :verified
176
+ @details[:smtp_verified] = true
177
+ check_catch_all(mail_server)
178
+ @smtp_result = true
179
+ when :assumed
180
+ @details[:smtp_failed_assumption] = true
181
+ @smtp_result = true
182
+ else
183
+ @smtp_result = false
184
+ end
185
+ rescue StandardError => e
186
+ log "SMTP check failed for #{@email}: #{e.message}"
187
+ @details[:smtp_failed_assumption] = true
188
+ @smtp_result = true
189
+ end
190
+
191
+ def pick_mail_server(domain)
192
+ resolver = Resolv::DNS.new
193
+ mx = resolver.getresources(domain, Resolv::DNS::Resource::IN::MX)
194
+ return mx.min_by(&:preference).exchange.to_s unless mx.empty?
195
+
196
+ a = resolver.getresources(domain, Resolv::DNS::Resource::IN::A)
197
+ a.empty? ? nil : domain
198
+ rescue StandardError => e
199
+ log "MX lookup failed for #{domain}: #{e.message}"
200
+ nil
201
+ end
202
+
203
+ def probe_ports(mail_server)
204
+ last = nil
205
+
206
+ SMTP_PORTS.each do |port|
207
+ result = attempt_rcpt(mail_server, port, @email)
208
+
209
+ return result if result == :verified || result == :invalid
210
+
211
+ last = result
212
+ next if result == :connection_failed
213
+
214
+ return result if result == :assumed && !problematic_server?(mail_server)
215
+ end
216
+
217
+ problematic_server?(mail_server) ? :assumed : (last || :assumed)
218
+ end
219
+
220
+ def attempt_rcpt(mail_server, port, recipient)
221
+ Timeout.timeout(@timeout) do
222
+ smtp = Net::SMTP.new(mail_server, port)
223
+ smtp.enable_starttls_auto if port == 587 || port == 465
224
+
225
+ smtp.start(@sender_domain) do |conn|
226
+ conn.mailfrom(@sender_email)
227
+ begin
228
+ conn.rcptto(recipient)
229
+ return :verified
230
+ rescue Net::SMTPFatalError => e
231
+ classify_fatal(e.message)
232
+ rescue Net::SMTPServerBusy, Net::SMTPSyntaxError, Net::SMTPAuthenticationError
233
+ return :assumed
234
+ end
235
+ end
236
+ end
237
+ rescue Timeout::Error, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH
238
+ :connection_failed
239
+ rescue Net::SMTPAuthenticationError, Net::SMTPServerBusy
240
+ :assumed
241
+ rescue StandardError => e
242
+ log "SMTP probe failed for #{recipient} on #{mail_server}:#{port}: #{e.message}"
243
+ :connection_failed
244
+ end
245
+
246
+ def classify_fatal(message)
247
+ msg = message.to_s.downcase
248
+ if msg.include?("550") || msg.include?("551") ||
249
+ msg.include?("user unknown") || msg.include?("recipient unknown") ||
250
+ msg.include?("mailbox unavailable")
251
+ :invalid
252
+ elsif msg.include?("421") || msg.include?("450") || msg.include?("451") || msg.include?("temporary")
253
+ :assumed
254
+ else
255
+ :assumed
256
+ end
257
+ end
258
+
259
+ def check_catch_all(mail_server)
260
+ domain = extract_domain
261
+ return unless domain
262
+
263
+ fake = "definitely-does-not-exist-#{rand(10**10)}@#{domain}"
264
+ result = SMTP_PORTS.each do |port|
265
+ outcome = attempt_rcpt(mail_server, port, fake)
266
+ break :accepted if outcome == :verified
267
+ break :rejected if outcome == :invalid
268
+ end
269
+
270
+ if result == :accepted
271
+ @accepts_any_email = true
272
+ @details[:accepts_any_email] = true
273
+ else
274
+ @accepts_any_email = false
275
+ end
276
+ end
277
+
278
+ def problematic_server?(mail_server)
279
+ PROBLEMATIC_SERVER_PATTERNS.any? { |p| mail_server.match?(p) }
280
+ end
281
+
282
+ def check_disposable
283
+ if disposable?
284
+ @warnings << "Email appears to be from a disposable email service"
285
+ end
286
+ end
287
+
288
+ def disposable?
289
+ ValidEmail2::Address.new(@email).disposable?
290
+ rescue StandardError
291
+ false
292
+ end
293
+
294
+ def check_typo
295
+ suggestion = typo_suggestion
296
+ @warnings << "Did you mean: #{suggestion}?" if suggestion
297
+ end
298
+
299
+ def typo_suggestion
300
+ return nil unless @email&.include?("@")
301
+
302
+ domain = extract_domain
303
+ closest = COMMON_DOMAINS.map { |d| [d, levenshtein(domain, d)] }.min_by { |_, dist| dist }
304
+ return nil unless closest
305
+
306
+ d, dist = closest
307
+ return nil if dist == 0
308
+ return nil if dist > 2 || dist >= domain.length / 2.0
309
+
310
+ @email.sub(/#{Regexp.escape(domain)}\z/, d)
311
+ end
312
+
313
+ def levenshtein(a, b)
314
+ return b.length if a.empty?
315
+ return a.length if b.empty?
316
+
317
+ m = Array.new(a.length + 1) { Array.new(b.length + 1, 0) }
318
+ (0..a.length).each { |i| m[i][0] = i }
319
+ (0..b.length).each { |j| m[0][j] = j }
320
+
321
+ (1..a.length).each do |i|
322
+ (1..b.length).each do |j|
323
+ cost = a[i - 1] == b[j - 1] ? 0 : 1
324
+ m[i][j] = [m[i - 1][j] + 1, m[i][j - 1] + 1, m[i - 1][j - 1] + cost].min
325
+ end
326
+ end
327
+
328
+ m[a.length][b.length]
329
+ end
330
+
331
+ def extract_domain
332
+ return nil unless @email&.include?("@")
333
+ @email.split("@").last
334
+ end
335
+
336
+ def calculate_score
337
+ @details[:has_warnings] = @warnings.any?
338
+ score = 0
339
+ score += 20 if @details[:syntax_valid]
340
+
341
+ if @details[:mx_verified]
342
+ score += 40
343
+ elsif @details[:dns_failed_assumption]
344
+ score += 15
345
+ end
346
+
347
+ if @details[:smtp_verified]
348
+ score += @details[:accepts_any_email] ? 15 : 35
349
+ elsif @details[:smtp_failed_assumption]
350
+ score += 10
351
+ elsif @smtp
352
+ score += 5
353
+ else
354
+ score += 20
355
+ end
356
+
357
+ score -= [@warnings.length * 8, 25].min if @warnings.any?
358
+ score.clamp(0, 100)
359
+ end
360
+
361
+ def classification(ok)
362
+ return "invalid" unless ok
363
+ return "risky" if @details[:accepts_any_email]
364
+ return "risky" if @details[:dns_failed_assumption] || @details[:smtp_failed_assumption]
365
+ return "risky" if disposable?
366
+ return "risky" if @warnings.any?
367
+ "valid"
368
+ end
369
+
370
+ def log(message)
371
+ @logger&.warn(message)
372
+ end
373
+ end
374
+ end
@@ -0,0 +1,3 @@
1
+ module Deliverable
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,37 @@
1
+ require_relative "deliverable/version"
2
+ require_relative "deliverable/result"
3
+ require_relative "deliverable/verifier"
4
+
5
+ module Deliverable
6
+ class Error < StandardError; end
7
+ class SenderNotConfigured < Error; end
8
+
9
+ class Configuration
10
+ attr_accessor :sender_email, :sender_domain, :logger, :default_timeout
11
+
12
+ def initialize
13
+ @sender_email = nil
14
+ @sender_domain = nil
15
+ @logger = nil
16
+ @default_timeout = 10
17
+ end
18
+ end
19
+
20
+ def self.config
21
+ @config ||= Configuration.new
22
+ end
23
+
24
+ def self.configure
25
+ yield config
26
+ end
27
+
28
+ def self.verify(email, smtp: true, timeout: nil, sender_email: nil, sender_domain: nil)
29
+ Verifier.new(
30
+ email,
31
+ smtp: smtp,
32
+ timeout: timeout || config.default_timeout,
33
+ sender_email: sender_email,
34
+ sender_domain: sender_domain
35
+ ).call
36
+ end
37
+ end
metadata ADDED
@@ -0,0 +1,103 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: deliverable
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Erik Strömberg
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-01 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: valid_email2
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '5.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '7.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '5.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '7.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: minitest
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - "~>"
37
+ - !ruby/object:Gem::Version
38
+ version: '5.0'
39
+ type: :development
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - "~>"
44
+ - !ruby/object:Gem::Version
45
+ version: '5.0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: rake
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '13.0'
53
+ type: :development
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '13.0'
60
+ description: Verifies whether an email address is actually deliverable. Multi-port
61
+ SMTP fallback, catch-all detection, and awareness of mail servers (Gmail, Outlook,
62
+ Mimecast) that block SMTP probing — so it tells you when it doesn't know instead
63
+ of guessing.
64
+ email:
65
+ - erik@peopledb.co
66
+ executables:
67
+ - deliverable
68
+ extensions: []
69
+ extra_rdoc_files: []
70
+ files:
71
+ - CHANGELOG.md
72
+ - LICENSE.txt
73
+ - README.md
74
+ - exe/deliverable
75
+ - lib/deliverable.rb
76
+ - lib/deliverable/result.rb
77
+ - lib/deliverable/verifier.rb
78
+ - lib/deliverable/version.rb
79
+ homepage: https://github.com/peopledb/deliverable
80
+ licenses:
81
+ - MIT
82
+ metadata:
83
+ homepage_uri: https://github.com/peopledb/deliverable
84
+ source_code_uri: https://github.com/peopledb/deliverable
85
+ changelog_uri: https://github.com/peopledb/deliverable/blob/main/CHANGELOG.md
86
+ rdoc_options: []
87
+ require_paths:
88
+ - lib
89
+ required_ruby_version: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '3.0'
94
+ required_rubygems_version: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: '0'
99
+ requirements: []
100
+ rubygems_version: 3.7.2
101
+ specification_version: 4
102
+ summary: 'Honest email verification: syntax, MX, SMTP RCPT, and catch-all detection.'
103
+ test_files: []