zerobounce-sdk 2.2.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6af251b375110bd15d24b7f59f9cf4825e698a7e2009b0b7c4ed4a55c385dc7f
4
- data.tar.gz: 630b6eda82fc8c016cf6cfb76fc54ff44db9aca843b8e9042f96c454083472cb
3
+ metadata.gz: a38febb2edd021c081aefb3df14db686a54ef1f41142f6559fd95069ad97219c
4
+ data.tar.gz: 13f9d5846f1ba7ac10c4afb082197f4d7b0efcbfa3bee2bb25310b82f27b2979
5
5
  SHA512:
6
- metadata.gz: e7f27766cddc52f625cd1fa3f7fa8902c252711d81aec0d28cbc39b4fc32548fec550f753655f3f0ffab2ef19aa2f75e1bd186d9a908afbe20d65e7dfd3c2f9b
7
- data.tar.gz: 986f86e4b3a9592d6f30f3a32be9a85e9d7a7e55029aac88d2ae72f9894eb60550f3a9028870e6dd31b515efc09c212e5fc7cf0f38c01ba2c98db4b37ac16686
6
+ metadata.gz: 4d0c27a41276b918ba70daa8e62dc9b7bff1fe4f067ffe5119b33cd1ab891b0249a3f83eb53cc6fa237388de13a47e7edce9c3ade36f38a85dde9031f741fdef
7
+ data.tar.gz: 4b7c4ba9febcbf1a45d5b1f8028d35ac6128d374c827973130aa7e040992df988ab6c36ecd13a6974ba7c170d2ee30a4df0967af541baccff8ceebeeb2e5a8fe
data/CHANGELOG.md CHANGED
@@ -5,7 +5,32 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [3.0.0] - 2026-11-12
9
+
10
+ ### Breaking
11
+
12
+ - **Breaking:** boolean options are parsed strictly. `true`/`false`, `'true'`/`'false'` (any case) and `1`/`0` are accepted; anything else raises `ArgumentError` naming the option before a request is sent. Up to 2.2.0 any truthy value was sent as `true`, so `activity_data: 'false'` sent `true`. Affects `validate` (`activity_data`, `verify_plus`, `domain_info`, `greylist_processing`), `validate_batch`, `validate_file_send`/`scoring_file_send` (`has_header_row`, `allow_phase_2`), `GetFileOptions#activity_data`, `send_email_finder_file`, `send_domain_search_file`, and the S3/SFTP jobs (`has_header_row`, `remove_duplicates`); a `nil` `has_header_row` on an S3/SFTP job, which 2.2.0 sent as `false`, now raises.
13
+ - **Breaking:** an API key with surrounding whitespace or control characters (for example one read from a file with its newline) raises `ArgumentError` before any request, from `Zerobounce.configure`'s key, `Zerobounce.with_api_key` and `Zerobounce::Client.new`, as `Zerobounce::Warmup.new` already did. 2.2.0 sent it as it was.
14
+
15
+ ### Fixed
16
+
17
+ - `validate` returns the greylist answer `{"Message": "Greylist validation still in progress..."}` as its result (GET and `use_post: true`). 2.2.0 raised `RuntimeError` for it, so `greylist_processing: true` could never be used to pick up a result later.
18
+ - A 2xx `{"error_message": "..."}` with no `success` field raises. 2.2.0 returned it as the result, and `credits` returned `0` for it.
19
+ - `find_email`, `find_domain` and `guessformat` raise for a body made only of message fields (`detail`, `error_message`, `message`, as well as `Message`); 2.2.0 returned `{"detail": ...}` and `{"error_message": ...}` as results.
20
+ - `validate_file_get`, `scoring_file_get` and the email finder and domain search downloads raise `Empty response from ZeroBounce (HTTP 200)` for an empty body instead of returning `""`.
21
+ - `validate_file_check`, `validate_file_delete` and the scoring equivalents no longer raise an empty message for `{"error": ""}`; an empty `error` is not an error.
22
+ - `Zerobounce::Client#inspect` and `Zerobounce::Warmup#inspect` no longer show the API key.
23
+
24
+ ### Changed
25
+
26
+ - Errors reported on a 2xx response raise `Zerobounce::ApiResponseError` with `#status_code` set to the HTTP status (usually 200). `ApiResponseError` is now a subclass of `RuntimeError` (it was a `StandardError`), so `rescue RuntimeError` catches these errors as before.
27
+ - The license file is named `LICENSE` (it was `LICENSE.txt`), and the gem ships only that one license file.
28
+ - The gemspec has a fuller summary and description, and `source_code_uri`, `changelog_uri` and `homepage_uri` metadata.
29
+ - Development only (nothing ships to gem users): `benchmark` is a development dependency, because it left Ruby 4.0's default gems and RuboCop needs it.
30
+
31
+ ### Removed
32
+
33
+ - `Zerobounce::MockRequest` and the `TEST=unit` switch are gone. With `TEST=unit` set in the environment, the gem answered every call with canned responses instead of calling the API, which could silently fake results in an application's own test or CI setup. Nothing in the gem's specs used it.
9
34
 
