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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +121 -0
- data/exe/deliverable +142 -0
- data/lib/deliverable/result.rb +36 -0
- data/lib/deliverable/verifier.rb +374 -0
- data/lib/deliverable/version.rb +3 -0
- data/lib/deliverable.rb +37 -0
- metadata +103 -0
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/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
|
data/lib/deliverable.rb
ADDED
|
@@ -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: []
|