valid_email_checker 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.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/Cargo.lock +2940 -0
  4. data/Cargo.toml +22 -0
  5. data/LICENSE.txt +661 -0
  6. data/NOTICE.md +56 -0
  7. data/README.md +316 -0
  8. data/exe/valid_email_checker +148 -0
  9. data/ext/valid_email_checker/Cargo.toml +27 -0
  10. data/ext/valid_email_checker/extconf.rb +26 -0
  11. data/ext/valid_email_checker/src/lib.rs +371 -0
  12. data/ext/valid_email_checker/src/nogvl.rs +64 -0
  13. data/ext/valid_email_checker/vendor/UPSTREAM.json +9 -0
  14. data/ext/valid_email_checker/vendor/check-if-email-exists/Cargo.toml +52 -0
  15. data/ext/valid_email_checker/vendor/check-if-email-exists/LICENSE.AGPL +661 -0
  16. data/ext/valid_email_checker/vendor/check-if-email-exists/LICENSE.md +11 -0
  17. data/ext/valid_email_checker/vendor/check-if-email-exists/README.md +175 -0
  18. data/ext/valid_email_checker/vendor/check-if-email-exists/src/haveibeenpwned.rs +70 -0
  19. data/ext/valid_email_checker/vendor/check-if-email-exists/src/lib.rs +281 -0
  20. data/ext/valid_email_checker/vendor/check-if-email-exists/src/misc/b2c.txt +96640 -0
  21. data/ext/valid_email_checker/vendor/check-if-email-exists/src/misc/gravatar.rs +60 -0
  22. data/ext/valid_email_checker/vendor/check-if-email-exists/src/misc/mod.rs +124 -0
  23. data/ext/valid_email_checker/vendor/check-if-email-exists/src/misc/roles.txt +944 -0
  24. data/ext/valid_email_checker/vendor/check-if-email-exists/src/mx/mod.rs +165 -0
  25. data/ext/valid_email_checker/vendor/check-if-email-exists/src/rules.json +28 -0
  26. data/ext/valid_email_checker/vendor/check-if-email-exists/src/rules.rs +105 -0
  27. data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/connect.rs +396 -0
  28. data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/error.rs +144 -0
  29. data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/gmail.rs +99 -0
  30. data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/headless.rs +82 -0
  31. data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/http_api.rs +27 -0
  32. data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/mod.rs +234 -0
  33. data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/outlook/headless.rs +181 -0
  34. data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/outlook/microsoft365.rs +109 -0
  35. data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/outlook/mod.rs +2 -0
  36. data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/parser.rs +291 -0
  37. data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/verif_method.rs +531 -0
  38. data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/yahoo/api.rs +174 -0
  39. data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/yahoo/headless.rs +188 -0
  40. data/ext/valid_email_checker/vendor/check-if-email-exists/src/smtp/yahoo/mod.rs +62 -0
  41. data/ext/valid_email_checker/vendor/check-if-email-exists/src/syntax/mod.rs +199 -0
  42. data/ext/valid_email_checker/vendor/check-if-email-exists/src/syntax/normalize.rs +70 -0
  43. data/ext/valid_email_checker/vendor/check-if-email-exists/src/util/input_output.rs +353 -0
  44. data/ext/valid_email_checker/vendor/check-if-email-exists/src/util/mod.rs +20 -0
  45. data/ext/valid_email_checker/vendor/check-if-email-exists/src/util/sentry.rs +173 -0
  46. data/ext/valid_email_checker/vendor/check-if-email-exists/src/util/ser_with_display.rs +28 -0
  47. data/lib/valid_email_checker/configuration.rb +216 -0
  48. data/lib/valid_email_checker/errors.rb +32 -0
  49. data/lib/valid_email_checker/result.rb +241 -0
  50. data/lib/valid_email_checker/syntax.rb +72 -0
  51. data/lib/valid_email_checker/version.rb +5 -0
  52. data/lib/valid_email_checker.rb +217 -0
  53. metadata +118 -0
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ValidEmailChecker
4
+ # Verification settings.
5
+ #
6
+ # Set process-wide defaults with {ValidEmailChecker.configure}, and override
7
+ # any of them per call by passing the same keys to {ValidEmailChecker.check}.
8
+ #
9
+ # Timeouts are expressed in seconds here, the way Ruby's own APIs do; they
10
+ # are converted to milliseconds on the way into Rust.
11
+ class Configuration
12
+ # Strategies the native extension understands, per provider.
13
+ YAHOO_METHODS = %i[api headless smtp].freeze
14
+ HOTMAIL_B2C_METHODS = %i[smtp headless].freeze
15
+
16
+ # Address used in the `MAIL FROM:` command. Upstream's default is an
17
+ # unused address owned by the Reacher project; set this to one you control,
18
+ # since some mail exchangers check it.
19
+ DEFAULT_FROM_EMAIL = "reacher@gmail.com"
20
+
21
+ # Name used in the `EHLO` command. Should be a domain you own, with
22
+ # matching forward and reverse DNS, or many servers will refuse to talk.
23
+ DEFAULT_HELLO_NAME = "gmail.com"
24
+
25
+ # Wall-clock ceiling for verifying one address. Upstream has no overall
26
+ # timeout; we default to one so a single unresponsive mail server cannot
27
+ # block a caller forever. Set to +nil+ to remove the ceiling.
28
+ DEFAULT_TIMEOUT = 30
29
+
30
+ # How many addresses {ValidEmailChecker.check_many} verifies at once.
31
+ DEFAULT_CONCURRENCY = 10
32
+
33
+ # Address used in the `MAIL FROM:` SMTP command.
34
+ attr_accessor :from_email
35
+ # Name used in the `EHLO` SMTP command.
36
+ attr_accessor :hello_name
37
+ # Port to connect to. Usually 25; try 587 or 2525 if 25 is blocked.
38
+ attr_accessor :smtp_port
39
+ # Timeout for one SMTP connection, in seconds. +nil+ for none.
40
+ attr_accessor :smtp_timeout
41
+ # Total number of SMTP connections to attempt. 2 can get past greylisting.
42
+ attr_accessor :retries
43
+ # Whether to look up a Gravatar for the address. Adds a HTTP round trip.
44
+ attr_accessor :check_gravatar
45
+ # API key enabling the HaveIBeenPwned breach lookup.
46
+ attr_accessor :haveibeenpwned_api_key
47
+ # SOCKS5 proxy, as a Hash of +:host+, +:port+ and optionally +:username+,
48
+ # +:password+, +:timeout+ (seconds).
49
+ attr_accessor :proxy
50
+ # WebDriver endpoint, used only by the +:headless+ strategies.
51
+ attr_accessor :webdriver_addr
52
+ # Identifier recorded in the result's debug section.
53
+ attr_accessor :backend_name
54
+ # Wall-clock ceiling per address, in seconds. +nil+ for none.
55
+ attr_accessor :timeout
56
+ # Strategy for Yahoo addresses: +:api+, +:headless+ or +:smtp+.
57
+ attr_accessor :yahoo
58
+ # Strategy for consumer Hotmail/Outlook addresses: +:smtp+ or +:headless+.
59
+ attr_accessor :hotmail_b2c
60
+ # Default concurrency for {ValidEmailChecker.check_many}.
61
+ attr_accessor :concurrency
62
+
63
+ # Keys accepted by {#initialize} and by per-call overrides.
64
+ KEYS = %i[
65
+ from_email hello_name smtp_port smtp_timeout retries check_gravatar
66
+ haveibeenpwned_api_key proxy webdriver_addr backend_name timeout
67
+ yahoo hotmail_b2c concurrency
68
+ ].freeze
69
+
70
+ def initialize(**overrides)
71
+ @from_email = DEFAULT_FROM_EMAIL
72
+ @hello_name = DEFAULT_HELLO_NAME
73
+ @smtp_port = 25
74
+ @smtp_timeout = nil
75
+ @retries = 1
76
+ @check_gravatar = false
77
+ @haveibeenpwned_api_key = nil
78
+ @proxy = nil
79
+ @webdriver_addr = nil
80
+ @backend_name = "valid_email_checker"
81
+ @timeout = DEFAULT_TIMEOUT
82
+ # Defaults that differ from upstream so the gem works out of the box with
83
+ # no WebDriver process running. Pass :headless to match upstream.
84
+ @yahoo = :api
85
+ @hotmail_b2c = :smtp
86
+ @concurrency = DEFAULT_CONCURRENCY
87
+
88
+ apply(overrides)
89
+ end
90
+
91
+ # A copy of this configuration with +overrides+ applied. Used for per-call
92
+ # options, so that a call never mutates the global configuration.
93
+ def merge(overrides)
94
+ return self if overrides.nil? || overrides.empty?
95
+
96
+ dup.tap { |config| config.send(:apply, overrides) }
97
+ end
98
+
99
+ def to_h
100
+ KEYS.to_h { |key| [key, public_send(key)] }
101
+ end
102
+
103
+ # The payload handed to the native extension.
104
+ #
105
+ # Deliberately excludes +concurrency+, which is a Ruby-side concern: the
106
+ # Rust options struct rejects unknown fields, which is what catches typos
107
+ # in caller-supplied option names.
108
+ def to_native_payload
109
+ validate!
110
+
111
+ {
112
+ from_email: from_email,
113
+ hello_name: hello_name,
114
+ smtp_port: smtp_port,
115
+ smtp_timeout_ms: seconds_to_ms(smtp_timeout),
116
+ retries: retries,
117
+ check_gravatar: check_gravatar,
118
+ haveibeenpwned_api_key: haveibeenpwned_api_key,
119
+ proxy: proxy_payload,
120
+ webdriver_addr: webdriver_addr,
121
+ backend_name: backend_name,
122
+ timeout_ms: seconds_to_ms(timeout),
123
+ yahoo: yahoo&.to_sym,
124
+ hotmail_b2c: hotmail_b2c&.to_sym
125
+ }
126
+ end
127
+
128
+ def validate!
129
+ unless yahoo.nil? || YAHOO_METHODS.include?(yahoo.to_sym)
130
+ raise ConfigurationError,
131
+ "yahoo must be one of #{YAHOO_METHODS.inspect}, got #{yahoo.inspect}"
132
+ end
133
+
134
+ unless hotmail_b2c.nil? || HOTMAIL_B2C_METHODS.include?(hotmail_b2c.to_sym)
135
+ raise ConfigurationError,
136
+ "hotmail_b2c must be one of #{HOTMAIL_B2C_METHODS.inspect}, got #{hotmail_b2c.inspect}"
137
+ end
138
+
139
+ if (yahoo&.to_sym == :headless || hotmail_b2c&.to_sym == :headless) && webdriver_addr.nil?
140
+ raise ConfigurationError,
141
+ "the :headless strategy needs a running WebDriver; set webdriver_addr " \
142
+ "(commonly \"http://localhost:9515\" for chromedriver)"
143
+ end
144
+
145
+ validate_proxy!
146
+
147
+ [[:timeout, timeout], [:smtp_timeout, smtp_timeout]].each do |name, value|
148
+ next if value.nil?
149
+ raise ConfigurationError, "#{name} must be positive, got #{value.inspect}" unless value.positive?
150
+ end
151
+
152
+ unless (1..65_535).cover?(smtp_port.to_i)
153
+ raise ConfigurationError, "smtp_port must be between 1 and 65535, got #{smtp_port.inspect}"
154
+ end
155
+
156
+ raise ConfigurationError, "retries must be at least 1, got #{retries.inspect}" if retries.to_i < 1
157
+
158
+ self
159
+ end
160
+
161
+ private
162
+
163
+ def apply(overrides)
164
+ overrides.each do |key, value|
165
+ unless KEYS.include?(key.to_sym)
166
+ raise ConfigurationError,
167
+ "unknown option #{key.inspect}; valid options are #{KEYS.inspect}"
168
+ end
169
+
170
+ public_send(:"#{key}=", value)
171
+ end
172
+
173
+ self
174
+ end
175
+
176
+ def validate_proxy!
177
+ return if proxy.nil?
178
+
179
+ unless proxy.respond_to?(:to_h)
180
+ raise ConfigurationError, "proxy must be a Hash of :host and :port, got #{proxy.inspect}"
181
+ end
182
+
183
+ hash = proxy.to_h
184
+ allowed = %i[host port username password timeout]
185
+ unknown = hash.keys.map(&:to_sym) - allowed
186
+ unless unknown.empty?
187
+ raise ConfigurationError, "unknown proxy keys #{unknown.inspect}; valid keys are #{allowed.inspect}"
188
+ end
189
+
190
+ host = hash[:host] || hash["host"]
191
+ port = hash[:port] || hash["port"]
192
+ raise ConfigurationError, "proxy requires a :host" if host.nil? || host.to_s.empty?
193
+ raise ConfigurationError, "proxy requires a :port" if port.nil?
194
+ end
195
+
196
+ def proxy_payload
197
+ return nil if proxy.nil?
198
+
199
+ hash = proxy.to_h.transform_keys(&:to_sym)
200
+
201
+ {
202
+ host: hash[:host].to_s,
203
+ port: hash[:port].to_i,
204
+ username: hash[:username],
205
+ password: hash[:password],
206
+ timeout_ms: seconds_to_ms(hash[:timeout])
207
+ }
208
+ end
209
+
210
+ def seconds_to_ms(seconds)
211
+ return nil if seconds.nil?
212
+
213
+ (Float(seconds) * 1000).round
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ValidEmailChecker
4
+ # Base class for errors specific to this gem.
5
+ #
6
+ # Note that bad arguments raise Ruby's own ::ArgumentError rather than
7
+ # something under this namespace, so that a plain `rescue ArgumentError`
8
+ # behaves the way it does everywhere else.
9
+ class Error < StandardError; end
10
+
11
+ # Raised when options are not usable: an unknown provider strategy, a proxy
12
+ # missing a host, a non-positive timeout, and so on.
13
+ #
14
+ # Raised before any network access happens, so nothing has been sent when
15
+ # you see this.
16
+ class ConfigurationError < Error; end
17
+
18
+ # Raised on macOS when a process tries to verify an address after forking
19
+ # from a parent that had already verified one.
20
+ #
21
+ # Apple's Network.framework is not fork-safe. The first verification in a
22
+ # process initializes XPC and dispatch state for the system resolver, and a
23
+ # child inherits that state in a form it cannot use: its next connection
24
+ # attempt deadlocks inside the framework and never returns, not even when the
25
+ # configured timeout expires.
26
+ #
27
+ # We cannot repair the inherited state, so this is raised in place of a hang
28
+ # that would otherwise be indistinguishable from a very slow mail server.
29
+ # See the "Forking servers" section of the README for how to avoid it --
30
+ # in short, do not verify in a parent process before forking.
31
+ class ForkAfterVerificationError < Error; end
32
+ end
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ValidEmailChecker
4
+ # The outcome of verifying one address.
5
+ #
6
+ # This wraps the JSON document the embedded Rust crate produces. The
7
+ # predicate methods cover the questions callers usually have; anything not
8
+ # given a method is still reachable through {#to_h}, which holds the upstream
9
+ # structure verbatim under +:syntax+, +:mx+, +:smtp+, +:misc+ and +:debug+.
10
+ #
11
+ # result = ValidEmailChecker.check("someone@gmail.com")
12
+ # result.reachable # => :safe
13
+ # result.safe? # => true
14
+ # result.disposable? # => false
15
+ # result.to_h[:smtp] # => {can_connect_smtp: true, ...}
16
+ class Result
17
+ # The four verdicts the crate can reach, in decreasing order of confidence
18
+ # that mail will arrive.
19
+ REACHABILITY = %i[safe risky invalid unknown].freeze
20
+
21
+ # @return [Hash] the raw upstream result, with symbol keys
22
+ attr_reader :to_h
23
+
24
+ def initialize(data)
25
+ @to_h = data.freeze
26
+ end
27
+
28
+ # @return [ValidEmailChecker::Result]
29
+ def self.from_json(json)
30
+ new(JSON.parse(json, symbolize_names: true))
31
+ end
32
+
33
+ # @return [String] the address that was checked, as it was given
34
+ def email
35
+ to_h[:input].to_s
36
+ end
37
+ alias input email
38
+
39
+ # How confident the crate is that mail sent here would arrive.
40
+ #
41
+ # @return [Symbol] +:safe+, +:risky+, +:invalid+ or +:unknown+
42
+ def reachable
43
+ value = to_h[:is_reachable]
44
+ value.nil? ? :unknown : value.to_sym
45
+ end
46
+
47
+ # Mail should arrive, and the address has no quality problems.
48
+ def safe?
49
+ reachable == :safe
50
+ end
51
+
52
+ # The address exists but is flagged: catch-all, disposable, role-based, or
53
+ # a full inbox. Mail may arrive, or may bounce.
54
+ def risky?
55
+ reachable == :risky
56
+ end
57
+
58
+ # The address does not exist or is malformed. Do not send here.
59
+ def invalid?
60
+ reachable == :invalid
61
+ end
62
+
63
+ # The mail server gave no usable answer. Common when port 25 is blocked
64
+ # outbound, the server greylists, or the IP is blacklisted -- check
65
+ # {#error} before concluding anything about the address.
66
+ def unknown?
67
+ reachable == :unknown
68
+ end
69
+
70
+ # Whether the address is safe to send to.
71
+ #
72
+ # This is deliberately strict: only +:safe+ counts. Treating +:risky+ or
73
+ # +:unknown+ as usable is a policy decision, so make it explicitly with
74
+ # {#reachable} rather than relying on a looser default here.
75
+ def valid?
76
+ safe?
77
+ end
78
+
79
+ # @return [ValidEmailChecker::Syntax]
80
+ def syntax
81
+ @syntax ||= Syntax.new(to_h[:syntax])
82
+ end
83
+
84
+ # --- SMTP findings -------------------------------------------------------
85
+
86
+ # @return [Boolean] the mail exchanger accepted the recipient
87
+ def deliverable?
88
+ smtp[:is_deliverable] == true
89
+ end
90
+
91
+ # @return [Boolean] we completed an SMTP conversation with the server
92
+ def can_connect_smtp?
93
+ smtp[:can_connect_smtp] == true
94
+ end
95
+
96
+ # @return [Boolean] the domain accepts mail for every address, so an
97
+ # individual address cannot be confirmed to exist
98
+ def catch_all?
99
+ smtp[:is_catch_all] == true
100
+ end
101
+
102
+ # @return [Boolean] the mailbox is over quota
103
+ def full_inbox?
104
+ smtp[:has_full_inbox] == true
105
+ end
106
+
107
+ # @return [Boolean] the provider has blocked or deactivated the mailbox
108
+ def disabled?
109
+ smtp[:is_disabled] == true
110
+ end
111
+
112
+ # --- Domain and address metadata ----------------------------------------
113
+
114
+ # @return [Boolean] the domain has MX records
115
+ def accepts_mail?
116
+ mx[:accepts_mail] == true
117
+ end
118
+
119
+ # @return [Array<String>] the mail exchangers found for the domain
120
+ def mx_records
121
+ Array(mx[:records])
122
+ end
123
+
124
+ # @return [Boolean] the address belongs to a known throwaway-mail provider
125
+ def disposable?
126
+ misc[:is_disposable] == true
127
+ end
128
+
129
+ # @return [Boolean] the address is a shared mailbox such as info@ or
130
+ # support@, rather than a person
131
+ def role_account?
132
+ misc[:is_role_account] == true
133
+ end
134
+
135
+ # @return [Boolean] the address is a consumer rather than a business one
136
+ def b2c?
137
+ misc[:is_b2c] == true
138
+ end
139
+
140
+ # @return [String, nil] the Gravatar image URL, when +check_gravatar+ was
141
+ # enabled and one exists
142
+ def gravatar_url
143
+ misc[:gravatar_url]
144
+ end
145
+
146
+ # Whether the address appears in a HaveIBeenPwned breach.
147
+ #
148
+ # @return [Boolean, nil] nil when no +haveibeenpwned_api_key+ was set, so
149
+ # the lookup never ran
150
+ def pwned?
151
+ misc[:haveibeenpwned]
152
+ end
153
+
154
+ # --- Errors and timing ---------------------------------------------------
155
+
156
+ # The reason a stage could not complete, if any.
157
+ #
158
+ # Present mainly on +:unknown+ results. The +:type+ is the upstream error
159
+ # variant, or +"Timeout"+ when the call hit its wall-clock budget.
160
+ #
161
+ # @return [Hash, nil] with +:type+ and +:message+ keys
162
+ def error
163
+ to_h[:error] || smtp_error || mx_error || misc_error
164
+ end
165
+
166
+ # @return [Boolean] whether anything went wrong during verification
167
+ def error?
168
+ !error.nil?
169
+ end
170
+
171
+ # @return [Boolean] whether the wall-clock budget ran out. The address was
172
+ # neither confirmed nor rejected; retrying later may succeed.
173
+ def timed_out?
174
+ error&.dig(:type).to_s == "Timeout"
175
+ end
176
+
177
+ # A hint about a recoverable cause of an +:unknown+ result, such as the
178
+ # sending IP being blacklisted or lacking a reverse DNS record.
179
+ #
180
+ # @return [String, nil]
181
+ def error_description
182
+ to_h.dig(:smtp, :description)
183
+ end
184
+
185
+ # @return [Float, nil] how long verification took, in seconds
186
+ def duration
187
+ value = to_h.dig(:debug, :duration)
188
+ return nil if value.nil?
189
+
190
+ value[:secs].to_i + (value[:nanos].to_i / 1_000_000_000.0)
191
+ end
192
+
193
+ # --- Plumbing ------------------------------------------------------------
194
+
195
+ def [](key)
196
+ to_h[key.to_sym]
197
+ end
198
+
199
+ def to_json(*args)
200
+ to_h.to_json(*args)
201
+ end
202
+
203
+ def inspect
204
+ "#<#{self.class} #{email.inspect} #{reachable}#{error? ? " error=#{error[:type].inspect}" : ""}>"
205
+ end
206
+
207
+ private
208
+
209
+ # The upstream result replaces a section with +{error: ...}+ when that
210
+ # stage failed, so every reader has to tolerate the section being an error
211
+ # rather than the details it normally holds.
212
+ def section(name)
213
+ value = to_h[name]
214
+ value.is_a?(Hash) && !value.key?(:error) ? value : {}
215
+ end
216
+
217
+ def smtp
218
+ section(:smtp)
219
+ end
220
+
221
+ def mx
222
+ section(:mx)
223
+ end
224
+
225
+ def misc
226
+ section(:misc)
227
+ end
228
+
229
+ def smtp_error
230
+ to_h.dig(:smtp, :error)
231
+ end
232
+
233
+ def mx_error
234
+ to_h.dig(:mx, :error)
235
+ end
236
+
237
+ def misc_error
238
+ to_h.dig(:misc, :error)
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ValidEmailChecker
4
+ # The outcome of parsing an address, with no network access involved.
5
+ #
6
+ # Returned on its own by {ValidEmailChecker.syntax}, and reachable as
7
+ # {Result#syntax} after a full verification.
8
+ class Syntax
9
+ # @return [Hash] the raw data as the Rust crate reported it
10
+ attr_reader :to_h
11
+
12
+ def initialize(data)
13
+ @to_h = (data || {}).freeze
14
+ end
15
+
16
+ # @return [Boolean] whether the address parses as a well-formed address
17
+ def valid?
18
+ to_h[:is_valid_syntax] == true
19
+ end
20
+
21
+ # @return [String, nil] the address, or nil if it did not parse
22
+ def address
23
+ to_h[:address]
24
+ end
25
+
26
+ # @return [String] the part before the "@", or "" for an unparseable address
27
+ def username
28
+ to_h[:username].to_s
29
+ end
30
+
31
+ # @return [String] the part after the "@", or "" for an unparseable address
32
+ def domain
33
+ to_h[:domain].to_s
34
+ end
35
+
36
+ # @return [String, nil] the address with provider-specific quirks removed,
37
+ # such as Gmail's dots and "+" tags
38
+ def normalized_email
39
+ to_h[:normalized_email]
40
+ end
41
+
42
+ # A likely correction when the domain looks like a typo of a well-known
43
+ # provider, e.g. "gmail.co" -> "gmail.com".
44
+ #
45
+ # Only offered for an address that parses: notorious typo domains such as
46
+ # "gmial.com" are on upstream's blocklist, so they come back invalid with
47
+ # no domain to compare against and hence no suggestion.
48
+ #
49
+ # @return [String, nil]
50
+ def suggestion
51
+ to_h[:suggestion]
52
+ end
53
+
54
+ # @return [Boolean] whether a likely typo correction is available
55
+ def suggestion?
56
+ !suggestion.nil?
57
+ end
58
+
59
+ def [](key)
60
+ to_h[key.to_sym]
61
+ end
62
+
63
+ def to_json(*args)
64
+ to_h.to_json(*args)
65
+ end
66
+
67
+ def inspect
68
+ "#<#{self.class} #{address.inspect} valid=#{valid?}" \
69
+ "#{suggestion? ? " suggestion=#{suggestion.inspect}" : ""}>"
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ValidEmailChecker
4
+ VERSION = "0.1.0"
5
+ end