10
35
  ## [2.2.0] - 2026-11-11
11
36
 
@@ -147,7 +172,8 @@ These change behaviour that 2.1.8 callers can rely on. Check each before upgradi
147
172
 
148
173
  - Initial client for ZeroBounce API v2.
149
174
 
150
- [Unreleased]: https://github.com/zerobounce/zero-bounce-ruby/compare/v2.2.0...HEAD
175
+ [Unreleased]: https://github.com/zerobounce/zero-bounce-ruby/compare/v3.0.0...HEAD
176
+ [3.0.0]: https://github.com/zerobounce/zero-bounce-ruby/compare/v2.2.0...v3.0.0
151
177
  [2.2.0]: https://github.com/zerobounce/zero-bounce-ruby/compare/v2.1.8...v2.2.0
152
178
  [2.1.8]: https://github.com/zerobounce/zero-bounce-ruby/compare/v2.1.6...v2.1.8
153
179
  [2.1.6]: https://github.com/zerobounce/zero-bounce-ruby/compare/v2.1.5...v2.1.6
data/README.md CHANGED
@@ -700,8 +700,9 @@ Zerobounce.find_domain(company_name: "Zero Bounce")
700
700
  ## Errors
701
701
 
702
702
  - An HTTP error status raises the `RestClient` exception for that status, as it did up to 2.1.8: `RestClient::NotFound`, `RestClient::Forbidden` and so on, all subclasses of `RestClient::ExceptionWithResponse` and of `RuntimeError`. `#http_code` gives the status. The message is the API's own short error text (up to 2.1.8 it was RestClient's, such as `404 Not Found`); an HTML error page is reduced to its title.
703
- - An error reported with an HTTP 200 - an `error` field, `{"success": false}`, or an account-level `validate_batch` failure - raises `RuntimeError` with the API's message.
704
- - Invalid arguments, and a base URL that is not `https://`, raise `ArgumentError` before any request is sent.
703
+ - An error reported with an HTTP 200 - an `error` field, an `Error` or `error_message` field without `success`, `{"success": false}`, or an account-level `validate_batch` failure - raises `Zerobounce::ApiResponseError` (a `RuntimeError`) with the API's message; `#status_code` is the HTTP status, usually 200. `find_email`, `find_domain` and `guessformat` also raise for a body made only of message fields (such as `{"Message": "Invalid API key or your account ran out of credits"}`).
704
+ - `validate`'s greylist answer, `{"Message": "Greylist validation still in progress..."}`, is a result: the Hash is returned with its `Message`.
705
+ - Invalid arguments, a boolean option that is not `true`/`false`, `'true'`/`'false'` or `1`/`0`, an API key with surrounding whitespace or control characters, and a base URL that is not `https://` raise `ArgumentError` before any request is sent.
705
706
  - Redirects are not followed; a 3xx raises its `RestClient` exception (for example `RestClient::Found`).
706
707
  - The Warmup client raises `Zerobounce::ApiResponseError`, whose `#status_code` gives the HTTP status.
707
708
 
@@ -839,3 +840,7 @@ Run it by hand before every release: the offline suite proves the SDK sends what
839
840
  2. **Actions → Publish → Run workflow** with that tag.
840
841
 
