clean-email 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: 4325a9205cc1e85f87c8791415b7845a0132c7b4a59fc87b5d1b7f62c816a34d
4
+ data.tar.gz: '048b1b3b60ff7b27f4500791f33961718011493a9282cc29f6a41da521dd0ad9'
5
+ SHA512:
6
+ metadata.gz: 547dba76b5a2a233a0ed2df32edd349a73ad1fde51335cf69c32db2f71d0432030d0dde6cb7995bc05b59ec53c3469e9f8b941b911e326ba5f06e9e7a916c63a
7
+ data.tar.gz: 822404c6745e72b017b044d2cb5605c9bb82164c0ddef7d389a79ef24e8d56bc0dce6076b943500ddfffa010e3bb4018049c8bf1853bfa71bc2ae5af9e0e488e
data/README.md ADDED
@@ -0,0 +1,345 @@
1
+ # clean-email
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/clean-email.png)](http://badge.fury.io/rb/clean-email)
4
+ ![Gem Downloads (for latest version)](https://img.shields.io/gem/dtv/clean-email)
5
+
6
+ Ruby gem for email validation with support for multiple providers ([Reoon](https://reoon.com/email-verifier-api/), [ZeroBounce](https://www.zerobounce.net/), [AbstractAPI](https://www.abstractapi.com/email-validation-api)), with optional smart caching to minimize API usage.
7
+
8
+ Unlike SMTP-based validators, clean-email uses HTTP APIs backed by continuously updated datasets — making it faster, more reliable across all environments, and capable of detecting **disposable** and **spamtrap** addresses that SMTP handshakes simply cannot identify.
9
+
10
+ ## How it works
11
+
12
+ Every validation follows this priority order:
13
+
14
+ 1. **Invalid format** — malformed emails are rejected immediately, no cache, no API call
15
+ 2. **Domain cache** — if the domain is already marked as `disposable` or `spamtrap`, it is rejected instantly without hitting the API
16
+ 3. **Email cache** — if the full email was validated before, the cached result is returned
17
+ 4. **Provider API** — only called when no cache is available; the result is stored for future use
18
+
19
+ The domain-level cache is the key to API savings: once `mailinator.com` is identified as disposable, any future email `@mailinator.com` is rejected without a single API call.
20
+
21
+ **No database?** The adapter is optional. Without one, every call goes straight to the provider API — no caching at all.
22
+
23
+ ## Installation
24
+
25
+ Add to your `Gemfile`:
26
+
27
+ ```ruby
28
+ gem "clean-email"
29
+ ```
30
+
31
+ Then run:
32
+
33
+ ```bash
34
+ bundle install
35
+ ```
36
+
37
+ ## Setup
38
+
39
+ ### Rails
40
+
41
+ Run the install generator to create the initializer and the migration:
42
+
43
+ ```bash
44
+ rails generate clean_email:install
45
+ rails db:migrate
46
+ ```
47
+
48
+ This creates:
49
+ - `config/initializers/clean_email.rb` — configuration file
50
+ - `db/migrate/TIMESTAMP_create_clean_email_caches.rb` — tables for email and domain caching
51
+
52
+ Then configure your chosen provider in the initializer (see [Configuration](#configuration-options) below).
53
+
54
+ ### Non-Rails projects
55
+
56
+ Require and configure the gem manually:
57
+
58
+ ```ruby
59
+ require "clean_email"
60
+
61
+ CleanEmail.configure do |config|
62
+ config.provider = :reoon
63
+ config.reoon_api_key = ENV["REOON_API_KEY"]
64
+ config.adapter = MyAdapter.new # optional — see Adapter section
65
+ end
66
+ ```
67
+
68
+ ### Without a database
69
+
70
+ Simply omit the adapter. Every call will hit the provider API directly:
71
+
72
+ ```ruby
73
+ CleanEmail.configure do |config|
74
+ config.provider = :reoon
75
+ config.reoon_api_key = ENV["REOON_API_KEY"]
76
+ end
77
+ ```
78
+
79
+ ## Configuration options
80
+
81
+ | Option | Description | Default |
82
+ |---|---|---|
83
+ | `provider` | Which API to use: `:reoon`, `:zerobounce`, or `:abstractapi` | `:reoon` |
84
+ | `reoon_api_key` | Reoon API key. Required when `provider: :reoon`. | — |
85
+ | `reoon_mode` | `:quick` (faster) or `:power` (more accurate) | `:quick` |
86
+ | `zerobounce_api_key` | ZeroBounce API key. Required when `provider: :zerobounce`. | — |
87
+ | `abstractapi_key` | AbstractAPI key. Required when `provider: :abstractapi`. | — |
88
+ | `adapter` | An instance of `CleanEmail::Adapters::Base`. Optional. | `nil` |
89
+ | `blocked_statuses` | Statuses rejected by the ActiveModel/Devise validator. | `%w[invalid disposable spamtrap]` |
90
+
91
+ ### Provider examples
92
+
93
+ **Reoon:**
94
+ ```ruby
95
+ CleanEmail.configure do |config|
96
+ config.provider = :reoon
97
+ config.reoon_api_key = ENV["REOON_API_KEY"]
98
+ config.reoon_mode = :quick # or :power
99
+ config.adapter = CleanEmail::Adapters::ActiveRecord.new
100
+ end
101
+ ```
102
+
103
+ **ZeroBounce:**
104
+ ```ruby
105
+ CleanEmail.configure do |config|
106
+ config.provider = :zerobounce
107
+ config.zerobounce_api_key = ENV["ZEROBOUNCE_API_KEY"]
108
+ config.adapter = CleanEmail::Adapters::ActiveRecord.new
109
+ end
110
+ ```
111
+
112
+ **AbstractAPI:**
113
+ ```ruby
114
+ CleanEmail.configure do |config|
115
+ config.provider = :abstractapi
116
+ config.abstractapi_key = ENV["ABSTRACTAPI_KEY"]
117
+ config.adapter = CleanEmail::Adapters::ActiveRecord.new
118
+ end
119
+ ```
120
+
121
+ ## Usage
122
+
123
+ ```ruby
124
+ result = CleanEmail.validate("user@example.com")
125
+
126
+ result.valid? # => true / false
127
+ result.invalid? # => true / false
128
+ result.status # => "valid", "invalid", "disposable", "spamtrap", "unknown"
129
+ result.disposable? # => true / false
130
+ result.spamtrap? # => true / false
131
+ result.cached? # => true if result came from cache (no API call was made)
132
+ result.to_h # => { email:, status:, valid:, cached: }
133
+ ```
134
+
135
+ Or instantiate the validator directly to reuse the same instance:
136
+
137
+ ```ruby
138
+ validator = CleanEmail::Validator.new
139
+
140
+ validator.validate("a@example.com")
141
+ validator.validate("b@example.com")
142
+ ```
143
+
144
+ ## Normalized statuses
145
+
146
+ Regardless of which provider you use, all results are normalized to a common set of statuses:
147
+
148
+ | Status | Meaning | `valid?` |
149
+ |---|---|---|
150
+ | `valid` | Email exists and accepts messages | `true` |
151
+ | `invalid` | Invalid email or non-existent domain | `false` |
152
+ | `disposable` | Temporary / throwaway email service | `false` |
153
+ | `spamtrap` | Anti-spam trap address | `false` |
154
+ | `unknown` | Provider could not determine | `false` |
155
+
156
+ ### Provider status mapping
157
+
158
+ **Reoon** → normalized:
159
+
160
+ | Reoon status | Normalized |
161
+ |---|---|
162
+ | `valid` | `valid` |
163
+ | `invalid` | `invalid` |
164
+ | `disposable` | `disposable` |
165
+ | `spamtrap` | `spamtrap` |
166
+ | `unknown` | `unknown` |
167
+
168
+ **ZeroBounce** → normalized:
169
+
170
+ | ZeroBounce status | Sub-status | Normalized |
171
+ |---|---|---|
172
+ | `valid` | — | `valid` |
173
+ | `invalid` | — | `invalid` |
174
+ | `catch-all` | — | `unknown` |
175
+ | `unknown` | — | `unknown` |
176
+ | `spamtrap` | — | `spamtrap` |
177
+ | `abuse` | — | `invalid` |
178
+ | `do_not_mail` | `disposable` | `disposable` |
179
+ | `do_not_mail` | other | `invalid` |
180
+
181
+ **AbstractAPI** → normalized:
182
+
183
+ | `is_disposable_email` | `deliverability` | Normalized |
184
+ |---|---|---|
185
+ | `true` | any | `disposable` |
186
+ | `false` | `DELIVERABLE` | `valid` |
187
+ | `false` | `UNDELIVERABLE` | `invalid` |
188
+ | `false` | `RISKY` / `UNKNOWN` | `unknown` |
189
+
190
+ ## Adapter
191
+
192
+ The gem is storage-agnostic. You can implement `CleanEmail::Adapters::Base` with any backend, or use the built-in adapters.
193
+
194
+ ### Built-in: ActiveRecord (Rails)
195
+
196
+ Generated automatically by `rails generate clean_email:install`. Creates two tables:
197
+
198
+ - `clean_email_email_caches` — caches full email results
199
+ - `clean_email_domain_caches` — caches disposable/spamtrap domains
200
+
201
+ ```ruby
202
+ config.adapter = CleanEmail::Adapters::ActiveRecord.new
203
+ ```
204
+
205
+ ### Built-in: Memory (tests and prototyping)
206
+
207
+ Data lives only for the duration of the process:
208
+
209
+ ```ruby
210
+ config.adapter = CleanEmail::Adapters::Memory.new
211
+ ```
212
+
213
+ ### Custom adapter interface
214
+
215
+ Implement four methods:
216
+
217
+ ```ruby
218
+ class MyAdapter < CleanEmail::Adapters::Base
219
+ # Persist a validated email result
220
+ def store_email(email, result) = raise NotImplementedError
221
+ # Return a CleanEmail::Result or nil
222
+ def find_email(email) = raise NotImplementedError
223
+
224
+ # Persist a domain marked as "disposable" or "spamtrap"
225
+ def store_domain(domain, status) = raise NotImplementedError
226
+ # Return "disposable", "spamtrap", or nil
227
+ def find_domain(domain) = raise NotImplementedError
228
+ end
229
+ ```
230
+
231
+ ### Redis example
232
+
233
+ ```ruby
234
+ class RedisAdapter < CleanEmail::Adapters::Base
235
+ TTL_EMAIL = 7 * 24 * 60 * 60 # 7 days
236
+ TTL_DOMAIN = 30 * 24 * 60 * 60 # 30 days
237
+
238
+ def initialize(redis = Redis.current)
239
+ @redis = redis
240
+ end
241
+
242
+ def store_email(email, result)
243
+ @redis.setex("email_valid:#{email}", TTL_EMAIL, result.status)
244
+ end
245
+
246
+ def find_email(email)
247
+ status = @redis.get("email_valid:#{email}")
248
+ return nil unless status
249
+ CleanEmail::Result.new(email: email, status: status)
250
+ end
251
+
252
+ def store_domain(domain, status)
253
+ @redis.setex("domain_valid:#{domain}", TTL_DOMAIN, status)
254
+ end
255
+
256
+ def find_domain(domain)
257
+ @redis.get("domain_valid:#{domain}")
258
+ end
259
+ end
260
+ ```
261
+
262
+ ## Devise / ActiveModel integration
263
+
264
+ The gem ships with an opt-in `ActiveModel` validator that can be used in any model — including Devise's `User`.
265
+
266
+ ### Setup
267
+
268
+ Add `validates :email, clean_email: true` to your model:
269
+
270
+ ```ruby
271
+ class User < ApplicationRecord
272
+ devise :database_authenticatable, :registerable, ...
273
+
274
+ validates :email, clean_email: true
275
+ end
276
+ ```
277
+
278
+ The validator calls the configured provider API (with caching if an adapter is configured) and rejects any status listed in `blocked_statuses`.
279
+
280
+ The API is only called when necessary: on new records, or when the email attribute has actually changed. Saving other fields (e.g. name, avatar) does not trigger a new API call.
281
+
282
+ ### Configuring which statuses are blocked
283
+
284
+ Set a global default in the initializer:
285
+
286
+ ```ruby
287
+ CleanEmail.configure do |config|
288
+ config.provider = :zerobounce
289
+ config.zerobounce_api_key = ENV["ZEROBOUNCE_API_KEY"]
290
+
291
+ # Reject disposable and invalid emails; allow "unknown" through
292
+ config.blocked_statuses = %w[invalid disposable spamtrap]
293
+ end
294
+ ```
295
+
296
+ Or override per-model:
297
+
298
+ ```ruby
299
+ # Only block outright invalid emails
300
+ validates :email, clean_email: { blocked_statuses: %w[invalid] }
301
+ ```
302
+
303
+ ### Custom error message
304
+
305
+ ```ruby
306
+ validates :email, clean_email: { message: "is not accepted" }
307
+ ```
308
+
309
+ ### API errors
310
+
311
+ If the provider API is unreachable, the validator rejects the email and logs a warning via `Rails.logger`. To change this behaviour, rescue `CleanEmail::ApiError` in a `before_validation` callback.
312
+
313
+ ## Error handling
314
+
315
+ | Exception | When raised |
316
+ |---|---|
317
+ | `CleanEmail::ConfigurationError` | Required API key not set, or unknown provider |
318
+ | `CleanEmail::ApiError` | HTTP request failed or provider returned an invalid response |
319
+
320
+ ```ruby
321
+ begin
322
+ result = CleanEmail.validate(email)
323
+ rescue CleanEmail::ApiError => e
324
+ # API failed — decide whether to reject or accept provisionally
325
+ Rails.logger.error("Email validation error: #{e.message}")
326
+ end
327
+ ```
328
+
329
+ ## Reoon API modes
330
+
331
+ Only applicable when `provider: :reoon`:
332
+
333
+ - **`:quick`** — Fast validation based on syntax, DNS, and known lists. Recommended for real-time validation.
334
+ - **`:power`** — Full validation including SMTP verification. More accurate but slower. Best for batch processing.
335
+
336
+ ## Development
337
+
338
+ ```bash
339
+ bundle install
340
+ bundle exec rspec
341
+ ```
342
+
343
+ ## License
344
+
345
+ MIT
@@ -0,0 +1 @@
1
+ require_relative "clean_email"
@@ -0,0 +1,59 @@
1
+ require "faraday"
2
+ require "json"
3
+
4
+ module CleanEmail
5
+ # Thin wrapper around the Abstract Email Validation API.
6
+ # https://docs.abstractapi.com/email-validation
7
+ class AbstractApiClient
8
+ BASE_URL = "https://emailreputation.abstractapi.com/v1/"
9
+
10
+ def initialize(api_key:)
11
+ @api_key = api_key
12
+ end
13
+
14
+ # @param email [String]
15
+ # @return [CleanEmail::Result]
16
+ # @raise [CleanEmail::ApiError]
17
+ def validate(email)
18
+ response = connection.get do |req|
19
+ req.params["api_key"] = @api_key
20
+ req.params["email"] = email
21
+ end
22
+
23
+ handle_response(email, response)
24
+ rescue Faraday::Error => e
25
+ raise ApiError, "AbstractAPI request failed: #{e.message}"
26
+ end
27
+
28
+ private
29
+
30
+ def connection
31
+ @connection ||= Faraday.new(url: BASE_URL) do |f|
32
+ f.response :raise_error
33
+ f.options.timeout = 10
34
+ f.options.open_timeout = 5
35
+ end
36
+ end
37
+
38
+ def handle_response(email, response)
39
+ body = JSON.parse(response.body)
40
+ status = extract_status(body)
41
+ Result.new(email: email, status: status, raw: body)
42
+ rescue JSON::ParserError => e
43
+ raise ApiError, "Invalid JSON from AbstractAPI: #{e.message}"
44
+ rescue Faraday::Error => e
45
+ raise ApiError, "AbstractAPI request failed: #{e.message}"
46
+ end
47
+
48
+ def extract_status(body)
49
+ # Disposable check takes priority regardless of deliverability
50
+ return "disposable" if body.dig("is_disposable_email", "value") == true
51
+
52
+ case body["deliverability"].to_s.upcase
53
+ when "DELIVERABLE" then "valid"
54
+ when "UNDELIVERABLE" then "invalid"
55
+ else "unknown"
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,33 @@
1
+ # ActiveModel/Devise integration for CleanEmail.
2
+ #
3
+ # Opt-in by adding to your model (e.g. User):
4
+ #
5
+ # validates :email, clean_email: true
6
+ #
7
+ # This calls the Reoon API (with caching if an adapter is configured) and
8
+ # rejects any status listed in CleanEmail.configuration.blocked_statuses.
9
+ #
10
+ # You can override blocked statuses per-validator:
11
+ #
12
+ # validates :email, clean_email: { blocked_statuses: %w[invalid] }
13
+ #
14
+ # Or customise the error message:
15
+ #
16
+ # validates :email, clean_email: { message: "is not accepted" }
17
+ #
18
+ class CleanEmailValidator < ActiveModel::EachValidator
19
+ def validate_each(record, attribute, value)
20
+ return if record.persisted? && !record.will_save_change_to_attribute?(attribute)
21
+
22
+ result = CleanEmail.validate(value.to_s)
23
+
24
+ blocked = options.fetch(:blocked_statuses, CleanEmail.configuration.blocked_statuses)
25
+
26
+ if blocked.include?(result.status)
27
+ record.errors.add(attribute, options[:message] || :invalid)
28
+ end
29
+ rescue CleanEmail::ApiError => e
30
+ record.errors.add(attribute, options[:message] || :invalid)
31
+ Rails.logger.warn("[CleanEmail] API error during validation: #{e.message}") if defined?(Rails)
32
+ end
33
+ end
@@ -0,0 +1,60 @@
1
+ require_relative "base"
2
+
3
+ module CleanEmail
4
+ module Adapters
5
+ # ActiveRecord adapter. Requires the tables created by the install generator:
6
+ #
7
+ # rails generate clean_email:install
8
+ # rails db:migrate
9
+ #
10
+ # Then configure:
11
+ #
12
+ # CleanEmail.configure do |c|
13
+ # c.adapter = CleanEmail::Adapters::ActiveRecord.new
14
+ # end
15
+ class ActiveRecord < Base
16
+ EMAIL_MODEL = "CleanEmail::EmailCache"
17
+ DOMAIN_MODEL = "CleanEmail::DomainCache"
18
+
19
+ def store_email(email, result)
20
+ email_model.upsert(
21
+ { email: email, status: result.status },
22
+ unique_by: :email
23
+ )
24
+ end
25
+
26
+ def find_email(email)
27
+ row = email_model.find_by(email: email)
28
+ return nil unless row
29
+ Result.new(email: row.email, status: row.status)
30
+ end
31
+
32
+ def store_domain(domain, status)
33
+ domain_model.upsert(
34
+ { domain: domain, status: status },
35
+ unique_by: :domain
36
+ )
37
+ end
38
+
39
+ def find_domain(domain)
40
+ domain_model.find_by(domain: domain)&.status
41
+ end
42
+
43
+ private
44
+
45
+ def email_model
46
+ Object.const_get(EMAIL_MODEL)
47
+ rescue NameError
48
+ raise ConfigurationError,
49
+ "#{EMAIL_MODEL} model not found. Did you run `rails generate clean_email:install` and `rails db:migrate`?"
50
+ end
51
+
52
+ def domain_model
53
+ Object.const_get(DOMAIN_MODEL)
54
+ rescue NameError
55
+ raise ConfigurationError,
56
+ "#{DOMAIN_MODEL} model not found. Did you run `rails generate clean_email:install` and `rails db:migrate`?"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,43 @@
1
+ module CleanEmail
2
+ module Adapters
3
+ # Base interface for storage adapters.
4
+ #
5
+ # Implement this in your application to plug in any storage backend
6
+ # (ActiveRecord, Redis, SQLite, etc.).
7
+ #
8
+ # All methods must be implemented by subclasses.
9
+ class Base
10
+ # Persist a validated email result.
11
+ #
12
+ # @param email [String] the full email address
13
+ # @param result [CleanEmail::Result]
14
+ def store_email(email, result)
15
+ raise NotImplementedError, "#{self.class}#store_email not implemented"
16
+ end
17
+
18
+ # Retrieve a cached result for an email.
19
+ #
20
+ # @param email [String]
21
+ # @return [CleanEmail::Result, nil] nil if not cached
22
+ def find_email(email)
23
+ raise NotImplementedError, "#{self.class}#find_email not implemented"
24
+ end
25
+
26
+ # Persist a domain marked as disposable or spamtrap.
27
+ #
28
+ # @param domain [String] e.g. "mailinator.com"
29
+ # @param status [String] "disposable" or "spamtrap"
30
+ def store_domain(domain, status)
31
+ raise NotImplementedError, "#{self.class}#store_domain not implemented"
32
+ end
33
+
34
+ # Retrieve a cached domain status.
35
+ #
36
+ # @param domain [String]
37
+ # @return [String, nil] "disposable", "spamtrap", or nil if not cached
38
+ def find_domain(domain)
39
+ raise NotImplementedError, "#{self.class}#find_domain not implemented"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,30 @@
1
+ require_relative "base"
2
+
3
+ module CleanEmail
4
+ module Adapters
5
+ # In-memory adapter. Useful for tests and quick prototyping.
6
+ # Data is lost when the process exits.
7
+ class Memory < Base
8
+ def initialize
9
+ @emails = {}
10
+ @domains = {}
11
+ end
12
+
13
+ def store_email(email, result)
14
+ @emails[email.downcase] = result
15
+ end
16
+
17
+ def find_email(email)
18
+ @emails[email.downcase]
19
+ end
20
+
21
+ def store_domain(domain, status)
22
+ @domains[domain.downcase] = status
23
+ end
24
+
25
+ def find_domain(domain)
26
+ @domains[domain.downcase]
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,53 @@
1
+ module CleanEmail
2
+ class Configuration
3
+ PROVIDERS = %i[reoon zerobounce abstractapi].freeze
4
+
5
+ # Which provider to use: :reoon, :zerobounce, or :abstractapi
6
+ attr_accessor :provider
7
+
8
+ # Reoon-specific options
9
+ attr_accessor :reoon_api_key
10
+ attr_accessor :reoon_mode
11
+
12
+ # ZeroBounce-specific options
13
+ attr_accessor :zerobounce_api_key
14
+
15
+ # AbstractAPI-specific options
16
+ attr_accessor :abstractapi_key
17
+
18
+ # An object implementing CleanEmail::Adapters::Base
19
+ attr_accessor :adapter
20
+
21
+ # Statuses that cause the ActiveModel validator to reject the email.
22
+ # Used by `validates :email, clean_email: true` in Devise (or any model).
23
+ # Possible values: "invalid", "disposable", "spamtrap", "unknown"
24
+ attr_accessor :blocked_statuses
25
+
26
+ def initialize
27
+ @provider = :reoon
28
+ @reoon_mode = :quick
29
+ @adapter = nil
30
+ @reoon_api_key = nil
31
+ @zerobounce_api_key = nil
32
+ @abstractapi_key = nil
33
+ @blocked_statuses = %w[invalid disposable spamtrap]
34
+ end
35
+
36
+ def validate!
37
+ case provider.to_sym
38
+ when :reoon
39
+ raise ConfigurationError, "reoon_api_key is required" if reoon_api_key.nil? || reoon_api_key.empty?
40
+ when :zerobounce
41
+ raise ConfigurationError, "zerobounce_api_key is required" if zerobounce_api_key.nil? || zerobounce_api_key.empty?
42
+ when :abstractapi
43
+ raise ConfigurationError, "abstractapi_key is required" if abstractapi_key.nil? || abstractapi_key.empty?
44
+ else
45
+ raise ConfigurationError, "Unknown provider: #{provider}. Use one of: #{PROVIDERS.join(', ')}"
46
+ end
47
+ end
48
+
49
+ def adapter?
50
+ !adapter.nil?
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,8 @@
1
+ module CleanEmail
2
+ class DomainCache < ::ActiveRecord::Base
3
+ self.table_name = "clean_email_domain_caches"
4
+
5
+ validates :domain, presence: true, uniqueness: true
6
+ validates :status, presence: true
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ module CleanEmail
2
+ class EmailCache < ::ActiveRecord::Base
3
+ self.table_name = "clean_email_email_caches"
4
+
5
+ validates :email, presence: true, uniqueness: true
6
+ validates :status, presence: true
7
+ end
8
+ end
@@ -0,0 +1,60 @@
1
+ require "faraday"
2
+ require "json"
3
+
4
+ module CleanEmail
5
+ # Thin wrapper around the Reoon Email Verifier API.
6
+ # https://reoon.com/email-verifier-api/
7
+ class ReoonClient
8
+ BASE_URL = "https://emailverifier.reoon.com/api/v1/verify"
9
+
10
+ VALID_STATUSES = %w[valid invalid disposable spamtrap unknown].freeze
11
+
12
+ def initialize(api_key:, mode: :quick)
13
+ @api_key = api_key
14
+ @mode = mode.to_s # "quick" or "power"
15
+ end
16
+
17
+ # @param email [String]
18
+ # @return [CleanEmail::Result]
19
+ # @raise [CleanEmail::ApiError]
20
+ def validate(email)
21
+ response = connection.get do |req|
22
+ req.params["email"] = email
23
+ req.params["key"] = @api_key
24
+ req.params["mode"] = @mode
25
+ end
26
+
27
+ handle_response(email, response)
28
+ end
29
+
30
+ private
31
+
32
+ def connection
33
+ @connection ||= Faraday.new(url: BASE_URL) do |f|
34
+ f.response :raise_error
35
+ f.options.timeout = 10
36
+ f.options.open_timeout = 5
37
+ end
38
+ end
39
+
40
+ def handle_response(email, response)
41
+ body = JSON.parse(response.body)
42
+
43
+ # Reoon wraps results; extract the status field
44
+ status = extract_status(body)
45
+
46
+ Result.new(email: email, status: status, raw: body)
47
+ rescue JSON::ParserError => e
48
+ raise ApiError, "Invalid JSON from Reoon: #{e.message}"
49
+ rescue Faraday::Error => e
50
+ raise ApiError, "Reoon API request failed: #{e.message}"
51
+ end
52
+
53
+ def extract_status(body)
54
+ # Reoon v1 response shape:
55
+ # { "status": "valid"|"invalid"|"disposable"|"spamtrap"|..., ... }
56
+ raw_status = body["status"].to_s.downcase
57
+ VALID_STATUSES.include?(raw_status) ? raw_status : "unknown"
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,53 @@
1
+ module CleanEmail
2
+ # Wraps the outcome of an email validation check.
3
+ class Result
4
+ # Reoon statuses that make an email unusable
5
+ BAD_STATUSES = %w[disposable spamtrap].freeze
6
+
7
+ attr_reader :email, :status, :raw
8
+
9
+ # @param email [String] the validated email
10
+ # @param status [String] Reoon status: "valid", "invalid", "disposable",
11
+ # "spamtrap", "unknown", etc.
12
+ # @param raw [Hash] the full Reoon API response (may be nil for
13
+ # domain-level cache hits)
14
+ def initialize(email:, status:, raw: nil)
15
+ @email = email
16
+ @status = status
17
+ @raw = raw
18
+ end
19
+
20
+ def valid?
21
+ status == "valid"
22
+ end
23
+
24
+ def invalid?
25
+ !valid?
26
+ end
27
+
28
+ def disposable?
29
+ status == "disposable"
30
+ end
31
+
32
+ def spamtrap?
33
+ status == "spamtrap"
34
+ end
35
+
36
+ # True when the domain alone is enough to reject (no API call needed next time)
37
+ def bad_domain?
38
+ BAD_STATUSES.include?(status)
39
+ end
40
+
41
+ def cached?
42
+ raw.nil?
43
+ end
44
+
45
+ def to_h
46
+ { email: email, status: status, valid: valid?, cached: cached? }
47
+ end
48
+
49
+ def inspect
50
+ "#<CleanEmail::Result email=#{email.inspect} status=#{status.inspect} valid=#{valid?}>"
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,88 @@
1
+ module CleanEmail
2
+ class Validator
3
+ BASIC_FORMAT = /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/
4
+
5
+ def initialize(config = CleanEmail.configuration)
6
+ @config = config
7
+ end
8
+
9
+ # Validate an email address.
10
+ #
11
+ # Resolution order (when an adapter is configured):
12
+ # 1. Reject malformed emails immediately (no cache, no API call)
13
+ # 2. If domain is cached as disposable/spamtrap → reject instantly
14
+ # 3. If full email is cached → return cached result
15
+ # 4. Call the configured provider API → cache result → return
16
+ #
17
+ # Without an adapter every call goes straight to the provider API.
18
+ #
19
+ # @param email [String]
20
+ # @return [CleanEmail::Result]
21
+ def validate(email)
22
+ @config.validate!
23
+
24
+ email = email.to_s.strip.downcase
25
+
26
+ return malformed_result(email) unless looks_like_email?(email)
27
+
28
+ return client.validate(email) unless adapter?
29
+
30
+ domain = extract_domain(email)
31
+
32
+ # 1. Domain-level cache (cheapest check)
33
+ if (domain_status = adapter.find_domain(domain))
34
+ return Result.new(email: email, status: domain_status)
35
+ end
36
+
37
+ # 2. Full email cache
38
+ if (cached = adapter.find_email(email))
39
+ return cached
40
+ end
41
+
42
+ # 3. API call
43
+ result = client.validate(email)
44
+
45
+ # Cache at domain level for disposable/spamtrap — any future email
46
+ # from this domain gets rejected without hitting the API
47
+ adapter.store_domain(domain, result.status) if result.bad_domain?
48
+ adapter.store_email(email, result)
49
+
50
+ result
51
+ end
52
+
53
+ private
54
+
55
+ def client
56
+ @client ||= case @config.provider.to_sym
57
+ when :reoon
58
+ ReoonClient.new(api_key: @config.reoon_api_key, mode: @config.reoon_mode)
59
+ when :zerobounce
60
+ ZeroBounceClient.new(api_key: @config.zerobounce_api_key)
61
+ when :abstractapi
62
+ AbstractApiClient.new(api_key: @config.abstractapi_key)
63
+ else
64
+ raise ConfigurationError, "Unknown provider: #{@config.provider}"
65
+ end
66
+ end
67
+
68
+ def adapter
69
+ @config.adapter
70
+ end
71
+
72
+ def adapter?
73
+ @config.adapter?
74
+ end
75
+
76
+ def looks_like_email?(email)
77
+ BASIC_FORMAT.match?(email)
78
+ end
79
+
80
+ def extract_domain(email)
81
+ email.split("@", 2).last
82
+ end
83
+
84
+ def malformed_result(email)
85
+ Result.new(email: email, status: "invalid")
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,3 @@
1
+ module CleanEmail
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,71 @@
1
+ require "faraday"
2
+ require "json"
3
+
4
+ module CleanEmail
5
+ # Thin wrapper around the ZeroBounce Email Validation API.
6
+ # https://www.zerobounce.net/docs/email-validation-api-quickstart/
7
+ class ZeroBounceClient
8
+ BASE_URL = "https://api.zerobounce.net/v2/validate"
9
+
10
+ # ZeroBounce status → normalized CleanEmail status
11
+ STATUS_MAP = {
12
+ "valid" => "valid",
13
+ "invalid" => "invalid",
14
+ "catch-all" => "unknown",
15
+ "unknown" => "unknown",
16
+ "spamtrap" => "spamtrap",
17
+ "abuse" => "invalid",
18
+ "do_not_mail" => "invalid"
19
+ }.freeze
20
+
21
+ def initialize(api_key:)
22
+ @api_key = api_key
23
+ end
24
+
25
+ # @param email [String]
26
+ # @return [CleanEmail::Result]
27
+ # @raise [CleanEmail::ApiError]
28
+ def validate(email)
29
+ response = connection.get do |req|
30
+ req.params["api_key"] = @api_key
31
+ req.params["email"] = email
32
+ req.params["ip_address"] = ""
33
+ end
34
+
35
+ handle_response(email, response)
36
+ rescue Faraday::Error => e
37
+ raise ApiError, "ZeroBounce API request failed: #{e.message}"
38
+ end
39
+
40
+ private
41
+
42
+ def connection
43
+ @connection ||= Faraday.new(url: BASE_URL) do |f|
44
+ f.response :raise_error
45
+ f.options.timeout = 10
46
+ f.options.open_timeout = 5
47
+ end
48
+ end
49
+
50
+ def handle_response(email, response)
51
+ body = JSON.parse(response.body)
52
+ status = extract_status(body)
53
+ Result.new(email: email, status: status, raw: body)
54
+ rescue JSON::ParserError => e
55
+ raise ApiError, "Invalid JSON from ZeroBounce: #{e.message}"
56
+ rescue Faraday::Error => e
57
+ raise ApiError, "ZeroBounce API request failed: #{e.message}"
58
+ end
59
+
60
+ def extract_status(body)
61
+ raw_status = body["status"].to_s.downcase
62
+
63
+ # do_not_mail with sub_status "disposable" is a disposable address
64
+ if raw_status == "do_not_mail" && body["sub_status"].to_s.downcase == "disposable"
65
+ return "disposable"
66
+ end
67
+
68
+ STATUS_MAP.fetch(raw_status, "unknown")
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,45 @@
1
+ require_relative "clean_email/version"
2
+ require_relative "clean_email/configuration"
3
+ require_relative "clean_email/result"
4
+ require_relative "clean_email/reoon_client"
5
+ require_relative "clean_email/zerobounce_client"
6
+ require_relative "clean_email/abstract_api_client"
7
+ require_relative "clean_email/validator"
8
+ require_relative "clean_email/adapters/base"
9
+ require_relative "clean_email/adapters/memory"
10
+
11
+ if defined?(ActiveRecord)
12
+ require_relative "clean_email/adapters/active_record"
13
+ require_relative "clean_email/models/email_cache"
14
+ require_relative "clean_email/models/domain_cache"
15
+ end
16
+
17
+ # ActiveModel validator (enables `validates :email, clean_email: true` in
18
+ # Devise models or any ActiveModel-backed class).
19
+ require_relative "clean_email/active_model_validator" if defined?(ActiveModel)
20
+
21
+ module CleanEmail
22
+ class Error < StandardError; end
23
+ class ConfigurationError < Error; end
24
+ class ApiError < Error; end
25
+
26
+ class << self
27
+ def configuration
28
+ @configuration ||= Configuration.new
29
+ end
30
+
31
+ def configure
32
+ yield configuration
33
+ end
34
+
35
+ def reset_configuration!
36
+ @configuration = Configuration.new
37
+ end
38
+
39
+ # Convenience method: CleanEmail.validate("user@example.com")
40
+ # Uses the globally configured validator.
41
+ def validate(email)
42
+ Validator.new.validate(email)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,32 @@
1
+ require "rails/generators"
2
+ require "rails/generators/active_record"
3
+
4
+ module CleanEmail
5
+ module Generators
6
+ class InstallGenerator < Rails::Generators::Base
7
+ include ActiveRecord::Generators::Migration
8
+
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ desc "Creates a CleanEmail initializer and ActiveRecord migration."
12
+
13
+ def create_initializer
14
+ template "initializer.rb.tt", "config/initializers/clean_email.rb"
15
+ end
16
+
17
+ def copy_migration
18
+ migration_template(
19
+ "migration.rb.tt",
20
+ "db/migrate/create_clean_email_caches.rb",
21
+ migration_version: migration_version
22
+ )
23
+ end
24
+
25
+ private
26
+
27
+ def migration_version
28
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
29
+ end
30
+ end
31
+ end
32
+ end
metadata ADDED
@@ -0,0 +1,128 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: clean-email
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - TapGoods
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: faraday
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '1.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rspec
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '3.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '3.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: webmock
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '3.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '3.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: simplecov
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.22'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.22'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rubocop-rails-omakase
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ description: |
83
+ Validates emails via the Reoon API while minimizing API usage through
84
+ storage-agnostic caching. Disposable and spamtrap domains are cached
85
+ at the domain level so subsequent emails from the same domain are
86
+ rejected instantly without an API call.
87
+ executables: []
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - README.md
92
+ - lib/clean-email.rb
93
+ - lib/clean_email.rb
94
+ - lib/clean_email/abstract_api_client.rb
95
+ - lib/clean_email/active_model_validator.rb
96
+ - lib/clean_email/adapters/active_record.rb
97
+ - lib/clean_email/adapters/base.rb
98
+ - lib/clean_email/adapters/memory.rb
99
+ - lib/clean_email/configuration.rb
100
+ - lib/clean_email/models/domain_cache.rb
101
+ - lib/clean_email/models/email_cache.rb
102
+ - lib/clean_email/reoon_client.rb
103
+ - lib/clean_email/result.rb
104
+ - lib/clean_email/validator.rb
105
+ - lib/clean_email/version.rb
106
+ - lib/clean_email/zerobounce_client.rb
107
+ - lib/generators/clean_email/install/install_generator.rb
108
+ licenses:
109
+ - MIT
110
+ metadata: {}
111
+ rdoc_options: []
112
+ require_paths:
113
+ - lib
114
+ required_ruby_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '3.0'
119
+ required_rubygems_version: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
124
+ requirements: []
125
+ rubygems_version: 3.6.9
126
+ specification_version: 4
127
+ summary: Email validation using Reoon with smart local caching
128
+ test_files: []