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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +146 -19
  3. data/README.md +472 -410
  4. data/lib/zerobounce/api_urls.rb +9 -0
  5. data/lib/zerobounce/base_request.rb +208 -8
  6. data/lib/zerobounce/client.rb +22 -0
  7. data/lib/zerobounce/configuration.rb +24 -3
  8. data/lib/zerobounce/error.rb +18 -0
  9. data/lib/zerobounce/get_file_helper.rb +3 -7
  10. data/lib/zerobounce/mock_request.rb +31 -12
  11. data/lib/zerobounce/priority3.rb +331 -0
  12. data/lib/zerobounce/request.rb +77 -38
  13. data/lib/zerobounce/response_error.rb +117 -0
  14. data/lib/zerobounce/validate_sub_status.rb +1 -0
  15. data/lib/zerobounce/version.rb +1 -1
  16. data/lib/zerobounce/warmup.rb +467 -0
  17. data/lib/zerobounce.rb +101 -14
  18. metadata +8 -33
  19. data/.dockerignore +0 -17
  20. data/.env.example +0 -3
  21. data/.github/auto_assign.yml +0 -5
  22. data/.github/dependabot.yml +0 -35
  23. data/.github/workflows/auto_assign_ci.yaml +0 -21
  24. data/.github/workflows/codeql.yml +0 -105
  25. data/.github/workflows/publish.yml +0 -131
  26. data/.github/workflows/sdk_ci.yml +0 -29
  27. data/.gitignore +0 -70
  28. data/.rspec +0 -2
  29. data/.ruby-version +0 -1
  30. data/CODE_OF_CONDUCT.md +0 -69
  31. data/CONTRIBUTING.md +0 -112
  32. data/Dockerfile +0 -17
  33. data/Gemfile +0 -13
  34. data/Gemfile.lock +0 -135
  35. data/Rakefile +0 -26
  36. data/SECURITY.md +0 -21
  37. data/bin/bundle +0 -105
  38. data/bin/console +0 -7
  39. data/bin/setup +0 -6
  40. data/data/zerobounce-ai-scoring.csv +0 -31
  41. data/data/zerobounce-batch-validation.csv +0 -31
  42. data/documentation.md +0 -530
  43. data/files/scoring.csv +0 -1
  44. data/files/validation.csv +0 -1
  45. data/files/zerobounce-ai-scoring.csv +0 -31
  46. data/files/zerobounce-batch-validation.csv +0 -31
  47. 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
@@ -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
- if response.headers[:content_type] == 'application/json'
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] == 'application/json'
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
- if response.headers[:content_type] == 'application/json'
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] == 'application/json'
78
- response_body = response.body
79
- response_body_json = JSON.parse(response_body)
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
- raise (response_body_json['error']) if response_body_json.key?('error')
82
- raise (response_body_json['errors'][0]['error']) \
83
- if response_body_json.key?('errors') and \
84
- response_body_json['errors'].length > 0
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
- return response_body_json
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
- return response.body
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
@@ -36,5 +36,6 @@ module Zerobounce
36
36
  ACCEPT_ALL = 'accept_all'
37
37
  ROLE_BASED_ACCEPT_ALL = 'role_based_accept_all'
38
38
  GOLD = 'gold'
39
+ AI_AGENT_MAILBOX = 'ai_agent_mailbox'
39
40
  end
40
41
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Zerobounce
4
4
  # The version of the gem.
5
- VERSION = '2.1.7'
5
+ VERSION = '2.2.0'
6
6
  end