841
842
  Registry: [zerobounce-sdk on RubyGems](https://rubygems.org/gems/zerobounce-sdk)
843
+
844
+ ## License
845
+
846
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zerobounce
4
+ # Checks an API key before it is sent.
5
+ module ApiKey
6
+ module_function
7
+
8
+ # The key, unchanged, or an ArgumentError when it has surrounding
9
+ # whitespace or control characters (a key read from a file with its
10
+ # newline). Such a key would fail at the API, and the HTTP library's own
11
+ # error could quote it unredacted.
12
+ def check!(api_key)
13
+ key = api_key.to_s
14
+ if key != key.strip || key.match?(/[\u0000-\u001f\u007f]/)
15
+ raise ArgumentError, 'api_key has surrounding whitespace or control characters'
16
+ end
17
+
18
+ api_key
19
+ end
20
+ end
21
+ end
@@ -3,6 +3,8 @@
3
3
  require 'json'
4
4
  require 'zerobounce/api_urls'
5
5
  require 'zerobounce/response_error'
6
+ require 'zerobounce/api_key'
7
+ require 'zerobounce/error'
6
8
 
7
9
  # https://github.com/rest-client/rest-client
8
10
  require 'rest-client'
@@ -70,6 +72,17 @@ module Zerobounce
70
72
  key.nil? ? Zerobounce.config.apikey : key
71
73
  end
72
74
 
75
+ # The key for this request, or an error before anything is sent. A key with
76
+ # surrounding whitespace or control characters (one read from a file with
77
+ # its newline) would go out as it is and fail, and the HTTP library could
78
+ # quote it unredacted.
79
+ def self.__checked_api_key__
80
+ api_key = current_api_key
81
+ raise ('API key must be assigned') if api_key.nil? || api_key.to_s.strip.empty?
82
+
83
+ ApiKey.check!(api_key)
84
+ end
85
+
73
86
  # The message for an HTTP error status.
74
87
  def self.__http_error_message__(error)
75
88
  code = error.http_code.to_i
@@ -109,8 +122,7 @@ module Zerobounce
109
122
  # puts path
110
123
  # puts Zerobounce.config.apikey
111
124
 
112
- api_key = current_api_key
113
- raise ('API key must be assigned') if api_key.nil? || api_key.to_s.strip.empty?
125
+ api_key = __checked_api_key__
114
126
 
115
127
  params[:api_key] = api_key
116
128
  url = "#{Zerobounce::BaseRequest.__root_without_trailing_slashes__(root)}/#{path}"
@@ -122,8 +134,7 @@ module Zerobounce
122
134
 
123
135
  def self._post(root, path, params, content_type='application/json', filepath=nil)
124
136
 
125
- api_key = current_api_key
126
- raise ('API key must be assigned') if api_key.nil? || api_key.to_s.strip.empty?
137
+ api_key = __checked_api_key__
127
138
 
128
139
  params[:api_key] = api_key
129
140
  url = "#{Zerobounce::BaseRequest.__root_without_trailing_slashes__(root)}/#{path}"
@@ -165,8 +176,7 @@ module Zerobounce
165
176
  # parameter, which the Inbox Placement Tester requires and which keeps the
166
177
  # key out of request logs.
167
178
  def self.__execute_with_method__(method, url, params, form: nil, json: nil, bearer: false)
168
- api_key = current_api_key
169
- raise ('API key must be assigned') if api_key.nil? || api_key.to_s.strip.empty?
179
+ api_key = __checked_api_key__
170
180
 
171
181
  params = (params || {}).dup
172
182
  extra = {}
@@ -251,30 +261,72 @@ module Zerobounce
251
261
  def self.__raise_if_success_false__(response, parsed)
252
262
  return unless parsed.is_a?(Hash) && parsed.key?('success') && __success_means_failure__(parsed['success'])
253
263
 
254
- raise ResponseError.message_from_parts(response.code, response.headers[:content_type].to_s, response.body.to_s)
264
+ raise ApiResponseError.new(
265
+ ResponseError.message_from_parts(response.code, response.headers[:content_type].to_s, response.body.to_s),
266
+ response.code
267
+ )
268
+ end
269
+
270
+ # A value that reads as an error: a non-blank String.
271
+ def self.__error_text__(value)
272
+ value.is_a?(String) && !value.strip.empty?
273
+ end
274
+
275
+ # An "error" value that means failure: anything but nil or a blank String.
276
+ def self.__error_value__(value)
277
+ !value.nil? && !(value.is_a?(String) && value.strip.empty?)
278
+ end
279
+
280
+ # The keys a message-only body is made of.
281
+ MESSAGE_KEYS = %w[error message error_message Message Error detail].freeze
282
+
283
+ # A body made only of message keys (no "success"), for the calls whose
284
+ # result is never a bare message.
285
+ def self.__raise_if_message_only__(response, parsed)
286
+ return if parsed.empty? || parsed.key?('success')
287
+ return unless parsed.keys.all? { |key| MESSAGE_KEYS.include?(key) }
288
+
289
+ text = MESSAGE_KEYS.map { |key| parsed[key] }.find { |value| __error_text__(value) }
290
+ raise ApiResponseError.new(ResponseError.strip_prefix(text), response.code) if text
291
+ end
292
+
293
+ # A dead or invalid key on an HTTP 200: no "success" field and a non-empty
294
+ # "Error" (LIVE: the filters spell it so) or "error_message".
295
+ def self.__raise_if_bare_error__(response, parsed)
296
+ return if parsed.key?('success')
297
+
298
+ %w[Error error_message].each do |key|
299
+ next unless __error_text__(parsed[key])
300
+
301
+ raise ApiResponseError.new(ResponseError.strip_prefix(parsed[key]), response.code)
302
+ end
255
303
  end
256
304
 
257
305
  def self.__parse_json_response__(response)
258
306
  body = response.body.to_s
259
307
  content_type = (response.headers[:content_type] || '').to_s
308
+ code = response.code
260
309
  if ResponseError.html?(body, content_type) || body.strip.empty?
261
- raise ResponseError.message_from_parts(response.code, content_type, body)
310
+ raise ApiResponseError.new(ResponseError.message_from_parts(code, content_type, body), code)
262
311
  end
263
312
  begin
264
313
  parsed = JSON.parse(body)
265
314
  rescue JSON::ParserError
266
- raise 'ZeroBounce returned a response that is not valid JSON.'
315
+ raise ApiResponseError.new('ZeroBounce returned a response that is not valid JSON.', code)
267
316
  end
268
317
  return parsed unless parsed.is_a?(Hash)
269
318
 
270
319
  __raise_if_success_false__(response, parsed)
271
320
 
272
- raise ResponseError.strip_prefix(parsed['error']) if parsed.key?('error') && !parsed['error'].nil?
321
+ raise ApiResponseError.new(ResponseError.strip_prefix(parsed['error']), code) if __error_value__(parsed['error'])
273
322
  # The Inbox Placement Tester is a Django REST framework app and reports
274
323
  # its errors under "detail" rather than "error"/"message".
275
- raise ResponseError.strip_prefix(parsed['detail']) if parsed.key?('detail') && !parsed['detail'].nil?
324
+ if __error_value__(parsed['detail'])
325
+ raise ApiResponseError.new(ResponseError.strip_prefix(parsed['detail']), code)
326
+ end
327
+ __raise_if_bare_error__(response, parsed)
276
328
  if parsed.key?('errors') && parsed['errors'].is_a?(Array) && !parsed['errors'].empty?
277
- raise ResponseError.strip_prefix(parsed['errors'][0]['error'])
329
+ raise ApiResponseError.new(ResponseError.strip_prefix(parsed['errors'][0]['error']), code)
278
330
  end
279
331
  parsed
280
332
  end
@@ -1,12 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'zerobounce/api_key'
4
+
3
5
  module Zerobounce
4
6
  # One API key. Package methods share Zerobounce.config; a Client does not.
5
7
  class Client
6
8
  def initialize(apikey)
9
+ ApiKey.check!(apikey) unless apikey.nil? || apikey.to_s.strip.empty?
7
10
  @apikey = apikey
8
11
  end
9
12
 
13
+ # Keeps the API key out of logs and consoles.
14
+ def inspect
15
+ "#<#{self.class.name} apikey=#{@apikey.nil? ? 'nil' : '[FILTERED]'}>"
16
+ end
17
+
10
18
  def validate(email, ip_address = nil, **options)
11
19
  Zerobounce.with_api_key(@apikey) { Zerobounce.validate(email, ip_address, **options) }
12
20
  end
@@ -59,10 +59,12 @@ module Zerobounce
59
59
  # Carries the HTTP status so callers can tell a 403 (Cloudflare) from a
60
60
  # 400/404 (API) or a 429 (the Warmup rate limit). The Warmup client raises
61
61
  # it. The validation client raises the RestClient exception for an HTTP
62
- # error status (status in #http_code), as it did up to 2.1.8, and a
63
- # RuntimeError for an error reported with an HTTP 200; both carry the same
64
- # short message.
65
- class ApiResponseError < StandardError
62
+ # error status (status in #http_code), as it did up to 2.1.8, and this
63
+ # class, with the 2xx status (usually 200), for an error reported on a
64
+ # successful HTTP response; both carry the same short message. It is a
65
+ # RuntimeError, which those errors were before, so `rescue RuntimeError`
66
+ # still catches them.
67
+ class ApiResponseError < RuntimeError
66
68
  # @return [Integer] the HTTP status of the failed response, or 0
67
69
  attr_reader :status_code
68
70
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require 'stringio'
5
+ require 'zerobounce/error'
5
6
  require 'zerobounce/response_error'
6
7
 
7
8
  module Zerobounce
@@ -70,12 +71,13 @@ module Zerobounce
70
71
  body_str = response.body.to_s
71
72
  ct = (response.headers[:content_type] || response.headers['Content-Type'] || '').to_s
72
73
 
73
- if code > 299 || ResponseError.html?(body_str, ct)
74
- raise ResponseError.message_from_parts(code, ct, body_str)
74
+ # An empty body is no result file: an error, as in the other SDKs.
75
+ if code > 299 || body_str.strip.empty? || ResponseError.html?(body_str, ct)
76
+ raise ApiResponseError.new(ResponseError.message_from_parts(code, ct, body_str), code)
75
77
  end
76
78
 
77
79
  if should_treat_as_error?(body_str, ct)
78
- raise format_error_message(body_str)
80
+ raise ApiResponseError.new(format_error_message(body_str), code)
79
81
  end
80
82
 
81
83
  io = StringIO.new(response.body)
@@ -27,7 +27,7 @@ module Zerobounce
27
27
  end
28
28
 
29
29
  params = { domain_column: domain_column }
30
- params[:has_header_row] = has_header_row ? 'true' : 'false' unless has_header_row.nil?
30
+ params[:has_header_row] = __wire_bool__(has_header_row, 'has_header_row') unless has_header_row.nil?
31
31
  params[:first_name_column] = first_name_column unless first_name_column.nil?
32
32
  params[:last_name_column] = last_name_column unless last_name_column.nil?
33
33
  params[:middle_name_column] = middle_name_column unless middle_name_column.nil?
@@ -51,7 +51,7 @@ module Zerobounce
51
51
  raise ArgumentError, 'domain_column is required' if domain_column.nil? || domain_column.to_i < 1
52
52
 
53
53
  params = { domain_column: domain_column }
54
- params[:has_header_row] = has_header_row ? 'true' : 'false' unless has_header_row.nil?
54
+ params[:has_header_row] = __wire_bool__(has_header_row, 'has_header_row') unless has_header_row.nil?
55
55
  zb_request.bulk_root_post("#{DOMAIN_SEARCH_BULK_BASE}/sendfile", params, 'multipart/form-data', filepath)
56
56
  end
57
57
 
@@ -287,6 +287,26 @@ module Zerobounce
287
287
  value.nil? || value.to_s.strip.empty?
288
288
  end
289
289
 
290
+ # A boolean option, parsed strictly: true/false, 'true'/'false' in any
291
+ # case, or 1/0. Anything else is an ArgumentError, so 'false' can no
292
+ # longer be sent as true and a typo is not silently taken as a flag.
293
+ def __strict_bool__(value, name)
294
+ return value if [true, false].include?(value)
295
+ return value == 1 if value.is_a?(Integer) && [0, 1].include?(value)
296
+
297
+ if value.is_a?(String)
298
+ text = value.strip.downcase
299
+ return true if %w[true 1].include?(text)
300
+ return false if %w[false 0].include?(text)
301
+ end
302
+ raise ArgumentError, "#{name} must be true or false"
303
+ end
304
+
305
+ # A strict boolean option as the string the API expects on the wire.
306
+ def __wire_bool__(value, name)
307
+ __strict_bool__(value, name) ? 'true' : 'false'
308
+ end
309
+
290
310
  def __inbox_placement_test_type__(value)
291
311
  test_type = value.is_a?(Integer) ? value : value.to_s.strip.to_i
292
312
  unless INBOX_PLACEMENT_TEST_TYPES.include?(test_type)
@@ -311,12 +331,12 @@ module Zerobounce
311
331
  return_url = options[:return_url]
312
332
 
313
333
  params = {
314
- has_header_row: has_header_row ? 'true' : 'false',
334
+ has_header_row: __wire_bool__(has_header_row, 'has_header_row'),
315
335
  file_name: file_name,
316
336
  return_url: return_url
317
337
  }
318
338
  if options.key?(:remove_duplicates)
319
- params[:remove_duplicates] = options[:remove_duplicates] ? 'true' : 'false'
339
+ params[:remove_duplicates] = __wire_bool__(options[:remove_duplicates], 'remove_duplicates')
320
340
  end
321
341
 
322
342
  zb_request.bulk_root_form_post(path, params)
@@ -9,9 +9,14 @@ 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
12
+ # The email finder and domain search. Their result is never a bare
13
+ # message, so a body made only of message keys is how they report a
14
+ # rejected request. LIVE: an invalid or drained key gets an HTTP 200 with
15
+ # {"Message": "Invalid API key or your account ran out of credits"}.
16
+ # Everywhere else a bare message is a result: validate's greylist answer
17
+ # {"Message": "Greylist validation still in progress..."} and the
18
+ # filters' {"Message": "Filter successfully added"}.
19
+ MESSAGE_ONLY_ERROR_PATHS = %w[guessformat].freeze
15
20
 
16
21
  def self.get(path, params, content_type='application/json')
17
22
  response = self._get(Zerobounce.configuration.api_root_url, path, params, content_type)
@@ -25,11 +30,14 @@ module Zerobounce
25
30
  response_body_json = JSON.parse(response_body)
26
31
  return response_body_json unless response_body_json.is_a?(Hash)
27
32
 
28
- raise (response_body_json['error']) if response_body_json.key?('error')
29
- raise (response_body_json['errors'][0]['error']) \
30
- if response_body_json.key?('errors') and \
31
- response_body_json['errors'].length > 0
33
+ if __error_value__(response_body_json['error'])
34
+ raise ApiResponseError.new(ResponseError.strip_prefix(response_body_json['error']), response.code)
35
+ end
36
+ if response_body_json['errors'].is_a?(Array) && !response_body_json['errors'].empty?
37
+ raise ApiResponseError.new(response_body_json['errors'][0]['error'].to_s, response.code)
38
+ end
32
39
  __raise_if_success_false__(response, response_body_json)
40
+ __raise_if_bare_error__(response, response_body_json)
33
41
 
34
42
  return response_body_json
35
43
  else
@@ -94,35 +102,30 @@ module Zerobounce
94
102
  content_type = (response.headers[:content_type] || '').to_s
95
103
  if ResponseError.html?(body, content_type) || body.strip.empty? ||
96
104
  (!content_type.include?('json') && !body.strip.start_with?('{', '['))
97
- raise ResponseError.message_from_parts(response.code, content_type, body)
105
+ raise ApiResponseError.new(ResponseError.message_from_parts(response.code, content_type, body), response.code)
98
106
  end
99
107
  begin
100
108
  parsed = JSON.parse(body)
101
109
  rescue JSON::ParserError
102
- raise 'ZeroBounce returned a response that is not valid JSON.'
110
+ raise ApiResponseError.new('ZeroBounce returned a response that is not valid JSON.', response.code)
103
111
  end
104
112
  return parsed unless parsed.is_a?(Hash)
105
113
 
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'])
114
+ code = response.code
115
+ if __error_value__(parsed['error'])
116
+ raise ApiResponseError.new(ResponseError.strip_prefix(parsed['error']), code)
119
117
  end
118
+ __raise_if_success_false__(response, parsed)
119
+ __raise_if_bare_error__(response, parsed)
120
+ __raise_if_message_only__(response, parsed) if MESSAGE_ONLY_ERROR_PATHS.include?(path)
120
121
  if parsed.key?('errors') && parsed['errors'].is_a?(Array) && !parsed['errors'].empty?
121
122
  account = parsed['errors'].find { |item| item.is_a?(Hash) && item['email_address'].to_s.casecmp('all').zero? }
122
123
  if account
123
- raise ResponseError.strip_prefix(account['error'])
124
+ raise ApiResponseError.new(ResponseError.strip_prefix(account['error']), code)
125
+ end
126
+ unless path == 'validatebatch'
127
+ raise ApiResponseError.new(ResponseError.strip_prefix(parsed['errors'][0]['error']), code)
124
128
  end
125
- raise ResponseError.strip_prefix(parsed['errors'][0]['error']) unless path == 'validatebatch'
126
129
  end
127
130
  parsed
128
131
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Zerobounce
4
4
  # The version of the gem.
5
- VERSION = '2.2.0'
5
+ VERSION = '3.0.0'
6
6
  end
@@ -5,6 +5,7 @@ require 'erb'
5
5
  require 'json'
6
6
  require 'rest-client'
7
7
 
8
+ require 'zerobounce/api_key'
8
9
  require 'zerobounce/error'
9
10
  require 'zerobounce/response_error'
10
11
 
@@ -40,9 +41,7 @@ module Zerobounce
40
41
 
41
42
  # A key with surrounding whitespace or control characters would be
42
43
  # 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
44
+ ApiKey.check!(api_key)
46
45
  unless base_url.to_s.downcase.start_with?('https://')
47
46
  raise ArgumentError, 'base_url must be an https:// URL'
48
47
  end
@@ -53,6 +52,11 @@ module Zerobounce
53
52
  @root = "#{root}#{BASE_PATH}"
54
53
  end
55
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
+
56
60
  # -- sending accounts ---------------------------------------------------
57
61
 
58
62
  # Every sending account of the key's team. GET /sending-accounts
data/lib/zerobounce.rb CHANGED
@@ -14,7 +14,6 @@ require 'zerobounce/get_file_helper'
14
14
  require 'zerobounce/validate_status'
15
15
  require 'zerobounce/validate_sub_status'
16
16
  require 'zerobounce/request'
17
- require 'zerobounce/mock_request'
18
17
  require 'zerobounce/configuration'
19
18
  require 'zerobounce/api_urls'
20
19
  require 'zerobounce/priority3'
@@ -27,7 +26,7 @@ module Zerobounce
27
26
  include Priority3
28
27
  attr_writer :configuration
29
28
 
30
- @@request = ENV['TEST']=='unit' ? MockRequest : Request
29
+ @@request = Request
31
30
 
32
31
  # Zerobounce configuration
33
32
  #
@@ -102,6 +101,10 @@ module Zerobounce
102
101
  raise ArgumentError, 'Invalid parameter: timeout (must be between 3 and 60 seconds)'
103
102
  end
104
103
  end
104
+ activity_data = __strict_bool__(activity_data, 'activity_data') unless activity_data.nil?
105
+ verify_plus = __strict_bool__(verify_plus, 'verify_plus') unless verify_plus.nil?
106
+ domain_info = __strict_bool__(domain_info, 'domain_info') unless domain_info.nil?
107
+ greylist_processing = __strict_bool__(greylist_processing, 'greylist_processing') unless greylist_processing.nil?
105
108
  unless callback_url.nil?
106
109
  raise ArgumentError, 'Empty parameter: callback_url' if callback_url.to_s.strip.empty?
107
110
 
@@ -312,8 +315,8 @@ module Zerobounce
312
315
  # the API rejects the whole batch with the account-level "Invalid API Key
313
316
  # or your account ran out of credits".
314
317
  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?
318
+ params[:activity_data] = __strict_bool__(activity_data, 'activity_data') unless activity_data.nil?
319
+ params[:verify_plus] = __strict_bool__(verify_plus, 'verify_plus') unless verify_plus.nil?
317
320
  results = @@request.post('validatebatch', params)
318
321
  results
319
322
  end
@@ -345,16 +348,14 @@ module Zerobounce
345
348
  return_url: nil,
346
349
  allow_phase_2: nil
347
350
  )
