zerobounce-sdk 2.1.8 → 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,331 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'erb'
|
|
4
|
+
|
|
5
|
+
module Zerobounce
|
|
6
|
+
module Priority3
|
|
7
|
+
EMAIL_FINDER_BULK_BASE = 'email-finder'
|
|
8
|
+
DOMAIN_SEARCH_BULK_BASE = 'domain-search'
|
|
9
|
+
|
|
10
|
+
# Inbox Placement Tester bounds, as documented and as the API enforces them.
|
|
11
|
+
INBOX_PLACEMENT_NAME_MAX = 64
|
|
12
|
+
INBOX_PLACEMENT_TEST_TYPES = [1, 2, 3].freeze
|
|
13
|
+
INBOX_PLACEMENT_PAGE_SIZE_MIN = 1
|
|
14
|
+
INBOX_PLACEMENT_PAGE_SIZE_MAX = 100
|
|
15
|
+
|
|
16
|
+
def zb_request
|
|
17
|
+
Zerobounce.class_variable_get(:@@request)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def send_email_finder_file(filepath, domain_column:, first_name_column: nil, last_name_column: nil,
|
|
21
|
+
middle_name_column: nil, full_name_column: nil, has_header_row: nil)
|
|
22
|
+
raise ArgumentError, 'domain_column is required' if domain_column.nil? || domain_column.to_i < 1
|
|
23
|
+
# One of the two name columns tells the finder what to look for; with
|
|
24
|
+
# neither there is nothing to build a local part from.
|
|
25
|
+
if __blank__(first_name_column) && __blank__(full_name_column)
|
|
26
|
+
raise ArgumentError, 'first_name_column or full_name_column is required'
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
params = { domain_column: domain_column }
|
|
30
|
+
params[:has_header_row] = has_header_row ? 'true' : 'false' unless has_header_row.nil?
|
|
31
|
+
params[:first_name_column] = first_name_column unless first_name_column.nil?
|
|
32
|
+
params[:last_name_column] = last_name_column unless last_name_column.nil?
|
|
33
|
+
params[:middle_name_column] = middle_name_column unless middle_name_column.nil?
|
|
34
|
+
params[:full_name_column] = full_name_column unless full_name_column.nil?
|
|
35
|
+
zb_request.bulk_root_post("#{EMAIL_FINDER_BULK_BASE}/sendfile", params, 'multipart/form-data', filepath)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def email_finder_file_status(file_id)
|
|
39
|
+
zb_request.bulk_root_get("#{EMAIL_FINDER_BULK_BASE}/filestatus", { file_id: file_id })
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def email_finder_file_get(file_id)
|
|
43
|
+
zb_request.bulk_root_getfile("#{EMAIL_FINDER_BULK_BASE}/getfile", { file_id: file_id })
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def email_finder_file_delete(file_id)
|
|
47
|
+
zb_request.bulk_root_get("#{EMAIL_FINDER_BULK_BASE}/deletefile", { file_id: file_id })
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def send_domain_search_file(filepath, domain_column:, has_header_row: nil)
|
|
51
|
+
raise ArgumentError, 'domain_column is required' if domain_column.nil? || domain_column.to_i < 1
|
|
52
|
+
|
|
53
|
+
params = { domain_column: domain_column }
|
|
54
|
+
params[:has_header_row] = has_header_row ? 'true' : 'false' unless has_header_row.nil?
|
|
55
|
+
zb_request.bulk_root_post("#{DOMAIN_SEARCH_BULK_BASE}/sendfile", params, 'multipart/form-data', filepath)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def domain_search_file_status(file_id)
|
|
59
|
+
zb_request.bulk_root_get("#{DOMAIN_SEARCH_BULK_BASE}/filestatus", { file_id: file_id })
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def domain_search_file_get(file_id)
|
|
63
|
+
zb_request.bulk_root_getfile("#{DOMAIN_SEARCH_BULK_BASE}/getfile", { file_id: file_id })
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def domain_search_file_delete(file_id)
|
|
67
|
+
zb_request.bulk_root_get("#{DOMAIN_SEARCH_BULK_BASE}/deletefile", { file_id: file_id })
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def send_list_evaluator_file(filepath, email_address_column:)
|
|
71
|
+
raise ArgumentError, 'email_address_column is required' if email_address_column.nil? || email_address_column.to_i < 1
|
|
72
|
+
|
|
73
|
+
params = { email_address_column: email_address_column }
|
|
74
|
+
zb_request.bulk_post('listevaluator/', params, 'multipart/form-data', filepath)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def get_list_evaluator_result(file_id)
|
|
78
|
+
raise ArgumentError, 'file_id is required' if file_id.nil? || file_id.to_s.strip.empty?
|
|
79
|
+
|
|
80
|
+
# The list evaluator has its own base URL, not a path built from the bulk
|
|
81
|
+
# one, and the file id is one path segment, so a slash must stay encoded.
|
|
82
|
+
zb_request.root_request(
|
|
83
|
+
:get,
|
|
84
|
+
Zerobounce.configuration.list_evaluator_root_url,
|
|
85
|
+
"#{ERB::Util.url_encode(file_id.to_s.strip)}/",
|
|
86
|
+
{}
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Submits a finished evaluation for validation. POST /v2/send-evaluated-file/
|
|
91
|
+
#
|
|
92
|
+
# LIVE: this takes the list evaluator's file_id as a form field, not the
|
|
93
|
+
# file upload the docs describe - an upload is rejected with
|
|
94
|
+
# "file_id: This field is required". It returns a new validation job's
|
|
95
|
+
# file_id, which answers "File cannot be found" for a short while after it
|
|
96
|
+
# is accepted.
|
|
97
|
+
def send_evaluated_file(file_id)
|
|
98
|
+
raise ArgumentError, 'file_id is required' if file_id.nil? || file_id.to_s.strip.empty?
|
|
99
|
+
|
|
100
|
+
zb_request.bulk_post('send-evaluated-file/', { file_id: file_id.to_s.strip },
|
|
101
|
+
'application/x-www-form-urlencoded')
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def add_filter(rule:, target:, value:)
|
|
105
|
+
zb_request.post('filters/add', { rule: rule, target: target, value: value }, 'application/x-www-form-urlencoded')
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def list_filters(use_post: false)
|
|
109
|
+
if use_post
|
|
110
|
+
zb_request.post('filters/list', {}, 'application/x-www-form-urlencoded')
|
|
111
|
+
else
|
|
112
|
+
zb_request.get('filters/list', {})
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def delete_filter(rule:, target:, value:)
|
|
117
|
+
zb_request.post('filters/delete', { rule: rule, target: target, value: value }, 'application/x-www-form-urlencoded')
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def list_s3_files(bucket: nil, search: nil)
|
|
121
|
+
params = {}
|
|
122
|
+
params[:bucket] = bucket unless bucket.nil? || bucket.to_s.empty?
|
|
123
|
+
params[:search] = search unless search.nil? || search.to_s.empty?
|
|
124
|
+
# The S3 listing lives on the bulkapi ROOT, like the jobs below it, not
|
|
125
|
+
# under /v2 - bulk_get would send it to /v2/listS3files, which is a 404.
|
|
126
|
+
zb_request.bulk_root_get('listS3files', params)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# The same job shape as the four below, so it goes through the same helper:
|
|
130
|
+
# LIVE, the API rejects it without file_name and return_url.
|
|
131
|
+
def validate_s3_file(file_name:, has_header_row: false, **options)
|
|
132
|
+
bulk_root_file_job('validateS3file', file_name, has_header_row, **options)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def score_s3_file(file_name:, has_header_row: false, **options)
|
|
136
|
+
bulk_root_file_job('scoreS3file', file_name, has_header_row, **options)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# export_bucket is optional: omit it and the export goes to the bucket in
|
|
140
|
+
# your ZeroBounce settings, the way export_sftp_file's export_path does.
|
|
141
|
+
def export_s3_file(file_id:, export_bucket: nil)
|
|
142
|
+
raise ArgumentError, 'file_id is required' if file_id.nil? || file_id.to_s.strip.empty?
|
|
143
|
+
|
|
144
|
+
if !export_bucket.nil? && export_bucket.to_s.strip.empty?
|
|
145
|
+
raise ArgumentError, 'export_bucket is required'
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
params = { file_id: file_id }
|
|
149
|
+
params[:export_bucket] = export_bucket unless export_bucket.nil?
|
|
150
|
+
zb_request.bulk_root_form_post('exportS3file', params)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The rest of the documented bulk surface.
|
|
154
|
+
#
|
|
155
|
+
# https://www.zerobounce.net/docs/api-integrations/amazon-s3-bulk-api
|
|
156
|
+
# https://www.zerobounce.net/docs/api-integrations/sftp_bulk_api
|
|
157
|
+
#
|
|
158
|
+
# The SFTP server, path and protocol are never sent: they come from your
|
|
159
|
+
# ZeroBounce settings.
|
|
160
|
+
|
|
161
|
+
def email_finder_s3_file(file_name, has_header_row, **options)
|
|
162
|
+
bulk_root_file_job('emailFinderS3file', file_name, has_header_row, **options)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def domain_search_s3_file(file_name, has_header_row, **options)
|
|
166
|
+
bulk_root_file_job('domainSearchS3file', file_name, has_header_row, **options)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# LIVE: file_name is required here too, although the docs list it as
|
|
170
|
+
# optional for this one endpoint.
|
|
171
|
+
def validate_sftp_file(file_name, has_header_row, **options)
|
|
172
|
+
bulk_root_file_job('validateSFTPfile', file_name, has_header_row, **options)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def score_sftp_file(file_name, has_header_row, **options)
|
|
176
|
+
bulk_root_file_job('scoreSFTPfile', file_name, has_header_row, **options)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def email_finder_sftp_file(file_name, has_header_row, **options)
|
|
180
|
+
bulk_root_file_job('emailFinderSFTPfile', file_name, has_header_row, **options)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def domain_search_sftp_file(file_name, has_header_row, **options)
|
|
184
|
+
bulk_root_file_job('domainSearchSFTPfile', file_name, has_header_row, **options)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Note the field name: this endpoint takes export_path, not the
|
|
188
|
+
# export_bucket that export_s3_file sends. Omit it to use your settings.
|
|
189
|
+
def export_sftp_file(file_id:, export_path: nil)
|
|
190
|
+
raise ArgumentError, 'file_id is required' if file_id.nil? || file_id.to_s.strip.empty?
|
|
191
|
+
|
|
192
|
+
if !export_path.nil? && export_path.to_s.strip.empty?
|
|
193
|
+
raise ArgumentError, 'export_path is required'
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
params = { file_id: file_id }
|
|
197
|
+
params[:export_path] = export_path unless export_path.nil?
|
|
198
|
+
zb_request.bulk_root_form_post('exportSFTPfile', params)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Inbox Placement Tester (/api/ipt).
|
|
202
|
+
#
|
|
203
|
+
# Its own host, an Authorization: Bearer header, camelCase JSON bodies, and
|
|
204
|
+
# errors under a "detail" key. Spends the account's inbox-test credits.
|
|
205
|
+
|
|
206
|
+
def inbox_placement_providers
|
|
207
|
+
zb_request.ipt_request(:get, 'providers')
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def inbox_placement_test_types
|
|
211
|
+
zb_request.ipt_request(:get, 'test-types')
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# name is capped at 64 characters, testType is 1, 2 or 3, and a testType of
|
|
215
|
+
# 1 (the seed-list test) has nowhere to send from without senderEmail. All
|
|
216
|
+
# three are rejected by the API, so they are caught before a request that
|
|
217
|
+
# would spend inbox-test credits.
|
|
218
|
+
def create_inbox_placement_test(name, **options)
|
|
219
|
+
raise ArgumentError, 'name is required' if name.nil? || name.to_s.strip.empty?
|
|
220
|
+
raise ArgumentError, 'name must be 64 characters or fewer' if name.to_s.strip.length > INBOX_PLACEMENT_NAME_MAX
|
|
221
|
+
|
|
222
|
+
if options.key?(:test_type)
|
|
223
|
+
test_type = __inbox_placement_test_type__(options[:test_type])
|
|
224
|
+
if test_type == 1 && __blank__(options[:sender_email])
|
|
225
|
+
raise ArgumentError, 'sender_email is required when test_type is 1'
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
body = { 'name' => name }
|
|
230
|
+
{
|
|
231
|
+
test_type: 'testType',
|
|
232
|
+
sender_email: 'senderEmail',
|
|
233
|
+
provider_ids: 'providerIds',
|
|
234
|
+
seed_ids: 'seedIds',
|
|
235
|
+
providers: 'providers',
|
|
236
|
+
webhook_url: 'webhookUrl'
|
|
237
|
+
}.each { |key, wire| body[wire] = options[key] if options.key?(key) }
|
|
238
|
+
|
|
239
|
+
zb_request.ipt_request(:post, 'test', {}, json: body)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def inbox_placement_test(test_id)
|
|
243
|
+
zb_request.ipt_request(:get, "test/#{inbox_placement_test_segment(test_id)}")
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def list_inbox_placement_tests(**options)
|
|
247
|
+
params = {}
|
|
248
|
+
params[:status] = options[:status] if options.key?(:status)
|
|
249
|
+
params[:createdAfter] = options[:created_after] if options.key?(:created_after)
|
|
250
|
+
params[:createdBefore] = options[:created_before] if options.key?(:created_before)
|
|
251
|
+
params[:page] = options[:page] if options.key?(:page)
|
|
252
|
+
if options.key?(:page_size)
|
|
253
|
+
page_size = options[:page_size]
|
|
254
|
+
unless (INBOX_PLACEMENT_PAGE_SIZE_MIN..INBOX_PLACEMENT_PAGE_SIZE_MAX).cover?(page_size.to_i)
|
|
255
|
+
raise ArgumentError,
|
|
256
|
+
"page_size must be between #{INBOX_PLACEMENT_PAGE_SIZE_MIN} and #{INBOX_PLACEMENT_PAGE_SIZE_MAX}"
|
|
257
|
+
end
|
|
258
|
+
# LIVE: the page size parameter is page_size, not the camelCase
|
|
259
|
+
# pageSize the rest of this API uses. page_size=1 paginates; pageSize=1
|
|
260
|
+
# is ignored and the whole list comes back.
|
|
261
|
+
params[:page_size] = page_size
|
|
262
|
+
end
|
|
263
|
+
# LIVE: the listing is GET /tests (plural). /test is the single-test path
|
|
264
|
+
# and answers 'Method "GET" not allowed'.
|
|
265
|
+
zb_request.ipt_request(:get, 'tests', params)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def delete_inbox_placement_test(test_id)
|
|
269
|
+
zb_request.ipt_request(:delete, "test/#{inbox_placement_test_segment(test_id)}")
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def inbox_placement_webhook
|
|
273
|
+
zb_request.ipt_request(:get, 'webhook')
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def set_inbox_placement_webhook(webhook_url)
|
|
277
|
+
raise ArgumentError, 'webhook_url is required' if webhook_url.nil? || webhook_url.to_s.strip.empty?
|
|
278
|
+
|
|
279
|
+
zb_request.ipt_request(:put, 'webhook', {}, json: { 'webhookUrl' => webhook_url })
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
private
|
|
283
|
+
|
|
284
|
+
# Nil, or a value that is nothing but whitespace. A column index of 0 is
|
|
285
|
+
# not blank: the API accepts it and the caller meant the first column.
|
|
286
|
+
def __blank__(value)
|
|
287
|
+
value.nil? || value.to_s.strip.empty?
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def __inbox_placement_test_type__(value)
|
|
291
|
+
test_type = value.is_a?(Integer) ? value : value.to_s.strip.to_i
|
|
292
|
+
unless INBOX_PLACEMENT_TEST_TYPES.include?(test_type)
|
|
293
|
+
raise ArgumentError, "test_type must be one of #{INBOX_PLACEMENT_TEST_TYPES.join(', ')}"
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
test_type
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# POSTs one of the eight documented S3 / SFTP file jobs on the bulkapi root.
|
|
300
|
+
#
|
|
301
|
+
# Booleans go on the wire as the strings 'true'/'false'.
|
|
302
|
+
#
|
|
303
|
+
# LIVE: file_name and return_url are required on every one of these jobs,
|
|
304
|
+
# validateS3file, scoreS3file and validateSFTPfile included, although the
|
|
305
|
+
# docs list both as optional. Without either the API answers "This field is
|
|
306
|
+
# required".
|
|
307
|
+
def bulk_root_file_job(path, file_name, has_header_row, **options)
|
|
308
|
+
raise ArgumentError, 'file_name is required' if __blank__(file_name)
|
|
309
|
+
raise ArgumentError, 'return_url is required' if __blank__(options[:return_url])
|
|
310
|
+
|
|
311
|
+
return_url = options[:return_url]
|
|
312
|
+
|
|
313
|
+
params = {
|
|
314
|
+
has_header_row: has_header_row ? 'true' : 'false',
|
|
315
|
+
file_name: file_name,
|
|
316
|
+
return_url: return_url
|
|
317
|
+
}
|
|
318
|
+
if options.key?(:remove_duplicates)
|
|
319
|
+
params[:remove_duplicates] = options[:remove_duplicates] ? 'true' : 'false'
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
zb_request.bulk_root_form_post(path, params)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def inbox_placement_test_segment(test_id)
|
|
326
|
+
raise ArgumentError, 'test_id is required' if test_id.nil? || test_id.to_s.strip.empty?
|
|
327
|
+
|
|
328
|
+
ERB::Util.url_encode(test_id.to_s.strip)
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
data/lib/zerobounce/request.rb
CHANGED
|
@@ -9,39 +9,33 @@ module Zerobounce
|
|
|
9
9
|
# Sends the HTTP request.
|
|
10
10
|
class Request < BaseRequest
|
|
11
11
|
|
|
12
|
+
# LIVE: these answer a success with a bare {"Message": "..."} body, which
|
|
13
|
+
# everywhere else means the request was rejected.
|
|
14
|
+
FILTER_PATHS = %w[filters/add filters/delete filters/list].freeze
|
|
15
|
+
|
|
12
16
|
def self.get(path, params, content_type='application/json')
|
|
13
17
|
response = self._get(Zerobounce.configuration.api_root_url, path, params, content_type)
|
|
14
|
-
|
|
15
|
-
response_body = response.body
|
|
16
|
-
response_body_json = JSON.parse(response_body)
|
|
17
|
-
|
|
18
|
-
raise (response_body_json['error']) if response_body_json.key?('error')
|
|
19
|
-
raise (response_body_json['Message']) \
|
|
20
|
-
if response_body_json.key?('Message') and \
|
|
21
|
-
response_body_json.length == 1
|
|
22
|
-
raise (response_body_json['errors'][0]['error']) \
|
|
23
|
-
if response_body_json.key?('errors') and \
|
|
24
|
-
response_body_json['errors'].length > 0
|
|
25
|
-
|
|
26
|
-
return response_body_json
|
|
27
|
-
else
|
|
28
|
-
return response
|
|
29
|
-
end
|
|
18
|
+
interpret_json_response(response, path)
|
|
30
19
|
end
|
|
31
20
|
|
|
32
21
|
def self.bulk_get(path, params, content_type='application/json')
|
|
33
22
|
response = self._get(Zerobounce.configuration.bulk_api_root_url, path, params, content_type)
|
|
34
|
-
if response.headers[:content_type]
|
|
23
|
+
if __json_content_type__(response.headers[:content_type])
|
|
35
24
|
response_body = response.body
|
|
36
25
|
response_body_json = JSON.parse(response_body)
|
|
26
|
+
return response_body_json unless response_body_json.is_a?(Hash)
|
|
37
27
|
|
|
38
28
|
raise (response_body_json['error']) if response_body_json.key?('error')
|
|
39
29
|
raise (response_body_json['errors'][0]['error']) \
|
|
40
30
|
if response_body_json.key?('errors') and \
|
|
41
31
|
response_body_json['errors'].length > 0
|
|
32
|
+
__raise_if_success_false__(response, response_body_json)
|
|
42
33
|
|
|
43
34
|
return response_body_json
|
|
44
35
|
else
|
|
36
|
+
if ResponseError.html?(response.body, response.headers[:content_type])
|
|
37
|
+
raise ResponseError.message_from(response)
|
|
38
|
+
end
|
|
45
39
|
content = StringIO.new(response.body)
|
|
46
40
|
content.set_encoding_by_bom
|
|
47
41
|
return content.string
|
|
@@ -57,35 +51,80 @@ module Zerobounce
|
|
|
57
51
|
def self.post(path, params, content_type='application/json', filepath=nil)
|
|
58
52
|
response = self._post(Zerobounce.configuration.api_root_url, path, params, \
|
|
59
53
|
content_type, filepath)
|
|
60
|
-
|
|
61
|
-
response_body = response.body
|
|
62
|
-
response_body_json = JSON.parse(response_body)
|
|
63
|
-
|
|
64
|
-
raise (response_body_json['error']) if response_body_json.key?('error')
|
|
65
|
-
raise (response_body_json['errors'][0]['error']) \
|
|
66
|
-
if response_body_json.key?('errors') and \
|
|
67
|
-
response_body_json['errors'].length > 0
|
|
68
|
-
|
|
69
|
-
return response_body_json
|
|
70
|
-
end
|
|
71
|
-
return response.body
|
|
54
|
+
interpret_json_response(response, path)
|
|
72
55
|
end
|
|
73
56
|
|
|
74
57
|
def self.bulk_post(path, params, content_type='application/json', filepath=nil)
|
|
75
58
|
response = self._post(Zerobounce.configuration.bulk_api_root_url, path, params, \
|
|
76
59
|
content_type, filepath)
|
|
77
|
-
if response.headers[:content_type]
|
|
78
|
-
|
|
79
|
-
|
|
60
|
+
if __json_content_type__(response.headers[:content_type])
|
|
61
|
+
return __parse_json_response__(response)
|
|
62
|
+
end
|
|
63
|
+
return response.body
|
|
64
|
+
end
|
|
80
65
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
66
|
+
def self.bulk_root_get(path, params, content_type='application/json')
|
|
67
|
+
response = self._get(bulk_api_root_without_v2, path, params, content_type)
|
|
68
|
+
if __json_content_type__(response.headers[:content_type])
|
|
69
|
+
return __parse_json_response__(response)
|
|
70
|
+
end
|
|
71
|
+
response.body
|
|
72
|
+
end
|
|
85
73
|
|
|
86
|
-
|
|
74
|
+
def self.bulk_root_getfile(path, params)
|
|
75
|
+
response = self._get(bulk_api_root_without_v2, path, params, 'application/json')
|
|
76
|
+
GetFileHelper.process_getfile_response(response)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.bulk_root_post(path, params, content_type='application/json', filepath=nil)
|
|
80
|
+
response = self._post(bulk_api_root_without_v2, path, params, content_type, filepath)
|
|
81
|
+
if __json_content_type__(response.headers[:content_type])
|
|
82
|
+
return __parse_json_response__(response)
|
|
87
83
|
end
|
|
88
|
-
|
|
84
|
+
response.body
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.bulk_root_form_post(path, params)
|
|
88
|
+
response = self._post(bulk_api_root_without_v2, path, params, 'application/x-www-form-urlencoded')
|
|
89
|
+
__parse_json_response__(response)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.interpret_json_response(response, path = nil)
|
|
93
|
+
body = response.body.to_s
|
|
94
|
+
content_type = (response.headers[:content_type] || '').to_s
|
|
95
|
+
if ResponseError.html?(body, content_type) || body.strip.empty? ||
|
|
96
|
+
(!content_type.include?('json') && !body.strip.start_with?('{', '['))
|
|
97
|
+
raise ResponseError.message_from_parts(response.code, content_type, body)
|
|
98
|
+
end
|
|
99
|
+
begin
|
|
100
|
+
parsed = JSON.parse(body)
|
|
101
|
+
rescue JSON::ParserError
|
|
102
|
+
raise 'ZeroBounce returned a response that is not valid JSON.'
|
|
103
|
+
end
|
|
104
|
+
return parsed unless parsed.is_a?(Hash)
|
|
105
|
+
|
|
106
|
+
raise ResponseError.strip_prefix(parsed['error']) if parsed.key?('error') && !parsed['error'].nil?
|
|
107
|
+
__raise_if_success_false__(response, parsed)
|
|
108
|
+
# LIVE: the filters spell it "Error". An invalid or drained key answers
|
|
109
|
+
# them with {"Error": "Invalid API key or your account ran out of
|
|
110
|
+
# credits"} on an HTTP 200, which used to come back as their result.
|
|
111
|
+
if !parsed.key?('success') && parsed['Error'].is_a?(String) && !parsed['Error'].strip.empty?
|
|
112
|
+
raise ResponseError.strip_prefix(parsed['Error'])
|
|
113
|
+
end
|
|
114
|
+
# A body that is nothing but "Message" is how the validate endpoints
|
|
115
|
+
# report a rejected request. The filters endpoints use the same shape for
|
|
116
|
+
# a *success* ("Filter successfully added"), so they are left out.
|
|
117
|
+
if parsed.key?('Message') && parsed.length == 1 && !FILTER_PATHS.include?(path)
|
|
118
|
+
raise ResponseError.strip_prefix(parsed['Message'])
|
|
119
|
+
end
|
|
120
|
+
if parsed.key?('errors') && parsed['errors'].is_a?(Array) && !parsed['errors'].empty?
|
|
121
|
+
account = parsed['errors'].find { |item| item.is_a?(Hash) && item['email_address'].to_s.casecmp('all').zero? }
|
|
122
|
+
if account
|
|
123
|
+
raise ResponseError.strip_prefix(account['error'])
|
|
124
|
+
end
|
|
125
|
+
raise ResponseError.strip_prefix(parsed['errors'][0]['error']) unless path == 'validatebatch'
|
|
126
|
+
end
|
|
127
|
+
parsed
|
|
89
128
|
end
|
|
90
129
|
|
|
91
130
|
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Zerobounce
|
|
6
|
+
# Turns HTML pages, empty bodies, and parser dumps into a short message.
|
|
7
|
+
module ResponseError
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# A <title> sits in the head; anything past this is not one worth scanning.
|
|
11
|
+
TITLE_SCAN_LIMIT = 64 * 1024
|
|
12
|
+
|
|
13
|
+
def html?(body, content_type = '')
|
|
14
|
+
sample = body.to_s[0, 2000].to_s.downcase
|
|
15
|
+
content_type.to_s.downcase.include?('text/html') ||
|
|
16
|
+
sample.include?('<!doctype html') ||
|
|
17
|
+
sample.include?('<html') ||
|
|
18
|
+
sample.include?('<head') ||
|
|
19
|
+
sample.include?('<body')
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def strip_prefix(message)
|
|
23
|
+
text = message.to_s.strip
|
|
24
|
+
loop do
|
|
25
|
+
if text.start_with?('Error:')
|
|
26
|
+
text = text.sub(/\AError:\s*/, '').strip
|
|
27
|
+
next
|
|
28
|
+
end
|
|
29
|
+
if text.start_with?('[Error]:')
|
|
30
|
+
text = text.sub(/\A\[Error\]:\s*/, '').strip
|
|
31
|
+
next
|
|
32
|
+
end
|
|
33
|
+
return text
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Walked with String#index instead of one <title[^>]*>(.*?)</title> match:
|
|
38
|
+
# that pattern retries [^>]* from every "<title" in the body, which is
|
|
39
|
+
# quadratic on a page built from repeated "<title" and was reported as a
|
|
40
|
+
# polynomial ReDoS. Each step below scans forward once, and the body is
|
|
41
|
+
# capped first because a title only ever lives in the head.
|
|
42
|
+
def html_title(body)
|
|
43
|
+
text = body.to_s[0, TITLE_SCAN_LIMIT].to_s
|
|
44
|
+
open_tag = text.index(/<title[\s>]/i)
|
|
45
|
+
return '' unless open_tag
|
|
46
|
+
|
|
47
|
+
open_end = text.index('>', open_tag)
|
|
48
|
+
return '' unless open_end
|
|
49
|
+
|
|
50
|
+
close_tag = text.index(%r{</title\s*>}i, open_end + 1)
|
|
51
|
+
return '' unless close_tag
|
|
52
|
+
|
|
53
|
+
text[(open_end + 1)...close_tag].strip
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def message_from_parts(status, content_type, body)
|
|
57
|
+
text = body.to_s
|
|
58
|
+
if html?(text, content_type)
|
|
59
|
+
title = html_title(text)
|
|
60
|
+
return 'ZeroBounce returned an HTML error page instead of an API response.' if title.empty?
|
|
61
|
+
|
|
62
|
+
return "ZeroBounce returned an HTML error page (\"#{title}\") instead of an API response."
|
|
63
|
+
end
|
|
64
|
+
trimmed = text.strip
|
|
65
|
+
return "Empty response from ZeroBounce (HTTP #{status})" if trimmed.empty?
|
|
66
|
+
|
|
67
|
+
if trimmed.start_with?('{', '[')
|
|
68
|
+
begin
|
|
69
|
+
parsed = JSON.parse(trimmed)
|
|
70
|
+
rescue JSON::ParserError
|
|
71
|
+
return 'ZeroBounce returned a response that is not valid JSON.'
|
|
72
|
+
end
|
|
73
|
+
if parsed.is_a?(Hash)
|
|
74
|
+
title = parsed['title'].to_s
|
|
75
|
+
access = title.include?('Access denied') || title.include?('1020')
|
|
76
|
+
if access && parsed['cloudflare_error'] == true
|
|
77
|
+
return title
|
|
78
|
+
end
|
|
79
|
+
%w[error message error_message Message detail].each do |key|
|
|
80
|
+
value = parsed[key]
|
|
81
|
+
return strip_prefix(value) if value.is_a?(String) && !value.strip.empty?
|
|
82
|
+
next unless value.is_a?(Array) && !value.empty?
|
|
83
|
+
return strip_prefix(value[0]) if value[0].is_a?(String) && !value[0].strip.empty?
|
|
84
|
+
|
|
85
|
+
# A validation error list: [{"loc" => [...], "msg" => "..."}].
|
|
86
|
+
if value[0].is_a?(Hash) && value[0]['msg']
|
|
87
|
+
loc = value[0]['loc']
|
|
88
|
+
field = loc.is_a?(Array) && loc.length > 1 ? loc[1..-1].join('.') : ''
|
|
89
|
+
return field.empty? ? value[0]['msg'].to_s : "#{field}: #{value[0]['msg']}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
# A top-level list of strings, e.g. ["In-progress tests cannot be deleted."].
|
|
94
|
+
if parsed.is_a?(Array)
|
|
95
|
+
first = parsed.find { |item| item.is_a?(String) && !item.strip.empty? }
|
|
96
|
+
return strip_prefix(first) if first
|
|
97
|
+
end
|
|
98
|
+
return 'ZeroBounce returned a response that is not valid JSON.' if trimmed.length > 1000
|
|
99
|
+
|
|
100
|
+
return strip_prefix(trimmed)
|
|
101
|
+
end
|
|
102
|
+
return 'ZeroBounce returned a response that is not valid JSON.' if trimmed.length > 1000
|
|
103
|
+
|
|
104
|
+
message = strip_prefix(trimmed)
|
|
105
|
+
message.empty? ? "Empty response from ZeroBounce (HTTP #{status})" : message
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def message_from(response)
|
|
109
|
+
content_type = ''
|
|
110
|
+
if response.respond_to?(:headers) && response.headers
|
|
111
|
+
content_type = (response.headers[:content_type] || response.headers['Content-Type']).to_s
|
|
112
|
+
end
|
|
113
|
+
code = response.respond_to?(:code) ? response.code : 0
|
|
114
|
+
message_from_parts(code, content_type, response.respond_to?(:body) ? response.body : response.to_s)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
data/lib/zerobounce/version.rb
CHANGED