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