348
- params = {
349
- email_address_column: email_address_column,
350
- has_header_row: has_header_row,
351
- }
351
+ params = { email_address_column: email_address_column }
352
+ params[:has_header_row] = __wire_bool__(has_header_row, 'has_header_row') unless has_header_row.nil?
352
353
  params[:first_name_column] = first_name_column unless first_name_column.nil?
353
354
  params[:last_name_column] = last_name_column unless last_name_column.nil?
354
355
  params[:gender_column] = gender_column unless gender_column.nil?
355
356
  params[:return_url] = return_url unless return_url.nil? || return_url.to_s.empty?
356
357
  unless allow_phase_2.nil?
357
- params[:allow_phase_2] = allow_phase_2 ? 'true' : 'false'
358
+ params[:allow_phase_2] = __wire_bool__(allow_phase_2, 'allow_phase_2')
358
359
  end
359
360
  @@request.bulk_post('sendfile', params, 'multipart/form-data', filepath)
360
361
  end
@@ -414,7 +415,7 @@ module Zerobounce
414
415
  if options
415
416
  params[:download_type] = options.download_type if options.download_type
416
417
  unless options.activity_data.nil?
417
- params[:activity_data] = options.activity_data ? 'true' : 'false'
418
+ params[:activity_data] = __wire_bool__(options.activity_data, 'activity_data')
418
419
  end
