zerobounce-sdk 2.1.7 → 2.2.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 +4 -4
- data/CHANGELOG.md +146 -19
- data/README.md +472 -410
- data/lib/zerobounce/api_urls.rb +9 -0
- data/lib/zerobounce/base_request.rb +208 -8
- data/lib/zerobounce/client.rb +22 -0
- data/lib/zerobounce/configuration.rb +24 -3
- data/lib/zerobounce/error.rb +18 -0
- data/lib/zerobounce/get_file_helper.rb +3 -7
- data/lib/zerobounce/mock_request.rb +31 -12
- data/lib/zerobounce/priority3.rb +331 -0
- data/lib/zerobounce/request.rb +77 -38
- data/lib/zerobounce/response_error.rb +117 -0
- data/lib/zerobounce/validate_sub_status.rb +1 -0
- data/lib/zerobounce/version.rb +1 -1
- data/lib/zerobounce/warmup.rb +467 -0
- data/lib/zerobounce.rb +101 -14
- metadata +8 -33
- data/.dockerignore +0 -17
- data/.env.example +0 -3
- data/.github/auto_assign.yml +0 -5
- data/.github/dependabot.yml +0 -35
- data/.github/workflows/auto_assign_ci.yaml +0 -21
- data/.github/workflows/codeql.yml +0 -105
- data/.github/workflows/publish.yml +0 -131
- data/.github/workflows/sdk_ci.yml +0 -29
- data/.gitignore +0 -70
- data/.rspec +0 -2
- data/.ruby-version +0 -1
- data/CODE_OF_CONDUCT.md +0 -69
- data/CONTRIBUTING.md +0 -112
- data/Dockerfile +0 -17
- data/Gemfile +0 -13
- data/Gemfile.lock +0 -135
- data/Rakefile +0 -26
- data/SECURITY.md +0 -21
- data/bin/bundle +0 -105
- data/bin/console +0 -7
- data/bin/setup +0 -6
- data/data/zerobounce-ai-scoring.csv +0 -31
- data/data/zerobounce-batch-validation.csv +0 -31
- data/documentation.md +0 -530
- data/files/scoring.csv +0 -1
- data/files/validation.csv +0 -1
- data/files/zerobounce-ai-scoring.csv +0 -31
- data/files/zerobounce-batch-validation.csv +0 -31
- data/zerobounce.gemspec +0 -49
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
require 'erb'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'rest-client'
|
|
7
|
+
|
|
8
|
+
require 'zerobounce/error'
|
|
9
|
+
require 'zerobounce/response_error'
|
|
10
|
+
|
|
11
|
+
module Zerobounce
|
|
12
|
+
# Client for the ZeroBounce Warmup API
|
|
13
|
+
# (https://www.zerobounce.net/docs/warmup-api).
|
|
14
|
+
#
|
|
15
|
+
# The Warmup API is separate from the email validation API: its own host, its
|
|
16
|
+
# own team-scoped key, and an "API-Key" header instead of an api_key
|
|
17
|
+
# parameter. Use Zerobounce::Warmup with a Warmup API key; the Zerobounce
|
|
18
|
+
# module stays for the validation API key.
|
|
19
|
+
#
|
|
20
|
+
# warmup = Zerobounce::Warmup.new('<YOUR_WARMUP_API_KEY>')
|
|
21
|
+
# warmup.list_sending_accounts
|
|
22
|
+
#
|
|
23
|
+
# A documented empty success (204, or an empty 200) comes back as nil. A
|
|
24
|
+
# payload that is not a JSON object - /seeds is an array, /seed-count a bare
|
|
25
|
+
# number - comes back as the parsed value itself.
|
|
26
|
+
class Warmup
|
|
27
|
+
DEFAULT_BASE_URL = 'https://warmup-api.zerobounce.net'
|
|
28
|
+
BASE_PATH = '/api/v1/warmup'
|
|
29
|
+
|
|
30
|
+
ONBOARDING_MODELS = %w[self_managed zb_managed].freeze
|
|
31
|
+
EMAIL_TYPES = %w[transactional newsletter marketing].freeze
|
|
32
|
+
|
|
33
|
+
# Required by the schema but always replaced with the key's own team.
|
|
34
|
+
ANY_TEAM_ID = 1
|
|
35
|
+
|
|
36
|
+
# @param api_key [String] your Warmup API key, sent in the API-Key header
|
|
37
|
+
# @param base_url [String] defaults to https://warmup-api.zerobounce.net
|
|
38
|
+
def initialize(api_key, base_url = DEFAULT_BASE_URL)
|
|
39
|
+
raise ArgumentError, 'api_key is required' if api_key.nil? || api_key.to_s.strip.empty?
|
|
40
|
+
|
|
41
|
+
# A key with surrounding whitespace or control characters would be
|
|
42
|
+
# quoted, unredacted, in the HTTP library's own error.
|
|
43
|
+
if api_key != api_key.strip || api_key.match?(/[\u0000-\u001f\u007f]/)
|
|
44
|
+
raise ArgumentError, 'api_key has surrounding whitespace or control characters'
|
|
45
|
+
end
|
|
46
|
+
unless base_url.to_s.downcase.start_with?('https://')
|
|
47
|
+
raise ArgumentError, 'base_url must be an https:// URL'
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
@api_key = api_key
|
|
51
|
+
root = base_url.to_s
|
|
52
|
+
root = root.chomp('/') while root.end_with?('/')
|
|
53
|
+
@root = "#{root}#{BASE_PATH}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# -- sending accounts ---------------------------------------------------
|
|
57
|
+
|
|
58
|
+
# Every sending account of the key's team. GET /sending-accounts
|
|
59
|
+
def list_sending_accounts
|
|
60
|
+
get('/sending-accounts')
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# One sending account. GET /sending-account
|
|
64
|
+
def get_sending_account(from_address)
|
|
65
|
+
require_value(from_address, 'from_address')
|
|
66
|
+
get('/sending-account', from_address: from_address)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Adds a from-address to warmup. POST /sending-accounts
|
|
70
|
+
#
|
|
71
|
+
# +self_managed+ runs your own mailbox and needs +engagement_rule_id+ 0-4.
|
|
72
|
+
# +zb_managed+ has ZeroBounce provision it and takes +email_content+ and
|
|
73
|
+
# +managed_sending+; a 200 means accepted, and the mailbox is provisioned
|
|
74
|
+
# afterwards.
|
|
75
|
+
def add_sending_account(from_address, onboarding_model, **options)
|
|
76
|
+
require_value(from_address, 'from_address')
|
|
77
|
+
unless ONBOARDING_MODELS.include?(onboarding_model.to_s)
|
|
78
|
+
raise ArgumentError, 'onboarding_model must be self_managed or zb_managed'
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
body = { 'from_address' => from_address, 'onboarding_model' => onboarding_model.to_s }
|
|
82
|
+
if onboarding_model.to_s == 'self_managed'
|
|
83
|
+
rule = options[:engagement_rule_id]
|
|
84
|
+
raise ArgumentError, 'engagement_rule_id is required for self_managed' if rule.nil?
|
|
85
|
+
|
|
86
|
+
body['engagement_rule_id'] = in_range(rule, 0, 4, 'engagement_rule_id')
|
|
87
|
+
if options.key?(:email_content) || options.key?(:managed_sending)
|
|
88
|
+
raise ArgumentError, 'email_content and managed_sending are for zb_managed'
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
body['ignore_conflict'] = !options[:ignore_conflict].nil? && options[:ignore_conflict] ? true : false if
|
|
92
|
+
options.key?(:ignore_conflict)
|
|
93
|
+
body['email_content'] = email_content_body(options[:email_content]) if options.key?(:email_content)
|
|
94
|
+
body['managed_sending'] = managed_sending_body(options[:managed_sending]) if options.key?(:managed_sending)
|
|
95
|
+
|
|
96
|
+
json_request(:post, '/sending-accounts', {}, body)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Changes an account's engagement rule, status and auto-enable flag.
|
|
100
|
+
# PUT /sending-accounts. +status_id+ is 0 to 8 (4 Paused, 5 Disabled).
|
|
101
|
+
def update_sending_account(from_address, engagement_rule_id, status_id, auto_enable)
|
|
102
|
+
require_value(from_address, 'from_address')
|
|
103
|
+
unless [true, false].include?(auto_enable)
|
|
104
|
+
raise ArgumentError, 'auto_enable must be true or false'
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
json_request(:put, '/sending-accounts', {},
|
|
108
|
+
{ 'team_id' => ANY_TEAM_ID,
|
|
109
|
+
'from_address' => from_address,
|
|
110
|
+
'engagement_rule_id' => in_range(engagement_rule_id, 0, 4, 'engagement_rule_id'),
|
|
111
|
+
'status_id' => in_range(status_id, 0, 8, 'status_id'),
|
|
112
|
+
'auto_enable' => auto_enable })
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Removes a sending account. DELETE /sending-accounts. Destructive and final.
|
|
116
|
+
def delete_sending_account(from_address)
|
|
117
|
+
require_value(from_address, 'from_address')
|
|
118
|
+
json_request(:delete, '/sending-accounts', { from_address: from_address })
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Moves an account and its history to a new from-address.
|
|
122
|
+
# PUT /smtp-credentials - despite the path, no SMTP credentials are stored.
|
|
123
|
+
def change_from_address(old_from_address, new_from_address)
|
|
124
|
+
require_value(old_from_address, 'old_from_address')
|
|
125
|
+
require_value(new_from_address, 'new_from_address')
|
|
126
|
+
json_request(:put, '/smtp-credentials', {},
|
|
127
|
+
{ 'team_id' => ANY_TEAM_ID,
|
|
128
|
+
'old_from_address' => old_from_address,
|
|
129
|
+
'new_from_address' => new_from_address })
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Registered from-addresses. GET /from-addresses
|
|
133
|
+
#
|
|
134
|
+
# +search_from+ takes letters, digits and . - _ @ + only. +offset+ and
|
|
135
|
+
# +limit+ go on the wire as strings.
|
|
136
|
+
def list_from_addresses(search_from: nil, offset: nil, limit: nil)
|
|
137
|
+
params = {}
|
|
138
|
+
unless search_from.nil?
|
|
139
|
+
unless search_from.to_s.match?(/\A[A-Za-z0-9.\-_@+]*\z/)
|
|
140
|
+
raise ArgumentError, 'search_from takes letters, digits and . - _ @ + only'
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
params[:search_from] = search_from
|
|
144
|
+
end
|
|
145
|
+
{ offset: offset, limit: limit }.each do |name, value|
|
|
146
|
+
next if value.nil?
|
|
147
|
+
raise ArgumentError, "#{name} must be a non-negative integer" unless value.is_a?(Integer) && value >= 0
|
|
148
|
+
|
|
149
|
+
params[name] = value.to_s
|
|
150
|
+
end
|
|
151
|
+
get('/from-addresses', **params)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# -- engagement rules ---------------------------------------------------
|
|
155
|
+
|
|
156
|
+
# -1 Custom, 0 Default, 1 Slow Pace, 2 Repair, 3 Boost, 4 Protect.
|
|
157
|
+
def engagement_rules
|
|
158
|
+
get('/engagement-rules')
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Sets a standard rule (0 to 4) on an address. PUT /engagement-rule
|
|
162
|
+
def set_engagement_rule(c_from, rule_id)
|
|
163
|
+
require_value(c_from, 'c_from')
|
|
164
|
+
json_request(:put, '/engagement-rule', { c_from: c_from },
|
|
165
|
+
{ 'rule_id' => in_range(rule_id, 0, 4, 'rule_id') })
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# An address's custom rule, with can_update and remaining_seconds.
|
|
169
|
+
def custom_engagement(c_from)
|
|
170
|
+
require_value(c_from, 'c_from')
|
|
171
|
+
segment = c_from.to_s.strip
|
|
172
|
+
raise ArgumentError, 'c_from is invalid' if ['.', '..'].include?(segment)
|
|
173
|
+
|
|
174
|
+
get("/custom-engagement/#{ERB::Util.url_encode(segment)}")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Creates a custom rule. Starts a 7 day cooldown on updates.
|
|
178
|
+
def create_custom_engagement(c_from, reply_pct, important_pct, click_pct)
|
|
179
|
+
json_request(:post, '/custom-engagement', {},
|
|
180
|
+
engagement_body(c_from, reply_pct, important_pct, click_pct))
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Updates a custom rule. During the 7 day cooldown the API answers 429.
|
|
184
|
+
def update_custom_engagement(c_from, reply_pct, important_pct, click_pct)
|
|
185
|
+
json_request(:put, '/custom-engagement', {},
|
|
186
|
+
engagement_body(c_from, reply_pct, important_pct, click_pct))
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Deletes a custom rule and falls back to standard rule 1-4.
|
|
190
|
+
def delete_custom_engagement(c_from, engagement_rule_id)
|
|
191
|
+
require_value(c_from, 'c_from')
|
|
192
|
+
json_request(:delete, '/custom-engagement', {},
|
|
193
|
+
{ 'c_from' => c_from,
|
|
194
|
+
'engagement_rule_id' => in_range(engagement_rule_id, 1, 4, 'engagement_rule_id') })
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# -- statistics, seeds and utilities ------------------------------------
|
|
198
|
+
|
|
199
|
+
# The seed list. GET /seeds
|
|
200
|
+
def seeds
|
|
201
|
+
get('/seeds')
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# The seed list as CSV (columns seed,provider). GET /seeds/download
|
|
205
|
+
def download_seeds
|
|
206
|
+
csv('/seeds/download')
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Number of seeds. GET /seed-count
|
|
210
|
+
def seed_count
|
|
211
|
+
get('/seed-count')
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Today's warmup progress. GET /progress-today
|
|
215
|
+
def progress_today
|
|
216
|
+
get('/progress-today')
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Messages sent today. GET /sent-today
|
|
220
|
+
def sent_today
|
|
221
|
+
get('/sent-today')
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Current daily volume. GET /emails-per-day
|
|
225
|
+
def emails_per_day
|
|
226
|
+
get('/emails-per-day')
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Statistics for an address. An address with no history returns zeros.
|
|
230
|
+
def statistics(from_address)
|
|
231
|
+
require_value(from_address, 'from_address')
|
|
232
|
+
get('/statistics', from_address: from_address)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Daily statistics between two dates, inclusive. GET /statistics-per-day
|
|
236
|
+
def statistics_per_day(from_address, start_date, end_date)
|
|
237
|
+
require_value(from_address, 'from_address')
|
|
238
|
+
first = api_date(start_date, 'start_date')
|
|
239
|
+
last = api_date(end_date, 'end_date')
|
|
240
|
+
raise ArgumentError, 'start_date is later than end_date' if first > last
|
|
241
|
+
|
|
242
|
+
get('/statistics-per-day', from_address: from_address, start_date: first, end_date: last)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Detailed statistics for an address. GET /statistics-detailed
|
|
246
|
+
def statistics_detailed(c_from)
|
|
247
|
+
require_value(c_from, 'c_from')
|
|
248
|
+
get('/statistics-detailed', c_from: c_from)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# An address's statistics as CSV. GET /statistics/download
|
|
252
|
+
def download_statistics(c_from)
|
|
253
|
+
require_value(c_from, 'c_from')
|
|
254
|
+
csv('/statistics/download', c_from: c_from)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Statistics for one from-address. GET /from-address-statistics
|
|
258
|
+
def from_address_statistics(from_address)
|
|
259
|
+
require_value(from_address, 'from_address')
|
|
260
|
+
get('/from-address-statistics', from_address: from_address)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# SPF, DKIM and DMARC status of a from-address's domain.
|
|
264
|
+
# GET /check-domain-dmarc
|
|
265
|
+
#
|
|
266
|
+
# LIVE: pass an EMAIL ADDRESS. The live API answers "Please provide a valid
|
|
267
|
+
# email address" for a bare domain, although the docs describe a domain.
|
|
268
|
+
def check_domain_dmarc(from_address, dkim_selector: nil)
|
|
269
|
+
require_value(from_address, 'from_address')
|
|
270
|
+
unless from_address.to_s.include?('@')
|
|
271
|
+
raise ArgumentError, 'from_address must be an email address, e.g. sender@example.com'
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
params = { from_address: from_address }
|
|
275
|
+
unless dkim_selector.nil?
|
|
276
|
+
require_value(dkim_selector, 'dkim_selector')
|
|
277
|
+
params[:dkim_selector] = dkim_selector
|
|
278
|
+
end
|
|
279
|
+
get('/check-domain-dmarc', **params)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Checks that SMTP credentials connect and hand off a message.
|
|
283
|
+
# POST /test-smtp-configuration
|
|
284
|
+
#
|
|
285
|
+
# Nothing is stored, and the test message goes to a ZeroBounce test
|
|
286
|
+
# recipient, not +from_address+.
|
|
287
|
+
#
|
|
288
|
+
# LIVE: the API answers any failed SMTP login with HTTP 500. That is an API
|
|
289
|
+
# bug. The username and password are masked in every error from this method.
|
|
290
|
+
def test_smtp_configuration(from_address, username, password, server, port)
|
|
291
|
+
require_value(from_address, 'from_address')
|
|
292
|
+
require_value(username, 'username')
|
|
293
|
+
require_value(password, 'password')
|
|
294
|
+
require_value(server, 'server')
|
|
295
|
+
raise ArgumentError, 'port must be 1 to 65535' unless port.is_a?(Integer) && port.between?(1, 65_535)
|
|
296
|
+
|
|
297
|
+
json_request(:post, '/test-smtp-configuration', {},
|
|
298
|
+
{ 'from_address' => from_address,
|
|
299
|
+
'username' => username,
|
|
300
|
+
'password' => password,
|
|
301
|
+
'server' => server,
|
|
302
|
+
'port' => port },
|
|
303
|
+
secrets: [password, username])
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
private
|
|
307
|
+
|
|
308
|
+
attr_reader :api_key, :root
|
|
309
|
+
|
|
310
|
+
# -- argument checks ----------------------------------------------------
|
|
311
|
+
|
|
312
|
+
def require_value(value, name)
|
|
313
|
+
raise ArgumentError, "#{name} is required" if value.nil? || value.to_s.strip.empty?
|
|
314
|
+
|
|
315
|
+
value
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def in_range(value, low, high, name)
|
|
319
|
+
unless value.is_a?(Integer) && value.between?(low, high)
|
|
320
|
+
raise ArgumentError, "#{name} must be #{low} to #{high}"
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
value
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# A date for the API: a Date/Time or a YYYY-MM-DD string.
|
|
327
|
+
def api_date(value, name)
|
|
328
|
+
return value.strftime('%Y-%m-%d') if value.respond_to?(:strftime)
|
|
329
|
+
return value if value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
|
|
330
|
+
|
|
331
|
+
raise ArgumentError, "#{name} must be YYYY-MM-DD"
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def engagement_body(c_from, reply_pct, important_pct, click_pct)
|
|
335
|
+
require_value(c_from, 'c_from')
|
|
336
|
+
in_range(reply_pct, 5, 45, 'reply_pct')
|
|
337
|
+
in_range(important_pct, 5, 40, 'important_pct')
|
|
338
|
+
in_range(click_pct, 0, 70, 'click_pct')
|
|
339
|
+
if click_pct > important_pct * 1.75
|
|
340
|
+
raise ArgumentError, 'click_pct must be at most important_pct x 1.75'
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
{ 'c_from' => c_from, 'reply_pct' => reply_pct,
|
|
344
|
+
'important_pct' => important_pct, 'click_pct' => click_pct }
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def email_content_body(content)
|
|
348
|
+
raise ArgumentError, 'email_content must be a Hash' unless content.is_a?(Hash)
|
|
349
|
+
|
|
350
|
+
body = {}
|
|
351
|
+
types = content[:types_of_emails] || content['types_of_emails']
|
|
352
|
+
unless types.nil?
|
|
353
|
+
Array(types).each do |kind|
|
|
354
|
+
unless EMAIL_TYPES.include?(kind.to_s)
|
|
355
|
+
raise ArgumentError, 'types_of_emails must be transactional, newsletter and/or marketing'
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
body['types_of_emails'] = Array(types).map(&:to_s)
|
|
359
|
+
end
|
|
360
|
+
%i[industry company_name website].each do |key|
|
|
361
|
+
value = content[key] || content[key.to_s]
|
|
362
|
+
body[key.to_s] = value unless value.nil?
|
|
363
|
+
end
|
|
364
|
+
body
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def managed_sending_body(sending)
|
|
368
|
+
raise ArgumentError, 'managed_sending must be a Hash' unless sending.is_a?(Hash)
|
|
369
|
+
|
|
370
|
+
body = {}
|
|
371
|
+
offset = sending[:timezone_offset] || sending['timezone_offset']
|
|
372
|
+
body['timezone_offset'] = in_range(offset, -11, 14, 'timezone_offset') unless offset.nil?
|
|
373
|
+
start = sending[:warmup_start_date] || sending['warmup_start_date']
|
|
374
|
+
body['warmup_start_date'] = api_date(start, 'warmup_start_date') unless start.nil?
|
|
375
|
+
limit = sending[:daily_limit] || sending['daily_limit']
|
|
376
|
+
body['daily_limit'] = limit unless limit.nil?
|
|
377
|
+
body
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# -- transport ----------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
# Replaces every form of a secret in a message with ***.
|
|
383
|
+
def redact(text, secrets = [])
|
|
384
|
+
value = text.to_s
|
|
385
|
+
([api_key] + secrets).each do |secret|
|
|
386
|
+
next if secret.nil? || secret.to_s.empty?
|
|
387
|
+
|
|
388
|
+
value = value.gsub(secret.to_s, '***').gsub(ERB::Util.url_encode(secret.to_s), '***')
|
|
389
|
+
end
|
|
390
|
+
value
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def get(path, **params)
|
|
394
|
+
json_request(:get, path, params)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def send_request(method, path, params, body, secrets)
|
|
398
|
+
url = "#{root}#{path}"
|
|
399
|
+
options = {
|
|
400
|
+
method: method,
|
|
401
|
+
url: url,
|
|
402
|
+
timeout: 120,
|
|
403
|
+
max_redirects: 0,
|
|
404
|
+
headers: { 'API-Key' => api_key, accept: :json, params: params }
|
|
405
|
+
}
|
|
406
|
+
if body
|
|
407
|
+
options[:payload] = body.to_json
|
|
408
|
+
options[:headers][:content_type] = :json
|
|
409
|
+
end
|
|
410
|
+
RestClient::Request.execute(**options)
|
|
411
|
+
rescue RestClient::ExceptionWithResponse => e
|
|
412
|
+
response = e.response
|
|
413
|
+
status = response.respond_to?(:code) ? response.code : 0
|
|
414
|
+
if status >= 300 && status < 400
|
|
415
|
+
raise ApiResponseError.new(
|
|
416
|
+
'ZeroBounce returned a redirect, which this client does not follow.', status
|
|
417
|
+
)
|
|
418
|
+
end
|
|
419
|
+
raise ApiResponseError.new(
|
|
420
|
+
redact(
|
|
421
|
+
ResponseError.message_from_parts(
|
|
422
|
+
status, (response.headers[:content_type] || '').to_s, response.body.to_s
|
|
423
|
+
), secrets
|
|
424
|
+
), status
|
|
425
|
+
)
|
|
426
|
+
rescue ApiResponseError
|
|
427
|
+
raise
|
|
428
|
+
rescue StandardError => e
|
|
429
|
+
# Rebuilt rather than re-raised: the original holds the URL and headers.
|
|
430
|
+
raise e.class, redact(e.message, secrets)
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# The JSON payload of a success, or nil for the documented empty successes
|
|
434
|
+
# (204, and an empty 200).
|
|
435
|
+
def json_request(method, path, params = {}, body = nil, secrets: [])
|
|
436
|
+
response = send_request(method, path, params, body, secrets)
|
|
437
|
+
text = response.body.to_s
|
|
438
|
+
return nil if response.code == 204 || text.strip.empty?
|
|
439
|
+
|
|
440
|
+
begin
|
|
441
|
+
JSON.parse(text, quirks_mode: true)
|
|
442
|
+
rescue JSON::ParserError
|
|
443
|
+
raise ApiResponseError.new(
|
|
444
|
+
redact(
|
|
445
|
+
ResponseError.message_from_parts(
|
|
446
|
+
response.code, (response.headers[:content_type] || '').to_s, text
|
|
447
|
+
), secrets
|
|
448
|
+
), response.code
|
|
449
|
+
)
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# A CSV download, returned as text.
|
|
454
|
+
def csv(path, **params)
|
|
455
|
+
response = send_request(:get, path, params, nil, [])
|
|
456
|
+
text = response.body.to_s
|
|
457
|
+
content_type = (response.headers[:content_type] || '').to_s
|
|
458
|
+
if text.strip.empty? || content_type.downcase.include?('application/json')
|
|
459
|
+
raise ApiResponseError.new(
|
|
460
|
+
redact(ResponseError.message_from_parts(response.code, content_type, text)),
|
|
461
|
+
response.code
|
|
462
|
+
)
|
|
463
|
+
end
|
|
464
|
+
text
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
end
|
data/lib/zerobounce.rb
CHANGED
|
@@ -5,6 +5,8 @@ require 'date'
|
|
|
5
5
|
require 'tempfile'
|
|
6
6
|
|
|
7
7
|
require 'zerobounce/error'
|
|
8
|
+
require 'zerobounce/response_error'
|
|
9
|
+
require 'zerobounce/client'
|
|
8
10
|
require 'zerobounce/version'
|
|
9
11
|
require 'zerobounce/download_type'
|
|
10
12
|
require 'zerobounce/get_file_options'
|
|
@@ -15,11 +17,14 @@ require 'zerobounce/request'
|
|
|
15
17
|
require 'zerobounce/mock_request'
|
|
16
18
|
require 'zerobounce/configuration'
|
|
17
19
|
require 'zerobounce/api_urls'
|
|
20
|
+
require 'zerobounce/priority3'
|
|
21
|
+
require 'zerobounce/warmup'
|
|
18
22
|
|
|
19
23
|
# Validate an email address with Zerobounce.net
|
|
20
24
|
module Zerobounce
|
|
21
25
|
|
|
22
26
|
class << self
|
|
27
|
+
include Priority3
|
|
23
28
|
attr_writer :configuration
|
|
24
29
|
|
|
25
30
|
@@request = ENV['TEST']=='unit' ? MockRequest : Request
|
|
@@ -52,6 +57,15 @@ module Zerobounce
|
|
|
52
57
|
yield configuration
|
|
53
58
|
end
|
|
54
59
|
|
|
60
|
+
# Run a block with an API key that is visible only on this thread.
|
|
61
|
+
def with_api_key(api_key)
|
|
62
|
+
previous = Thread.current[:zerobounce_api_key]
|
|
63
|
+
Thread.current[:zerobounce_api_key] = api_key
|
|
64
|
+
yield
|
|
65
|
+
ensure
|
|
66
|
+
Thread.current[:zerobounce_api_key] = previous
|
|
67
|
+
end
|
|
68
|
+
|
|
55
69
|
# Validates the email address and gets geoip information for an IP if provided.
|
|
56
70
|
#
|
|
57
71
|
# @param [String] :email The email address to validate.
|
|
@@ -80,9 +94,43 @@ module Zerobounce
|
|
|
80
94
|
# "zipcode": null,
|
|
81
95
|
# "processed_at": "2023-04-18 12:09:39.074"
|
|
82
96
|
# }
|
|
83
|
-
def validate(email, ip_address=nil
|
|
97
|
+
def validate(email, ip_address=nil, timeout: nil, activity_data: nil, verify_plus: nil,
|
|
98
|
+
domain_info: nil, greylist_processing: nil, callback_url: nil, use_post: false)
|
|
99
|
+
unless timeout.nil?
|
|
100
|
+
timeout_i = Integer(timeout)
|
|
101
|
+
if timeout_i < 3 || timeout_i > 60
|
|
102
|
+
raise ArgumentError, 'Invalid parameter: timeout (must be between 3 and 60 seconds)'
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
unless callback_url.nil?
|
|
106
|
+
raise ArgumentError, 'Empty parameter: callback_url' if callback_url.to_s.strip.empty?
|
|
107
|
+
|
|
108
|
+
# The callback is where the greylist retry posts its result, so it is
|
|
109
|
+
# meaningless - and rejected by the API - without the retry itself.
|
|
110
|
+
unless greylist_processing
|
|
111
|
+
raise ArgumentError, 'callback_url requires greylist_processing to be true'
|
|
112
|
+
end
|
|
113
|
+
end
|
|
84
114
|
params = {email: email, ip_address: ip_address}
|
|
85
|
-
|
|
115
|
+
params[:timeout] = timeout unless timeout.nil?
|
|
116
|
+
unless activity_data.nil?
|
|
117
|
+
params[:activity_data] = activity_data ? 'true' : 'false'
|
|
118
|
+
end
|
|
119
|
+
unless verify_plus.nil?
|
|
120
|
+
params[:verify_plus] = verify_plus ? 'true' : 'false'
|
|
121
|
+
end
|
|
122
|
+
unless domain_info.nil?
|
|
123
|
+
params[:domain_info] = domain_info ? 'true' : 'false'
|
|
124
|
+
end
|
|
125
|
+
unless greylist_processing.nil?
|
|
126
|
+
params[:greylist_processing] = greylist_processing ? 'true' : 'false'
|
|
127
|
+
end
|
|
128
|
+
params[:callback_url] = callback_url unless callback_url.nil?
|
|
129
|
+
if use_post
|
|
130
|
+
@@request.post('validate', params, 'application/x-www-form-urlencoded')
|
|
131
|
+
else
|
|
132
|
+
@@request.get('validate', params)
|
|
133
|
+
end
|
|
86
134
|
end
|
|
87
135
|
|
|
88
136
|
# Get the number of remaining credits on the account.
|
|
@@ -183,13 +231,33 @@ module Zerobounce
|
|
|
183
231
|
@@request.get('activity', params)
|
|
184
232
|
end
|
|
185
233
|
|
|
234
|
+
# Real-time AI scoring for a single email (GET /scoring by default).
|
|
235
|
+
#
|
|
236
|
+
# @param [String] :email Email to score
|
|
237
|
+
# @param [Boolean] :use_post When true, uses POST /scoring with form body
|
|
238
|
+
#
|
|
239
|
+
# @return [Hash] e.g. {"email"=>"mark@gmail.com", "score"=>10}
|
|
240
|
+
def score_email(email, use_post: false)
|
|
241
|
+
params = { email: email }
|
|
242
|
+
if use_post
|
|
243
|
+
@@request.post('scoring', params, 'application/x-www-form-urlencoded')
|
|
244
|
+
else
|
|
245
|
+
@@request.get('scoring', params)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
186
249
|
# Validate email batch
|
|
187
250
|
#
|
|
188
251
|
# @param [Array] :emails List of email addresses to validate.
|
|
189
252
|
# @option [Array] :ip_addresses Corresponding list of IP addresses.
|
|
190
253
|
#
|
|
191
|
-
# @return [
|
|
192
|
-
#
|
|
254
|
+
# @return [Hash] the whole response: +email_batch+ holds one validate
|
|
255
|
+
# result per address, +errors+ the addresses that could not be
|
|
256
|
+
# validated. A per-address error does not raise; an account-level one
|
|
257
|
+
# (reported for the address "all", e.g. an invalid key) does.
|
|
258
|
+
# Up to 2.1.8 this returned only the +email_batch+ Array.
|
|
259
|
+
# {
|
|
260
|
+
# "email_batch": [
|
|
193
261
|
# {
|
|
194
262
|
# "address": "disposable@example.com",
|
|
195
263
|
# "status": "do_not_mail",
|
|
@@ -216,8 +284,16 @@ module Zerobounce
|
|
|
216
284
|
# ...
|
|
217
285
|
# },
|
|
218
286
|
# ...
|
|
219
|
-
#
|
|
220
|
-
|
|
287
|
+
# ],
|
|
288
|
+
# "errors": []
|
|
289
|
+
# }
|
|
290
|
+
def validate_batch(emails, ip_addresses=[], timeout: nil, activity_data: nil, verify_plus: nil)
|
|
291
|
+
unless timeout.nil?
|
|
292
|
+
timeout_i = Integer(timeout)
|
|
293
|
+
if timeout_i < 10 || timeout_i > 120
|
|
294
|
+
raise ArgumentError, 'Invalid parameter: timeout (must be between 10 and 120 seconds)'
|
|
295
|
+
end
|
|
296
|
+
end
|
|
221
297
|
|
|
222
298
|
raise ArgumentError.new if emails.class != Array
|
|
223
299
|
emails.each do |email|
|
|
@@ -232,8 +308,14 @@ module Zerobounce
|
|
|
232
308
|
})
|
|
233
309
|
end
|
|
234
310
|
params = {email_batch: email_batch}
|
|
311
|
+
# LIVE: validatebatch takes timeout as a JSON *string*. Sent as a number
|
|
312
|
+
# the API rejects the whole batch with the account-level "Invalid API Key
|
|
313
|
+
# or your account ran out of credits".
|
|
314
|
+
params[:timeout] = timeout.to_s unless timeout.nil?
|
|
315
|
+
params[:activity_data] = activity_data unless activity_data.nil?
|
|
316
|
+
params[:verify_plus] = verify_plus unless verify_plus.nil?
|
|
235
317
|
results = @@request.post('validatebatch', params)
|
|
236
|
-
|
|
318
|
+
results
|
|
237
319
|
end
|
|
238
320
|
|
|
239
321
|
# Validate CSV file
|
|
@@ -267,10 +349,10 @@ module Zerobounce
|
|
|
267
349
|
email_address_column: email_address_column,
|
|
268
350
|
has_header_row: has_header_row,
|
|
269
351
|
}
|
|
270
|
-
params[:first_name_column] = first_name_column
|
|
271
|
-
params[:last_name_column] = last_name_column
|
|
272
|
-
params[:gender_column] = gender_column
|
|
273
|
-
params[:return_url] = return_url
|
|
352
|
+
params[:first_name_column] = first_name_column unless first_name_column.nil?
|
|
353
|
+
params[:last_name_column] = last_name_column unless last_name_column.nil?
|
|
354
|
+
params[:gender_column] = gender_column unless gender_column.nil?
|
|
355
|
+
params[:return_url] = return_url unless return_url.nil? || return_url.to_s.empty?
|
|
274
356
|
unless allow_phase_2.nil?
|
|
275
357
|
params[:allow_phase_2] = allow_phase_2 ? 'true' : 'false'
|
|
276
358
|
end
|
|
@@ -513,7 +595,8 @@ module Zerobounce
|
|
|
513
595
|
#
|
|
514
596
|
# @option [String] domain Domain to search within (e.g. example.com). Required if company_name is not provided.
|
|
515
597
|
# @option [String] company_name Company name to search within (e.g. Example). Required if domain is not provided.
|
|
516
|
-
# @
|
|
598
|
+
# @param [String] first_name First name of target. Optional: left out, the
|
|
599
|
+
# call answers with the domain's format rather than one person's address.
|
|
517
600
|
# @option [String] middle_name Middle name of target.
|
|
518
601
|
# @option [String] last_name Last name of target.
|
|
519
602
|
#
|
|
@@ -526,7 +609,10 @@ module Zerobounce
|
|
|
526
609
|
# "did_you_mean": "",
|
|
527
610
|
# "failure_reason": ""
|
|
528
611
|
# }
|
|
529
|
-
|
|
612
|
+
# first_name is optional: /guessformat only requires a domain or a company
|
|
613
|
+
# name, and answers the domain's format when no name is given. It stays the
|
|
614
|
+
# first positional argument, so every existing call keeps working.
|
|
615
|
+
def find_email(first_name = nil, domain: '', company_name: '', middle_name: '', last_name: '')
|
|
530
616
|
# Validate that exactly one of domain or company_name is provided
|
|
531
617
|
if (domain.nil? || domain.empty?) && (company_name.nil? || company_name.empty?)
|
|
532
618
|
raise ArgumentError, "Either domain or company_name must be provided"
|
|
@@ -534,7 +620,8 @@ module Zerobounce
|
|
|
534
620
|
raise ArgumentError, "Only one of domain or company_name can be provided"
|
|
535
621
|
end
|
|
536
622
|
|
|
537
|
-
params = {
|
|
623
|
+
params = {}
|
|
624
|
+
params[:first_name] = first_name unless first_name.nil? || first_name.to_s.empty?
|
|
538
625
|
params[:domain] = domain unless domain.nil? || domain.empty?
|
|
539
626
|
params[:company_name] = company_name unless company_name.nil? || company_name.empty?
|
|
540
627
|
params[:middle_name] = middle_name unless middle_name.nil? || middle_name.empty?
|