419
420
  end
420
421
  @@request.bulk_getfile('getfile', params)
@@ -457,10 +458,8 @@ module Zerobounce
457
458
  has_header_row: true,
458
459
  return_url: nil
459
460
  )
460
- params = {
461
- email_address_column: email_address_column,
462
- has_header_row: has_header_row,
463
- }
461
+ params = { email_address_column: email_address_column }
462
+ params[:has_header_row] = __wire_bool__(has_header_row, 'has_header_row') unless has_header_row.nil?
464
463
  params[:return_url] = return_url if return_url
465
464
  @@request.bulk_post('scoring/sendfile', params, 'multipart/form-data', filepath)
466
465
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zerobounce-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.2.0
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Zero Bounce
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2026-11-11 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: rest-client
@@ -94,6 +93,34 @@ dependencies:
94
93
  - - ">="
95
94
  - !ruby/object:Gem::Version
96
95
  version: '0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: racc
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: benchmark
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
97
124
  - !ruby/object:Gem::Dependency
98
125
  name: bundler
99
126
  requirement: !ruby/object:Gem::Requirement
@@ -282,7 +309,9 @@ dependencies:
282
309
  - - "~>"
283
310
  - !ruby/object:Gem::Version
284
311
  version: '6.4'
285
- description: A Ruby client for Zerobounce.net.
312
+ description: 'The official Ruby client for the ZeroBounce API: real-time and batch
313
+ email validation, AI scoring, bulk file validation and scoring, email finder and
314
+ domain search, the list evaluator, filters, the Inbox Placement Tester and Warmup.'
286
315
  email:
287
316
  - integrations@zerobounce.net
288
317
  executables: []
@@ -291,9 +320,9 @@ extra_rdoc_files: []
291
320
  files:
292
321
  - CHANGELOG.md
293
322
  - LICENSE
294
- - LICENSE.txt
295
323
  - README.md
296
324
  - lib/zerobounce.rb
325
+ - lib/zerobounce/api_key.rb
297
326
  - lib/zerobounce/api_urls.rb
298
327
  - lib/zerobounce/base_request.rb
299
328
  - lib/zerobounce/client.rb
@@ -302,7 +331,6 @@ files:
302
331
  - lib/zerobounce/error.rb
303
332
  - lib/zerobounce/get_file_helper.rb
304
333
  - lib/zerobounce/get_file_options.rb
305
- - lib/zerobounce/mock_request.rb
306
334
  - lib/zerobounce/priority3.rb
307
335
  - lib/zerobounce/request.rb
308
336
  - lib/zerobounce/response_error.rb
@@ -315,7 +343,9 @@ licenses:
315
343
  - MIT
316
344
  metadata:
317
345
  yard.run: yri
318
- post_install_message:
346
+ homepage_uri: https://zerobounce.net
347
+ source_code_uri: https://github.com/zerobounce/zero-bounce-ruby
348
+ changelog_uri: https://github.com/zerobounce/zero-bounce-ruby/blob/develop/CHANGELOG.md
319
349
  rdoc_options: []
320
350
  require_paths:
321
351
  - lib
@@ -330,8 +360,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
330
360
  - !ruby/object:Gem::Version
331
361
  version: '0'
332
362
  requirements: []
333
- rubygems_version: 3.4.19
334
- signing_key:
363
+ rubygems_version: 3.6.9
335
364
  specification_version: 4
336
- summary: A Ruby client for Zerobounce.net.
365
+ summary: 'Official ZeroBounce Ruby SDK: email validation, AI scoring, bulk files,
366
+ email finder and domain search'
337
367
  test_files: []
data/LICENSE.txt DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 ZeroBounce
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
@@ -1,105 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'zerobounce/base_request'
4
- require 'zerobounce/get_file_helper'
5
-
6
- module Zerobounce
7
-
8
- # Sends the HTTP request.
9
- class MockRequest < BaseRequest
10
-
11
- def self.get(path, params, content_type='application/json')
12
- response = self._get(Zerobounce.configuration.api_root_url, path, params, content_type)
13
- if __json_content_type__(response.headers[:content_type])
14
- response_body = response.body
15
- response_body_json = JSON.parse(response_body)
16
-
17
- raise (response_body_json['error']) if response_body_json.key?('error')
18
- raise (response_body_json['errors'][0]['error']) \
19
- if response_body_json.key?('errors') and \
20
- response_body_json['errors'].length > 0
21
-
22
- return response_body_json
23
- else
24
- return response
25
- end
26
- end
27
-
28
- def self.bulk_get(path, params, content_type='application/json')
29
- response = self._get(Zerobounce.configuration.bulk_api_root_url, path, params, content_type)
30
- if __json_content_type__(response.headers[:content_type])
31
- response_body = response.body
32
- response_body_json = JSON.parse(response_body)
33
- return response_body_json unless response_body_json.is_a?(Hash)
34
-
35
- raise (response_body_json['error']) if response_body_json.key?('error')
36
- raise (response_body_json['errors'][0]['error']) \
37
- if response_body_json.key?('errors') and \
38
- response_body_json['errors'].length > 0
39
-
40
- return response_body_json
41
- else
42
- return response.body
43
- end
44
- end
45
-
46
- def self.bulk_getfile(path, params)
47
- response = self._get(Zerobounce.configuration.bulk_api_root_url, path, params, 'application/json')
48
- GetFileHelper.process_getfile_response(response)
49
- end
50
-
51
- def self.post(path, params, content_type='application/json', filepath=nil)
52
- response = self._post(Zerobounce.configuration.api_root_url, path, params, \
53
- content_type, filepath)
54
- if __json_content_type__(response.headers[:content_type])
55
- response_body = response.body
56
- response_body_json = JSON.parse(response_body)
57
-
58
- raise (response_body_json['error']) if response_body_json.key?('error')
59
- raise (response_body_json['errors'][0]['error']) \
60
- if response_body_json.key?('errors') and \
61
- response_body_json['errors'].length > 0
62
-
63
- return response_body_json
64
- end
65
- return response.body
66
- end
67
-
68
- def self.bulk_post(path, params, content_type='application/json', filepath=nil)
69
- response = self._post(Zerobounce.configuration.bulk_api_root_url, path, params, \
70
- content_type, filepath)
71
- if __json_content_type__(response.headers[:content_type])
72
- return __parse_json_response__(response)
73
- end
74
- response.body
75
- end
76
-
77
- def self.bulk_root_get(path, params, content_type='application/json')
78
- response = self._get(bulk_api_root_without_v2, path, params, content_type)
79
- if __json_content_type__(response.headers[:content_type])
80
- return __parse_json_response__(response)
81
- end
82
- response.body
83
- end
84
-
85
- def self.bulk_root_getfile(path, params)
86
- response = self._get(bulk_api_root_without_v2, path, params, 'application/json')
87
- GetFileHelper.process_getfile_response(response)
88
- end
89
-
90
- def self.bulk_root_post(path, params, content_type='application/json', filepath=nil)
91
- response = self._post(bulk_api_root_without_v2, path, params, content_type, filepath)
92
- if __json_content_type__(response.headers[:content_type])
93
- return __parse_json_response__(response)
94
- end
95
- response.body
96
- end
97
-
98
- def self.bulk_root_form_post(path, params)
99
- response = self._post(bulk_api_root_without_v2, path, params, 'application/x-www-form-urlencoded')
100
- __parse_json_response__(response)
101
- end
102
-
103
- end
104
-
105
- end