From 44c3b0bc3cdaa99ecd2aa7b86b99de270397aee7 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:36:33 +0400 Subject: [PATCH 1/7] fix: redact API key in RestClient errors and harden publish CI Wrap RestClient failures with redacted messages and disable bundler-cache on publish workflow jobs to match npm publish hardening. Co-authored-by: Cursor --- .github/workflows/publish.yml | 4 ++-- lib/zerobounce/base_request.rb | 18 +++++++++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3650248..3c78f62 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -83,7 +83,7 @@ jobs: uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1 with: ruby-version: "3.2" - bundler-cache: true + bundler-cache: false - name: Run specs run: bundle exec rspec @@ -107,7 +107,7 @@ jobs: uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1 with: ruby-version: "3.2" - bundler-cache: true + bundler-cache: false - name: Configure RubyGems credentials uses: rubygems/configure-rubygems-credentials@dc5a8d8553e6ee01fc26761a49e99e733d17954a # v2.1.0 diff --git a/lib/zerobounce/base_request.rb b/lib/zerobounce/base_request.rb index 7f37dcf..eac495e 100644 --- a/lib/zerobounce/base_request.rb +++ b/lib/zerobounce/base_request.rb @@ -48,6 +48,18 @@ def self.__require_https__(url) url end + def self.__redact_api_key__(message) + key = Zerobounce.config.apikey + return message if key.nil? || key.to_s.empty? + message.to_s.gsub(key.to_s, '***') + end + + def self.__execute_request__(**kwargs) + RestClient::Request.execute(**kwargs) + rescue StandardError => e + raise e.class, __redact_api_key__(e.message) + end + def self._get(root, path, params, content_type='application/json') # puts path @@ -59,7 +71,7 @@ def self._get(root, path, params, content_type='application/json') url = "#{Zerobounce::BaseRequest.__root_without_trailing_slashes__(root)}/#{path}" Zerobounce::BaseRequest.__require_https__(url) - response = RestClient::Request.execute(method: :get, url: url, timeout: 120, headers: { params: params }) + response = __execute_request__(method: :get, url: url, timeout: 120, headers: { params: params }) return response end @@ -75,10 +87,10 @@ def self._post(root, path, params, content_type='application/json', filepath=nil if filepath or content_type == 'multipart/form-data' params[:file] = File.new(Zerobounce::BaseRequest.__safe_file_path__(filepath), 'rb') params[:multipart] = true - response = RestClient::Request.execute(method: :post, url: url, payload: params, timeout: 120) + response = __execute_request__(method: :post, url: url, payload: params, timeout: 120) elsif content_type == 'application/json' - response = RestClient::Request.execute( + response = __execute_request__( method: :post, url: url, payload: params.to_json, From eb09c8070168ae51da26fa1bd09490c900da2e77 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:52:27 +0400 Subject: [PATCH 2/7] feat: v2 validate parity (verify_plus, activity_data, batch timeout) Optional keyword params on validate and validate_batch per ZeroBounce v2 docs. Co-authored-by: Cursor --- lib/zerobounce.rb | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/zerobounce.rb b/lib/zerobounce.rb index 7b4c9c2..0d3fd0c 100644 --- a/lib/zerobounce.rb +++ b/lib/zerobounce.rb @@ -80,8 +80,15 @@ def configure # "zipcode": null, # "processed_at": "2023-04-18 12:09:39.074" # } - def validate(email, ip_address=nil) + def validate(email, ip_address=nil, timeout: nil, activity_data: nil, verify_plus: nil) params = {email: email, ip_address: ip_address} + params[:timeout] = timeout unless timeout.nil? + unless activity_data.nil? + params[:activity_data] = activity_data ? 'true' : 'false' + end + unless verify_plus.nil? + params[:verify_plus] = verify_plus ? 'true' : 'false' + end @@request.get('validate', params) end @@ -217,7 +224,7 @@ def activity(email) # }, # ... # ] - def validate_batch(emails, ip_addresses=[]) + def validate_batch(emails, ip_addresses=[], timeout: nil, activity_data: nil, verify_plus: nil) raise ArgumentError.new if emails.class != Array emails.each do |email| @@ -232,6 +239,9 @@ def validate_batch(emails, ip_addresses=[]) }) end params = {email_batch: email_batch} + params[:timeout] = timeout unless timeout.nil? + params[:activity_data] = activity_data unless activity_data.nil? + params[:verify_plus] = verify_plus unless verify_plus.nil? results = @@request.post('validatebatch', params) return results['email_batch'] end From 6950f12a7741b584f5703268581ff38576335ba9 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 01:50:12 +0400 Subject: [PATCH 3/7] feat: add score_email for GET/POST /v2/scoring Co-authored-by: Cursor --- lib/zerobounce.rb | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/zerobounce.rb b/lib/zerobounce.rb index 0d3fd0c..52634a3 100644 --- a/lib/zerobounce.rb +++ b/lib/zerobounce.rb @@ -190,6 +190,21 @@ def activity(email) @@request.get('activity', params) end + # Real-time AI scoring for a single email (GET /scoring by default). + # + # @param [String] :email Email to score + # @param [Boolean] :use_post When true, uses POST /scoring with form body + # + # @return [Hash] e.g. {"email"=>"mark@gmail.com", "score"=>10} + def score_email(email, use_post: false) + params = { email: email } + if use_post + @@request.post('scoring', params, 'application/x-www-form-urlencoded') + else + @@request.get('scoring', params) + end + end + # Validate email batch # # @param [Array] :emails List of email addresses to validate. From fcdf326f49ca56b25926e2eeea5a00a7ddbafd8c Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:45:20 +0400 Subject: [PATCH 4/7] feat: Priority 3 API parity (bulk finder, filters, S3) Add bulk email finder, domain search, list evaluator, filters, and S3 helpers with WebMock-backed RSpec coverage. Co-authored-by: Cursor --- lib/zerobounce.rb | 2 + lib/zerobounce/base_request.rb | 17 +++++ lib/zerobounce/mock_request.rb | 34 +++++++-- lib/zerobounce/priority3.rb | 117 +++++++++++++++++++++++++++++ lib/zerobounce/request.rb | 34 +++++++-- spec/fixtures/priority3_finder.csv | 2 + spec/priority3_spec.rb | 40 ++++++++++ 7 files changed, 230 insertions(+), 16 deletions(-) create mode 100644 lib/zerobounce/priority3.rb create mode 100644 spec/fixtures/priority3_finder.csv create mode 100644 spec/priority3_spec.rb diff --git a/lib/zerobounce.rb b/lib/zerobounce.rb index 52634a3..8865ca9 100644 --- a/lib/zerobounce.rb +++ b/lib/zerobounce.rb @@ -15,11 +15,13 @@ require 'zerobounce/mock_request' require 'zerobounce/configuration' require 'zerobounce/api_urls' +require 'zerobounce/priority3' # Validate an email address with Zerobounce.net module Zerobounce class << self + include Priority3 attr_writer :configuration @@request = ENV['TEST']=='unit' ? MockRequest : Request diff --git a/lib/zerobounce/base_request.rb b/lib/zerobounce/base_request.rb index eac495e..3c2fd8a 100644 --- a/lib/zerobounce/base_request.rb +++ b/lib/zerobounce/base_request.rb @@ -97,6 +97,8 @@ def self._post(root, path, params, content_type='application/json', filepath=nil timeout: 120, headers: { content_type: :json, accept: :json } ) + elsif content_type == 'application/x-www-form-urlencoded' + response = __execute_request__(method: :post, url: url, payload: params, timeout: 120) else # this shouldn't happen raise Error.new('Unknown content type specified in request.'\ @@ -105,5 +107,20 @@ def self._post(root, path, params, content_type='application/json', filepath=nil return response end + def self.bulk_api_root_without_v2 + root = Zerobounce.configuration.bulk_api_root_url.to_s.sub(%r{/v2/?$}, '') + __root_without_trailing_slashes__(root) + end + + def self.__parse_json_response__(response) + response_body = response.body + response_body_json = JSON.parse(response_body) + raise(response_body_json['error']) if response_body_json.key?('error') + if response_body_json.key?('errors') && response_body_json['errors'].length > 0 + raise(response_body_json['errors'][0]['error']) + end + response_body_json + end + end end \ No newline at end of file diff --git a/lib/zerobounce/mock_request.rb b/lib/zerobounce/mock_request.rb index 1a2e398..e6e935c 100644 --- a/lib/zerobounce/mock_request.rb +++ b/lib/zerobounce/mock_request.rb @@ -68,17 +68,35 @@ def self.bulk_post(path, params, content_type='application/json', filepath=nil) response = self._post(Zerobounce.configuration.bulk_api_root_url, path, params, \ content_type, filepath) if response.headers[:content_type] == 'application/json' - response_body = response.body - response_body_json = JSON.parse(response_body) + return __parse_json_response__(response) + end + response.body + end - raise (response_body_json['error']) if response_body_json.key?('error') - raise (response_body_json['errors'][0]['error']) \ - if response_body_json.key?('errors') and \ - response_body_json['errors'].length > 0 + def self.bulk_root_get(path, params, content_type='application/json') + response = self._get(bulk_api_root_without_v2, path, params, content_type) + if response.headers[:content_type] == 'application/json' + return __parse_json_response__(response) + end + response.body + end - return response_body_json + def self.bulk_root_getfile(path, params) + response = self._get(bulk_api_root_without_v2, path, params, 'application/json') + GetFileHelper.process_getfile_response(response) + end + + def self.bulk_root_post(path, params, content_type='application/json', filepath=nil) + response = self._post(bulk_api_root_without_v2, path, params, content_type, filepath) + if response.headers[:content_type] == 'application/json' + return __parse_json_response__(response) end - return response.body + response.body + end + + def self.bulk_root_form_post(path, params) + response = self._post(bulk_api_root_without_v2, path, params, 'application/x-www-form-urlencoded') + __parse_json_response__(response) end end diff --git a/lib/zerobounce/priority3.rb b/lib/zerobounce/priority3.rb new file mode 100644 index 0000000..0b35a8c --- /dev/null +++ b/lib/zerobounce/priority3.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +module Zerobounce + module Priority3 + EMAIL_FINDER_BULK_BASE = 'email-finder' + DOMAIN_SEARCH_BULK_BASE = 'domain-search' + + def zb_request + Zerobounce.class_variable_get(:@@request) + end + + def send_email_finder_file(filepath, domain_column:, first_name_column: nil, last_name_column: nil, + middle_name_column: nil, full_name_column: nil, has_header_row: nil) + raise ArgumentError, 'domain_column is required' if domain_column.nil? || domain_column.to_i < 1 + + params = { domain_column: domain_column } + params[:has_header_row] = true if has_header_row + params[:first_name_column] = first_name_column if first_name_column + params[:last_name_column] = last_name_column if last_name_column + params[:middle_name_column] = middle_name_column if middle_name_column + params[:full_name_column] = full_name_column if full_name_column + zb_request.bulk_root_post("#{EMAIL_FINDER_BULK_BASE}/sendfile", params, 'multipart/form-data', filepath) + end + + def email_finder_file_status(file_id) + zb_request.bulk_root_get("#{EMAIL_FINDER_BULK_BASE}/filestatus", { file_id: file_id }) + end + + def email_finder_file_get(file_id) + zb_request.bulk_root_getfile("#{EMAIL_FINDER_BULK_BASE}/getfile", { file_id: file_id }) + end + + def email_finder_file_delete(file_id) + zb_request.bulk_root_get("#{EMAIL_FINDER_BULK_BASE}/deletefile", { file_id: file_id }) + end + + def send_domain_search_file(filepath, domain_column:, has_header_row: nil) + raise ArgumentError, 'domain_column is required' if domain_column.nil? || domain_column.to_i < 1 + + params = { domain_column: domain_column } + params[:has_header_row] = true if has_header_row + zb_request.bulk_root_post("#{DOMAIN_SEARCH_BULK_BASE}/sendfile", params, 'multipart/form-data', filepath) + end + + def domain_search_file_status(file_id) + zb_request.bulk_root_get("#{DOMAIN_SEARCH_BULK_BASE}/filestatus", { file_id: file_id }) + end + + def domain_search_file_get(file_id) + zb_request.bulk_root_getfile("#{DOMAIN_SEARCH_BULK_BASE}/getfile", { file_id: file_id }) + end + + def domain_search_file_delete(file_id) + zb_request.bulk_root_get("#{DOMAIN_SEARCH_BULK_BASE}/deletefile", { file_id: file_id }) + end + + def send_list_evaluator_file(filepath, email_address_column:) + raise ArgumentError, 'email_address_column is required' if email_address_column.nil? || email_address_column.to_i < 1 + + params = { email_address_column: email_address_column } + zb_request.bulk_post('listevaluator/', params, 'multipart/form-data', filepath) + end + + def get_list_evaluator_result(file_id) + zb_request.bulk_get("listevaluator/#{file_id}/", {}) + end + + def send_evaluated_file(filepath) + zb_request.bulk_post('send-evaluated-file/', {}, 'multipart/form-data', filepath) + end + + def add_filter(rule:, target:, value:) + zb_request.post('filters/add', { rule: rule, target: target, value: value }, 'application/x-www-form-urlencoded') + end + + def list_filters(use_post: false) + if use_post + zb_request.post('filters/list', {}, 'application/x-www-form-urlencoded') + else + zb_request.get('filters/list', {}) + end + end + + def delete_filter(rule:, target:, value:) + zb_request.post('filters/delete', { rule: rule, target: target, value: value }, 'application/x-www-form-urlencoded') + end + + def validate_s3_file(file_name:, **options) + raise ArgumentError, 'file_name is required' if file_name.nil? || file_name.to_s.empty? + + params = { file_name: file_name } + options.each do |key, value| + next if value.nil? + params[key] = value.is_a?(TrueClass) || value.is_a?(FalseClass) ? (value ? 'true' : 'false') : value + end + zb_request.bulk_root_form_post('validateS3file', params) + end + + def score_s3_file(file_name:, **options) + raise ArgumentError, 'file_name is required' if file_name.nil? || file_name.to_s.empty? + + params = { file_name: file_name } + options.each do |key, value| + next if value.nil? + params[key] = value.is_a?(TrueClass) || value.is_a?(FalseClass) ? (value ? 'true' : 'false') : value + end + zb_request.bulk_root_form_post('scoreS3file', params) + end + + def export_s3_file(file_id:, export_bucket:) + raise ArgumentError, 'file_id is required' if file_id.nil? || file_id.to_s.empty? + raise ArgumentError, 'export_bucket is required' if export_bucket.nil? || export_bucket.to_s.empty? + + zb_request.bulk_root_form_post('exportS3file', { file_id: file_id, export_bucket: export_bucket }) + end + end +end diff --git a/lib/zerobounce/request.rb b/lib/zerobounce/request.rb index 15d8381..7a72f4c 100644 --- a/lib/zerobounce/request.rb +++ b/lib/zerobounce/request.rb @@ -75,17 +75,35 @@ def self.bulk_post(path, params, content_type='application/json', filepath=nil) response = self._post(Zerobounce.configuration.bulk_api_root_url, path, params, \ content_type, filepath) if response.headers[:content_type] == 'application/json' - response_body = response.body - response_body_json = JSON.parse(response_body) + return __parse_json_response__(response) + end + return response.body + end - raise (response_body_json['error']) if response_body_json.key?('error') - raise (response_body_json['errors'][0]['error']) \ - if response_body_json.key?('errors') and \ - response_body_json['errors'].length > 0 + def self.bulk_root_get(path, params, content_type='application/json') + response = self._get(bulk_api_root_without_v2, path, params, content_type) + if response.headers[:content_type] == 'application/json' + return __parse_json_response__(response) + end + response.body + end - return response_body_json + def self.bulk_root_getfile(path, params) + response = self._get(bulk_api_root_without_v2, path, params, 'application/json') + GetFileHelper.process_getfile_response(response) + end + + def self.bulk_root_post(path, params, content_type='application/json', filepath=nil) + response = self._post(bulk_api_root_without_v2, path, params, content_type, filepath) + if response.headers[:content_type] == 'application/json' + return __parse_json_response__(response) end - return response.body + response.body + end + + def self.bulk_root_form_post(path, params) + response = self._post(bulk_api_root_without_v2, path, params, 'application/x-www-form-urlencoded') + __parse_json_response__(response) end end diff --git a/spec/fixtures/priority3_finder.csv b/spec/fixtures/priority3_finder.csv new file mode 100644 index 0000000..ffb0271 --- /dev/null +++ b/spec/fixtures/priority3_finder.csv @@ -0,0 +1,2 @@ +domain,name +acme.com,Jane diff --git a/spec/priority3_spec.rb b/spec/priority3_spec.rb new file mode 100644 index 0000000..f0a1f63 --- /dev/null +++ b/spec/priority3_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'webmock/rspec' + +describe Zerobounce do + let(:api_key) { 'dummy_key' } + + before do + Zerobounce.configure { |c| c.apikey = api_key } + end + + it 'send_email_finder_file posts to bulk email-finder sendfile' do + fixture = File.join(Dir.pwd, 'spec', 'fixtures', 'priority3_finder.csv') + FileUtils.mkdir_p(File.dirname(fixture)) + File.write(fixture, "domain,name\nacme.com,Jane\n") + + stub_request(:post, %r{\Ahttps://bulkapi\.zerobounce\.net/email-finder/sendfile}) + .to_return( + status: 200, + body: '{"success":true,"file_id":"f1","file_name":"out.csv"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = Zerobounce.send_email_finder_file(fixture, domain_column: 1) + expect(result['success']).to be true + end + + it 'validate_s3_file posts to bulk validateS3file' do + stub_request(:post, %r{\Ahttps://bulkapi\.zerobounce\.net/validateS3file}) + .to_return( + status: 200, + body: '{"success":true}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = Zerobounce.validate_s3_file(file_name: 'bucket/key.csv') + expect(result['success']).to be true + end +end From 6e0e2f60549658eff65370cf9e8e32ddf5c0e169 Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:17:25 +0400 Subject: [PATCH 5/7] feat: POST /validate transport parity and listS3files Add form-encoded POST option on single validate (alongside GET) and GET listS3files on bulkapi root, with unit tests and API parity doc updates. --- lib/zerobounce.rb | 8 ++++++-- lib/zerobounce/priority3.rb | 7 +++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/lib/zerobounce.rb b/lib/zerobounce.rb index 8865ca9..6624301 100644 --- a/lib/zerobounce.rb +++ b/lib/zerobounce.rb @@ -82,7 +82,7 @@ def configure # "zipcode": null, # "processed_at": "2023-04-18 12:09:39.074" # } - def validate(email, ip_address=nil, timeout: nil, activity_data: nil, verify_plus: nil) + def validate(email, ip_address=nil, timeout: nil, activity_data: nil, verify_plus: nil, use_post: false) params = {email: email, ip_address: ip_address} params[:timeout] = timeout unless timeout.nil? unless activity_data.nil? @@ -91,7 +91,11 @@ def validate(email, ip_address=nil, timeout: nil, activity_data: nil, verify_plu unless verify_plus.nil? params[:verify_plus] = verify_plus ? 'true' : 'false' end - @@request.get('validate', params) + if use_post + @@request.post('validate', params, 'application/x-www-form-urlencoded') + else + @@request.get('validate', params) + end end # Get the number of remaining credits on the account. diff --git a/lib/zerobounce/priority3.rb b/lib/zerobounce/priority3.rb index 0b35a8c..89fc4b2 100644 --- a/lib/zerobounce/priority3.rb +++ b/lib/zerobounce/priority3.rb @@ -85,6 +85,13 @@ def delete_filter(rule:, target:, value:) zb_request.post('filters/delete', { rule: rule, target: target, value: value }, 'application/x-www-form-urlencoded') end + def list_s3_files(bucket: nil, search: nil) + params = {} + params[:bucket] = bucket unless bucket.nil? || bucket.to_s.empty? + params[:search] = search unless search.nil? || search.to_s.empty? + zb_request.bulk_get('listS3files', params) + end + def validate_s3_file(file_name:, **options) raise ArgumentError, 'file_name is required' if file_name.nil? || file_name.to_s.empty? From 2f7f15a8092cf537dc4633fc07e63f201c32da9d Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:50:34 +0400 Subject: [PATCH 6/7] test: add list_s3_files WebMock spec and array bulk_get support listS3files returns a JSON array; allow bulk_get to return non-hash payloads so the new priority3 spec can run. Co-authored-by: Cursor --- lib/zerobounce/mock_request.rb | 1 + lib/zerobounce/request.rb | 1 + spec/priority3_spec.rb | 13 +++++++++++++ 3 files changed, 15 insertions(+) diff --git a/lib/zerobounce/mock_request.rb b/lib/zerobounce/mock_request.rb index e6e935c..9ad50da 100644 --- a/lib/zerobounce/mock_request.rb +++ b/lib/zerobounce/mock_request.rb @@ -30,6 +30,7 @@ def self.bulk_get(path, params, content_type='application/json') if response.headers[:content_type] == 'application/json' response_body = response.body response_body_json = JSON.parse(response_body) + return response_body_json unless response_body_json.is_a?(Hash) raise (response_body_json['error']) if response_body_json.key?('error') raise (response_body_json['errors'][0]['error']) \ diff --git a/lib/zerobounce/request.rb b/lib/zerobounce/request.rb index 7a72f4c..c23cf8f 100644 --- a/lib/zerobounce/request.rb +++ b/lib/zerobounce/request.rb @@ -34,6 +34,7 @@ def self.bulk_get(path, params, content_type='application/json') if response.headers[:content_type] == 'application/json' response_body = response.body response_body_json = JSON.parse(response_body) + return response_body_json unless response_body_json.is_a?(Hash) raise (response_body_json['error']) if response_body_json.key?('error') raise (response_body_json['errors'][0]['error']) \ diff --git a/spec/priority3_spec.rb b/spec/priority3_spec.rb index f0a1f63..8cebed0 100644 --- a/spec/priority3_spec.rb +++ b/spec/priority3_spec.rb @@ -37,4 +37,17 @@ result = Zerobounce.validate_s3_file(file_name: 'bucket/key.csv') expect(result['success']).to be true end + + it 'list_s3_files gets bulk listS3files' do + stub_request(:get, %r{\Ahttps://bulkapi\.zerobounce\.net/v2/listS3files}) + .to_return( + status: 200, + body: '[{"file_name":"bucket/key.csv"}]', + headers: { 'Content-Type' => 'application/json' } + ) + + result = Zerobounce.list_s3_files(bucket: 'my-bucket', search: 'key') + expect(result).to be_an(Array) + expect(result.first['file_name']).to eq('bucket/key.csv') + end end From f8cf2fd5ee29c09ab6872684ab431e115a0d239a Mon Sep 17 00:00:00 2001 From: Francisc Balint <7178547+franciscbalint@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:50:30 +0400 Subject: [PATCH 7/7] fix: return short API errors and keep batch item errors HTML and Cloudflare bodies were raised in full, an account-level batch failure looked successful, and an explicit false header flag was left off the request. Co-authored-by: Cursor --- .github/workflows/sdk_ci.yml | 3 + AGENTS.md | 55 +++++++++++ CHANGELOG.md | 141 ++++++++++++++++++++++---- lib/zerobounce.rb | 33 ++++++- lib/zerobounce/base_request.rb | 46 +++++++-- lib/zerobounce/client.rb | 22 +++++ lib/zerobounce/get_file_helper.rb | 10 +- lib/zerobounce/priority3.rb | 12 +-- lib/zerobounce/request.rb | 61 ++++++------ lib/zerobounce/response_error.rb | 87 ++++++++++++++++ spec/mock_spec.rb | 46 ++++----- spec/offline_api_spec.rb | 158 ++++++++++++++++++++++++++++++ 12 files changed, 576 insertions(+), 98 deletions(-) create mode 100644 AGENTS.md create mode 100644 lib/zerobounce/client.rb create mode 100644 lib/zerobounce/response_error.rb create mode 100644 spec/offline_api_spec.rb diff --git a/.github/workflows/sdk_ci.yml b/.github/workflows/sdk_ci.yml index 49d7a27..a0ca519 100644 --- a/.github/workflows/sdk_ci.yml +++ b/.github/workflows/sdk_ci.yml @@ -27,5 +27,8 @@ jobs: ruby-version: "3.2" bundler-cache: true + - name: RuboCop + run: bundle exec rubocop + - name: Run specs run: bundle exec rspec diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6ee21c0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,55 @@ +# AGENTS.md + +Guidance for AI coding agents working in this repository. + +## Project + +`zero-bounce-ruby` publishes the `zerobounce-sdk` gem. Callers set `Zerobounce.config.apikey`, or construct `Zerobounce::Client.new(api_key)`. Module methods share one configuration object. A `Client` stores the key on the current thread for the duration of each call. + +## Layout + +- `lib/zerobounce.rb`: public methods for validate, credits, batch, files, scoring, and finder. +- `lib/zerobounce/priority3.rb`: bulk email finder, domain search, list evaluator, filters, and S3. +- `lib/zerobounce/base_request.rb` and `request.rb`: RestClient transport. `response_error.rb` turns HTML, empty bodies, and non-JSON into a short `RuntimeError`. +- `lib/zerobounce/version.rb`: `Zerobounce::VERSION`. `Gemfile.lock` pins `zerobounce-sdk (X.Y.Z)` to that same version. +- `spec/offline_api_spec.rb`: offline suite. WebMock answers without the network. +- The user agent is `ZerobounceRubyGem/#{Zerobounce::VERSION}`. + +## Commands + +```bash +bundle exec rspec +bundle exec rubocop +``` + +`rspec` is the test command. `rubocop` is the lint step; there is no separate typechecker. CI (`.github/workflows/sdk_ci.yml`) runs both on Ruby 3.2. This checkout may be a newer Ruby where RuboCop cannot load `benchmark`; use 3.2 to lint. + +## Conventions + +- **One key per caller.** `Zerobounce.config` is process-wide. Do not reuse it across users. Use `Zerobounce::Client` (or `with_api_key`) so two threads cannot observe each other's keys. The offline concurrency spec does this. +- **Column indexes.** `0` is a real column. Do not drop it with `if column`. Omit an optional column only when the argument is `nil`. `domain_column` of `0` is rejected. `Import`-style defaults are not used here: the send methods default name columns to 1–4, and an explicit `0` is sent. +- **Optional parameters are omitted when unset.** `timeout`, `activity_data`, `verify_plus`, and `allow_phase_2` are omitted when `nil`. Explicit `false` is sent as `false`. +- **Booleans on multipart are the strings `true` and `false`.** `has_header_row: false` on bulk email finder and domain search is sent. `allow_phase_2` is validation `sendfile` only. `activity_data` on get-file is validation only. +- **`validate` timeout is 3–60 seconds. `validate_batch` timeout is 10–120 seconds.** Out-of-range values raise `ArgumentError` before a request. +- **Failures are `RuntimeError` (or `ArgumentError` for bad arguments).** The message must be short: no raw HTML, no megabyte body, no `JSON::ParserError` dump, and no `Error: Error:` prefix. An account-level `validate_batch` error (`email_address` `all`) raises. A per-address error stays on the returned hash. +- **`validate_batch` returns the hash**, with `email_batch` and `errors`, not only the array. +- **Tests replace HTTP with WebMock.** Do not call the live API from `rspec`. +- Match the surrounding style. Do not reformat unrelated code. + +## Changelog + +`CHANGELOG.md` follows [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/), and the project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html). + +- Add every user-visible change to `## [Unreleased]` in the same commit as the change. +- Group entries under these headings, in this order, omitting empty ones: `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`. +- Write for SDK users: name the method, option, or behaviour, and say what changed. Leave out CI-only changes, internal refactors, and fixes to code that was never released. +- At 2.y.z, a breaking change bumps the major version, a new method or option bumps the minor version, and fixes alone bump the patch version. + +## Releasing + +Publishing and pushing tags need a maintainer's explicit approval. Do not bump the version, tag, or publish unless they asked. + +1. Move `[Unreleased]` entries under `## [x.y.z] - YYYY-MM-DD`, leave an empty `[Unreleased]` section, and update the compare links at the bottom of `CHANGELOG.md`. +2. Set the same version in `lib/zerobounce/version.rb` and in the `zerobounce-sdk (X.Y.Z)` pin in `Gemfile.lock`. A frozen CI install fails if those two differ. The gemspec reads `Zerobounce::VERSION`; do not add a third copy. +3. `bundle exec rubocop` and `bundle exec rspec`. +4. Tag `vx.y.z` on that commit and run the Publish workflow with that tag. It publishes to RubyGems. diff --git a/CHANGELOG.md b/CHANGELOG.md index 961e679..6ddb3f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,33 +1,136 @@ -# 2.1.7 +# Changelog -* Update Gemfile.lock for v2.1.6 release. +All notable changes to this project will be documented in this file. -# 2.1.6 +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -* Route `validatebatch` requests to `api.zerobounce.net` instead of `bulkapi.zerobounce.net`. +## [Unreleased] -# 2.1.1 +### Added -* Raise development and transitive dependency floors to address security advisories for REXML, Addressable, and YARD (updated `Gemfile.lock`; see `Gemfile` and `zerobounce.gemspec`). +- `validate` options `timeout`, `activity_data`, `verify_plus`, and `use_post` (form-encoded `POST /validate`). +- `validate_batch` options `timeout`, `activity_data`, and `verify_plus`. +- `score_email` for `GET` and `POST /scoring`. +- Bulk email finder, bulk domain search, list evaluator, filters, and S3 (`validate_s3_file`, `score_s3_file`, `export_s3_file`, `list_s3_files`). +- `Zerobounce::Client` so each API key stays on its own thread. Module methods still share `Zerobounce.config`. -# 0.3.0 -* Update ruby and bundler version +### Changed -# 0.2.0 +- `validate_batch` returns the full response hash (`email_batch` and `errors`). A per-address error stays on that hash. +- API requests time out after 120 seconds and do not follow redirects. +- Multipart booleans are sent as `true` or `false`. -* Update faraday and faraday_middleware dependency requirements. -* Update rubocop dev dependencies and fixed failures. -* Updated minimum ruby version to 2.5. +### Fixed -# 0.1.2 +- An HTML "Access denied" page is a short `RuntimeError`. An HTML page with status 200 is an error as well, including `validate_file_get`, instead of file text or a JSON parse. +- Empty and non-JSON bodies are a short error. A repeated `Error:` / `[Error]:` prefix is removed. +- `validate_batch` raises for an account-level failure (HTTP 200, error for address `all`, such as an invalid API key or no credits). +- Bulk email finder and domain search send `has_header_row` when it is false. A column index of `0` is sent when the caller passes it. +- An empty API key is rejected before a request is sent. +- `validate` timeout must be 3–60 seconds. `validate_batch` timeout must be 10–120 seconds. -* Removed the double quote from the body check since it appears a space is added after the colon. -* Added changelog file. +### Security -# 0.1.1 +- Redirects are disabled, so a 3xx response cannot send the API key to another host. +- Error messages no longer include the raw HTML access-denied page. +- RestClient error text is redacted of the API key. -* Removed safe accessor usage to work with older versions of Ruby. +## [2.1.8] - 2026-08-14 -# 0.1.0 +### Fixed -* Added support for V2 of Zerobounce API. +- `Gemfile.lock` matches the gemspec version, so a frozen install can resolve the gem. + +## [2.1.6] - 2026-06-18 + +### Fixed + +- `validate_batch` posts to `api.zerobounce.net` instead of `bulkapi.zerobounce.net`. + +## [2.1.5] - 2026-06-10 + +### Added + +- `catchall_domain` on validation results. + +## [2.1.1] - 2026-04-08 + +### Security + +- Development and transitive dependency floors were raised for REXML, Addressable, and YARD. + +## [2.1.0] - 2026-04-08 + +### Added + +- Optional bulk get-file parameters, including download type and activity data. + +## [2.0.12] - 2026-03-03 + +### Changed + +- License the project under MIT. + +## [2.0.1] - 2026-03-03 + +### Security + +- Uploaded file paths must stay under the current directory. + +## [2.0.0] - 2026-03-03 + +### Changed + +- Release the 2.0 line of the gem. + +## [1.3.0] - 2025-10-22 + +### Added + +- `find_email` and `find_domain`. + +### Deprecated + +- `guessformat`. Use `find_email` or `find_domain`. + +## [0.3.0] - 2023-09-18 + +### Changed + +- Ruby and Bundler version requirements. + +## [0.2.0] - 2023-09-12 + +### Changed + +- Faraday requirements, and the minimum Ruby version is 2.5. + +## [0.1.2] - 2023-04-18 + +### Fixed + +- Error-body detection no longer depends on a quote immediately after the colon. + +## [0.1.1] - 2023-04-12 + +### Fixed + +- Removed safe-navigation calls so the client runs on older Ruby. + +## [0.1.0] - 2023-04-07 + +### Added + +- Initial client for ZeroBounce API v2. + +[Unreleased]: https://github.com/zerobounce/zero-bounce-ruby/compare/v2.1.8...HEAD +[2.1.8]: https://github.com/zerobounce/zero-bounce-ruby/compare/v2.1.6...v2.1.8 +[2.1.6]: https://github.com/zerobounce/zero-bounce-ruby/compare/v2.1.5...v2.1.6 +[2.1.5]: https://github.com/zerobounce/zero-bounce-ruby/compare/v2.1.1...v2.1.5 +[2.1.1]: https://github.com/zerobounce/zero-bounce-ruby/compare/v2.1.0...v2.1.1 +[2.1.0]: https://github.com/zerobounce/zero-bounce-ruby/compare/v2.0.12...v2.1.0 +[2.0.12]: https://github.com/zerobounce/zero-bounce-ruby/compare/v2.0.1...v2.0.12 +[2.0.1]: https://github.com/zerobounce/zero-bounce-ruby/compare/v2.0.0...v2.0.1 +[2.0.0]: https://github.com/zerobounce/zero-bounce-ruby/compare/v1.3.0...v2.0.0 +[1.3.0]: https://github.com/zerobounce/zero-bounce-ruby/releases/tag/v1.3.0 diff --git a/lib/zerobounce.rb b/lib/zerobounce.rb index 6624301..681657d 100644 --- a/lib/zerobounce.rb +++ b/lib/zerobounce.rb @@ -5,6 +5,8 @@ require 'tempfile' require 'zerobounce/error' +require 'zerobounce/response_error' +require 'zerobounce/client' require 'zerobounce/version' require 'zerobounce/download_type' require 'zerobounce/get_file_options' @@ -54,6 +56,15 @@ def configure yield configuration end + # Run a block with an API key that is visible only on this thread. + def with_api_key(api_key) + previous = Thread.current[:zerobounce_api_key] + Thread.current[:zerobounce_api_key] = api_key + yield + ensure + Thread.current[:zerobounce_api_key] = previous + end + # Validates the email address and gets geoip information for an IP if provided. # # @param [String] :email The email address to validate. @@ -83,6 +94,12 @@ def configure # "processed_at": "2023-04-18 12:09:39.074" # } def validate(email, ip_address=nil, timeout: nil, activity_data: nil, verify_plus: nil, use_post: false) + unless timeout.nil? + timeout_i = Integer(timeout) + if timeout_i < 3 || timeout_i > 60 + raise ArgumentError, 'Invalid parameter: timeout (must be between 3 and 60 seconds)' + end + end params = {email: email, ip_address: ip_address} params[:timeout] = timeout unless timeout.nil? unless activity_data.nil? @@ -246,6 +263,12 @@ def score_email(email, use_post: false) # ... # ] def validate_batch(emails, ip_addresses=[], timeout: nil, activity_data: nil, verify_plus: nil) + unless timeout.nil? + timeout_i = Integer(timeout) + if timeout_i < 10 || timeout_i > 120 + raise ArgumentError, 'Invalid parameter: timeout (must be between 10 and 120 seconds)' + end + end raise ArgumentError.new if emails.class != Array emails.each do |email| @@ -264,7 +287,7 @@ def validate_batch(emails, ip_addresses=[], timeout: nil, activity_data: nil, ve params[:activity_data] = activity_data unless activity_data.nil? params[:verify_plus] = verify_plus unless verify_plus.nil? results = @@request.post('validatebatch', params) - return results['email_batch'] + results end # Validate CSV file @@ -298,10 +321,10 @@ def validate_file_send( email_address_column: email_address_column, has_header_row: has_header_row, } - params[:first_name_column] = first_name_column if first_name_column - params[:last_name_column] = last_name_column if last_name_column - params[:gender_column] = gender_column if gender_column - params[:return_url] = return_url if return_url + params[:first_name_column] = first_name_column unless first_name_column.nil? + params[:last_name_column] = last_name_column unless last_name_column.nil? + params[:gender_column] = gender_column unless gender_column.nil? + params[:return_url] = return_url unless return_url.nil? || return_url.to_s.empty? unless allow_phase_2.nil? params[:allow_phase_2] = allow_phase_2 ? 'true' : 'false' end diff --git a/lib/zerobounce/base_request.rb b/lib/zerobounce/base_request.rb index 3c2fd8a..3e0a5ec 100644 --- a/lib/zerobounce/base_request.rb +++ b/lib/zerobounce/base_request.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'json' +require 'zerobounce/response_error' # https://github.com/rest-client/rest-client require 'rest-client' @@ -54,8 +55,18 @@ def self.__redact_api_key__(message) message.to_s.gsub(key.to_s, '***') end + def self.current_api_key + key = Thread.current[:zerobounce_api_key] + key.nil? ? Zerobounce.config.apikey : key + end + def self.__execute_request__(**kwargs) + kwargs[:max_redirects] = 0 + user_agent = Zerobounce.config.headers && Zerobounce.config.headers[:user_agent] + kwargs[:headers] = (kwargs[:headers] || {}).merge(user_agent: user_agent) if user_agent RestClient::Request.execute(**kwargs) + rescue RestClient::ExceptionWithResponse => e + raise ResponseError.message_from(e.response) rescue StandardError => e raise e.class, __redact_api_key__(e.message) end @@ -65,9 +76,10 @@ def self._get(root, path, params, content_type='application/json') # puts path # puts Zerobounce.config.apikey - raise ("API key must be assigned") if not Zerobounce.config.apikey + api_key = current_api_key + raise ('API key must be assigned') if api_key.nil? || api_key.to_s.strip.empty? - params[:api_key] = Zerobounce.config.apikey + params[:api_key] = api_key url = "#{Zerobounce::BaseRequest.__root_without_trailing_slashes__(root)}/#{path}" Zerobounce::BaseRequest.__require_https__(url) @@ -77,14 +89,18 @@ def self._get(root, path, params, content_type='application/json') def self._post(root, path, params, content_type='application/json', filepath=nil) - raise ("API key must be assigned") if not Zerobounce.config.apikey + api_key = current_api_key + raise ('API key must be assigned') if api_key.nil? || api_key.to_s.strip.empty? - params[:api_key] = Zerobounce.config.apikey + params[:api_key] = api_key url = "#{Zerobounce::BaseRequest.__root_without_trailing_slashes__(root)}/#{path}" Zerobounce::BaseRequest.__require_https__(url) response = nil if filepath or content_type == 'multipart/form-data' + params.each do |key, value| + params[key] = value ? 'true' : 'false' if value == true || value == false + end params[:file] = File.new(Zerobounce::BaseRequest.__safe_file_path__(filepath), 'rb') params[:multipart] = true response = __execute_request__(method: :post, url: url, payload: params, timeout: 120) @@ -113,13 +129,23 @@ def self.bulk_api_root_without_v2 end def self.__parse_json_response__(response) - response_body = response.body - response_body_json = JSON.parse(response_body) - raise(response_body_json['error']) if response_body_json.key?('error') - if response_body_json.key?('errors') && response_body_json['errors'].length > 0 - raise(response_body_json['errors'][0]['error']) + body = response.body.to_s + content_type = (response.headers[:content_type] || '').to_s + if ResponseError.html?(body, content_type) || body.strip.empty? + raise ResponseError.message_from_parts(response.code, content_type, body) + end + begin + parsed = JSON.parse(body) + rescue JSON::ParserError + raise 'ZeroBounce returned a response that is not valid JSON.' + end + return parsed unless parsed.is_a?(Hash) + + raise ResponseError.strip_prefix(parsed['error']) if parsed.key?('error') && !parsed['error'].nil? + if parsed.key?('errors') && parsed['errors'].is_a?(Array) && !parsed['errors'].empty? + raise ResponseError.strip_prefix(parsed['errors'][0]['error']) end - response_body_json + parsed end end diff --git a/lib/zerobounce/client.rb b/lib/zerobounce/client.rb new file mode 100644 index 0000000..28e1e3a --- /dev/null +++ b/lib/zerobounce/client.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Zerobounce + # One API key. Package methods share Zerobounce.config; a Client does not. + class Client + def initialize(apikey) + @apikey = apikey + end + + def validate(email, ip_address = nil, **options) + Zerobounce.with_api_key(@apikey) { Zerobounce.validate(email, ip_address, **options) } + end + + def credits + Zerobounce.with_api_key(@apikey) { Zerobounce.credits } + end + + def validate_batch(emails, ip_addresses = [], **options) + Zerobounce.with_api_key(@apikey) { Zerobounce.validate_batch(emails, ip_addresses, **options) } + end + end +end diff --git a/lib/zerobounce/get_file_helper.rb b/lib/zerobounce/get_file_helper.rb index 1ede414..5f7a714 100644 --- a/lib/zerobounce/get_file_helper.rb +++ b/lib/zerobounce/get_file_helper.rb @@ -2,6 +2,7 @@ require 'json' require 'stringio' +require 'zerobounce/response_error' module Zerobounce # Bulk getfile response handling (v2): HTTP errors, JSON error bodies (including HTTP 200). @@ -69,13 +70,8 @@ def process_getfile_response(response) body_str = response.body.to_s ct = (response.headers[:content_type] || response.headers['Content-Type'] || '').to_s - if code > 299 - msg = if body_str.lstrip.start_with?('{') - format_error_message(body_str) - else - body_str.empty? ? "HTTP #{code}" : body_str - end - raise msg + if code > 299 || ResponseError.html?(body_str, ct) + raise ResponseError.message_from_parts(code, ct, body_str) end if should_treat_as_error?(body_str, ct) diff --git a/lib/zerobounce/priority3.rb b/lib/zerobounce/priority3.rb index 89fc4b2..3210625 100644 --- a/lib/zerobounce/priority3.rb +++ b/lib/zerobounce/priority3.rb @@ -14,11 +14,11 @@ def send_email_finder_file(filepath, domain_column:, first_name_column: nil, las raise ArgumentError, 'domain_column is required' if domain_column.nil? || domain_column.to_i < 1 params = { domain_column: domain_column } - params[:has_header_row] = true if has_header_row - params[:first_name_column] = first_name_column if first_name_column - params[:last_name_column] = last_name_column if last_name_column - params[:middle_name_column] = middle_name_column if middle_name_column - params[:full_name_column] = full_name_column if full_name_column + params[:has_header_row] = has_header_row ? 'true' : 'false' unless has_header_row.nil? + params[:first_name_column] = first_name_column unless first_name_column.nil? + params[:last_name_column] = last_name_column unless last_name_column.nil? + params[:middle_name_column] = middle_name_column unless middle_name_column.nil? + params[:full_name_column] = full_name_column unless full_name_column.nil? zb_request.bulk_root_post("#{EMAIL_FINDER_BULK_BASE}/sendfile", params, 'multipart/form-data', filepath) end @@ -38,7 +38,7 @@ def send_domain_search_file(filepath, domain_column:, has_header_row: nil) raise ArgumentError, 'domain_column is required' if domain_column.nil? || domain_column.to_i < 1 params = { domain_column: domain_column } - params[:has_header_row] = true if has_header_row + params[:has_header_row] = has_header_row ? 'true' : 'false' unless has_header_row.nil? zb_request.bulk_root_post("#{DOMAIN_SEARCH_BULK_BASE}/sendfile", params, 'multipart/form-data', filepath) end diff --git a/lib/zerobounce/request.rb b/lib/zerobounce/request.rb index c23cf8f..07e3608 100644 --- a/lib/zerobounce/request.rb +++ b/lib/zerobounce/request.rb @@ -11,22 +11,7 @@ class Request < BaseRequest def self.get(path, params, content_type='application/json') response = self._get(Zerobounce.configuration.api_root_url, path, params, content_type) - if response.headers[:content_type] == 'application/json' - response_body = response.body - response_body_json = JSON.parse(response_body) - - raise (response_body_json['error']) if response_body_json.key?('error') - raise (response_body_json['Message']) \ - if response_body_json.key?('Message') and \ - response_body_json.length == 1 - raise (response_body_json['errors'][0]['error']) \ - if response_body_json.key?('errors') and \ - response_body_json['errors'].length > 0 - - return response_body_json - else - return response - end + interpret_json_response(response, path) end def self.bulk_get(path, params, content_type='application/json') @@ -43,6 +28,9 @@ def self.bulk_get(path, params, content_type='application/json') return response_body_json else + if ResponseError.html?(response.body, response.headers[:content_type]) + raise ResponseError.message_from(response) + end content = StringIO.new(response.body) content.set_encoding_by_bom return content.string @@ -58,18 +46,7 @@ def self.bulk_getfile(path, params) def self.post(path, params, content_type='application/json', filepath=nil) response = self._post(Zerobounce.configuration.api_root_url, path, params, \ content_type, filepath) - if response.headers[:content_type] == 'application/json' - response_body = response.body - response_body_json = JSON.parse(response_body) - - raise (response_body_json['error']) if response_body_json.key?('error') - raise (response_body_json['errors'][0]['error']) \ - if response_body_json.key?('errors') and \ - response_body_json['errors'].length > 0 - - return response_body_json - end - return response.body + interpret_json_response(response, path) end def self.bulk_post(path, params, content_type='application/json', filepath=nil) @@ -107,6 +84,34 @@ def self.bulk_root_form_post(path, params) __parse_json_response__(response) end + def self.interpret_json_response(response, path = nil) + body = response.body.to_s + content_type = (response.headers[:content_type] || '').to_s + if ResponseError.html?(body, content_type) || body.strip.empty? || + (!content_type.include?('json') && !body.strip.start_with?('{', '[')) + raise ResponseError.message_from_parts(response.code, content_type, body) + end + begin + parsed = JSON.parse(body) + rescue JSON::ParserError + raise 'ZeroBounce returned a response that is not valid JSON.' + end + return parsed unless parsed.is_a?(Hash) + + raise ResponseError.strip_prefix(parsed['error']) if parsed.key?('error') && !parsed['error'].nil? + if parsed.key?('Message') && parsed.length == 1 + raise ResponseError.strip_prefix(parsed['Message']) + end + if parsed.key?('errors') && parsed['errors'].is_a?(Array) && !parsed['errors'].empty? + account = parsed['errors'].find { |item| item.is_a?(Hash) && item['email_address'].to_s.casecmp('all').zero? } + if account + raise ResponseError.strip_prefix(account['error']) + end + raise ResponseError.strip_prefix(parsed['errors'][0]['error']) unless path == 'validatebatch' + end + parsed + end + end end diff --git a/lib/zerobounce/response_error.rb b/lib/zerobounce/response_error.rb new file mode 100644 index 0000000..b8dec14 --- /dev/null +++ b/lib/zerobounce/response_error.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'json' + +module Zerobounce + # Turns HTML pages, empty bodies, and parser dumps into a short message. + module ResponseError + module_function + + def html?(body, content_type = '') + sample = body.to_s[0, 2000].to_s.downcase + content_type.to_s.downcase.include?('text/html') || + sample.include?(']*>(.*?)}im) + match ? match[1].strip : '' + end + + def message_from_parts(status, content_type, body) + text = body.to_s + if html?(text, content_type) + title = html_title(text) + return 'ZeroBounce returned an HTML error page instead of an API response.' if title.empty? + + return "ZeroBounce returned an HTML error page (\"#{title}\") instead of an API response." + end + trimmed = text.strip + return "Empty response from ZeroBounce (HTTP #{status})" if trimmed.empty? + + if trimmed.start_with?('{', '[') + begin + parsed = JSON.parse(trimmed) + rescue JSON::ParserError + return 'ZeroBounce returned a response that is not valid JSON.' + end + if parsed.is_a?(Hash) + title = parsed['title'].to_s + access = title.include?('Access denied') || title.include?('1020') + if access && parsed['cloudflare_error'] == true + return title + end + %w[error message error_message Message].each do |key| + value = parsed[key] + return strip_prefix(value) if value.is_a?(String) && !value.strip.empty? + return strip_prefix(value[0]) if value.is_a?(Array) && value[0].is_a?(String) && !value[0].strip.empty? + end + end + return 'ZeroBounce returned a response that is not valid JSON.' if trimmed.length > 1000 + + return strip_prefix(trimmed) + end + return 'ZeroBounce returned a response that is not valid JSON.' if trimmed.length > 1000 + + message = strip_prefix(trimmed) + message.empty? ? "Empty response from ZeroBounce (HTTP #{status})" : message + end + + def message_from(response) + content_type = '' + if response.respond_to?(:headers) && response.headers + content_type = (response.headers[:content_type] || response.headers['Content-Type']).to_s + end + code = response.respond_to?(:code) ? response.code : 0 + message_from_parts(code, content_type, response.respond_to?(:body) ? response.body : response.to_s) + end + end +end diff --git a/spec/mock_spec.rb b/spec/mock_spec.rb index dc06683..08cfed3 100644 --- a/spec/mock_spec.rb +++ b/spec/mock_spec.rb @@ -73,7 +73,7 @@ it 'should raise an API key error' do VCR.use_cassette 'validate-incorrect-api-key' do expect{ described_class.validate('valid@example.com') }.to \ - raise_error(RestClient::Forbidden) + raise_error(RuntimeError) end end end @@ -128,7 +128,7 @@ it 'should raise an API key error' do VCR.use_cassette 'activity-incorrect-api-key' do expect{ described_class.activity('ss@gmail.com') }.to \ - raise_error(RestClient::Forbidden) + raise_error(RuntimeError) end end end @@ -169,7 +169,7 @@ end it 'should raise a forbidden error' do VCR.use_cassette 'credits-incorrect-api-key' do - expect{ described_class.credits }.to raise_error(RestClient::Forbidden) + expect{ described_class.credits }.to raise_error(RuntimeError) end end end @@ -208,7 +208,7 @@ it 'should raise an API key error' do VCR.use_cassette 'api-usage-incorrect-api-key' do expect{ described_class.api_usage(test_date, test_date) }.to \ - raise_error(RestClient::Forbidden) + raise_error(RuntimeError) end end end @@ -253,7 +253,7 @@ it 'should raise an API key error' do VCR.use_cassette 'batch-validate-incorrect-api-key' do expect{ described_class.validate_batch(emails) }.to \ - raise_error(RestClient::Forbidden) + raise_error(RuntimeError) end end end @@ -279,8 +279,8 @@ it 'should return valid results' do VCR.use_cassette 'batch-validate-valid' do results = described_class.validate_batch(emails) - expect(results.length).to be(6) - results.each do |result| + expect(results['email_batch'].length).to be(6) + results['email_batch'].each do |result| expect(result).to include( 'address', 'status', 'sub_status', 'domain_age_days', 'smtp_provider', 'mx_found', 'mx_record' @@ -309,7 +309,7 @@ it 'should raise an API key error' do VCR.use_cassette 'file-validate-send-incorrect-api-key' do expect{ described_class.validate_file_send(validate_file_path) }.to \ - raise_error(RestClient::Unauthorized) + raise_error(RuntimeError) end end end @@ -364,7 +364,7 @@ it 'should raise an API key error' do VCR.use_cassette 'file-validate-check-incorrect-api-key' do expect{ described_class.validate_file_check(validate_file_id) }.to \ - raise_error(RestClient::Forbidden) + raise_error(RuntimeError) end end end @@ -410,7 +410,7 @@ it 'should raise an API key error' do VCR.use_cassette 'file-validate-get-incorrect-api-key' do expect{ described_class.validate_file_get(validate_file_id) }.to \ - raise_error(RestClient::Forbidden) + raise_error(RuntimeError) end end end @@ -422,7 +422,7 @@ it 'should raise a forbidden error' do VCR.use_cassette 'file-validate-get-incorrect-file-id' do expect{ described_class.validate_file_get('invalid-file-id') - }.to raise_error(RestClient::Forbidden) + }.to raise_error(RuntimeError) end end end @@ -454,7 +454,7 @@ it 'should raise an API key error' do VCR.use_cassette 'file-validate-delete-incorrect-api-key' do expect{ described_class.validate_file_delete(validate_file_id) }.to \ - raise_error(RestClient::Forbidden) + raise_error(RuntimeError) end end end @@ -466,7 +466,7 @@ it 'should raise a forbidden error' do VCR.use_cassette 'file-validate-delete-incorrect-file-id' do expect{ described_class.validate_file_delete('invalid-file-id') - }.to raise_error(RestClient::Forbidden) + }.to raise_error(RuntimeError) end end end @@ -498,7 +498,7 @@ it 'should raise an API key error' do VCR.use_cassette 'scoring-send-incorrect-api-key' do expect{ described_class.scoring_file_send(scoring_file_path) }.to \ - raise_error(RestClient::Unauthorized) + raise_error(RuntimeError) end end end @@ -553,7 +553,7 @@ it 'should raise an API key error' do VCR.use_cassette 'scoring-check-incorrect-api-key' do expect{ described_class.scoring_file_check(scoring_file_id) }.to \ - raise_error(RestClient::Forbidden) + raise_error(RuntimeError) end end end @@ -565,7 +565,7 @@ it 'should raise a forbidden error' do VCR.use_cassette 'scoring-check-incorrect-file-id' do expect{ described_class.scoring_file_check('invalid-file-id') - }.to raise_error(RestClient::Forbidden) + }.to raise_error(RuntimeError) end end end @@ -598,7 +598,7 @@ it 'should raise an API key error' do VCR.use_cassette 'scoring-get-icorrect-api-key' do expect{ described_class.scoring_file_get(scoring_file_id) }.to \ - raise_error(RestClient::Forbidden) + raise_error(RuntimeError) end end end @@ -610,7 +610,7 @@ it 'should raise a forbidden error' do VCR.use_cassette 'scoring-get-incorrect-file-id' do expect{ described_class.scoring_file_get('invalid-file-id') - }.to raise_error(RestClient::Forbidden) + }.to raise_error(RuntimeError) end end end @@ -642,7 +642,7 @@ it 'should raise an API key error' do VCR.use_cassette 'scoring-delete-incorrect-api-key' do expect{ described_class.scoring_file_delete(scoring_file_id) }.to \ - raise_error(RestClient::Forbidden) + raise_error(RuntimeError) end end end @@ -654,7 +654,7 @@ it 'should raise a forbidden error' do VCR.use_cassette 'scoring-delete-incorrect-file-id' do expect{ described_class.scoring_file_delete('invalid-file-id') - }.to raise_error(RestClient::Forbidden) + }.to raise_error(RuntimeError) end end end @@ -695,7 +695,7 @@ first_name: 'John', middle_name: 'Deere', last_name: 'Doe') - }.to raise_error(RestClient::Forbidden) + }.to raise_error(RuntimeError) end end end @@ -801,7 +801,7 @@ domain: 'example.com', middle_name: 'Deere', last_name: 'Doe') - }.to raise_error(RestClient::Forbidden) + }.to raise_error(RuntimeError) end end end @@ -928,7 +928,7 @@ VCR.use_cassette 'find-domain-incorrect-api-key' do expect{ described_class.find_domain( domain: 'example.com') - }.to raise_error(RestClient::Forbidden) + }.to raise_error(RuntimeError) end end end diff --git a/spec/offline_api_spec.rb b/spec/offline_api_spec.rb new file mode 100644 index 0000000..7afe297 --- /dev/null +++ b/spec/offline_api_spec.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +require 'webmock/rspec' +require 'zerobounce' + +RSpec.describe 'offline API' do + def html_page + "Access denied | zerobounce.net | Zero Bounce#{'

blocked

' * 20_000}" + end + + def answer(request) + uri = request.uri + key = uri.query_values.to_h['api_key'].to_s + body = request.body.to_s + if body.include?('api_key=') + key = URI.decode_www_form(body).to_h['api_key'].to_s if key.empty? + end + if key.empty? && body.start_with?('{') + begin + parsed = JSON.parse(body) + key = parsed['api_key'].to_s + rescue JSON::ParserError + key = '' + end + end + path = uri.path + status = 200 + content_type = 'application/json' + payload = '{"Credits":"7","address":"' + key + '","status":"valid","email":"' + key + '","domain":"' + key + '","success":true,"message":"ok","file_id":"' + key + '","file_name":"f.csv","email_batch":[{"address":"' + key + '","status":"valid"}],"errors":[]}' + if key.start_with?('html-403-') + status = 403 + content_type = 'text/html' + payload = html_page + elsif key.start_with?('html-200-') + content_type = 'text/html' + payload = html_page + elsif key.start_with?('empty-') + payload = '' + elsif key.start_with?('malformed-') + payload = "<<>>#{'x' * 5000}" + elsif key.start_with?('cf-json-') + status = 403 + payload = '{"title":"Error 1020: Access denied","status":403,"detail":"The request was blocked by a Cloudflare firewall rule configured by the site owner.","error_code":1020,"cloudflare_error":true,"type":"https://developers.cloudflare.com/support/troubleshooting/http-status-codes/cloudflare-1xxx-errors/error-1020/"}' + elsif key.start_with?('double-error-') + status = 400 + content_type = 'text/plain' + payload = 'Error: Error: api_key is invalid' + elsif path.include?('validatebatch') && key.start_with?('batch-account-') + payload = '{"email_batch":[],"errors":[{"email_address":"all","error":"Invalid API Key or your account ran out of credits"}]}' + elsif path.include?('validatebatch') && key.start_with?('batch-partial-') + payload = '{"email_batch":[{"address":"good@example.com","status":"valid"}],"errors":[{"email_address":"bad@","error":"Invalid email address"}]}' + elsif path.include?('getfile') + content_type = 'application/octet-stream' + payload = "email\n#{key}@example.com\n" + end + sleep(rand * 0.01) + { status: status, headers: { 'Content-Type' => content_type }, body: payload } + end + + before do + @requests = [] + stub_request(:any, /zerobounce\.net/).to_return do |request| + @requests << request + answer(request) + end + Zerobounce.configure { |config| config.apikey = 'offline-key' } + end + + after do + Thread.current[:zerobounce_api_key] = nil + end + + it 'matches the gem version in the user agent' do + expect(Zerobounce::VERSION).to eq('2.1.8') + expect(Zerobounce::Configuration.new.headers[:user_agent]).to include(Zerobounce::VERSION) + end + + it 'calls credits and validate with the right parameters' do + expect(Zerobounce.credits).to eq(7) + request = @requests.pop + expect(request.method).to eq(:get) + expect(request.uri.host).to eq('api.zerobounce.net') + expect(request.uri.path).to eq('/v2/getcredits') + expect(request.uri.query_values['api_key']).to eq('offline-key') + + result = Zerobounce.validate('person@example.com', nil, activity_data: false, verify_plus: true) + request = @requests.pop + expect(request.uri.path).to eq('/v2/validate') + expect(request.uri.query_values['activity_data']).to eq('false') + expect(request.uri.query_values['verify_plus']).to eq('true') + expect(request.uri.query_values).not_to have_key('timeout') + expect(result['address']).to eq('offline-key') + end + + it 'keeps a partial batch success and fails an account-level batch error' do + Zerobounce.configure { |config| config.apikey = 'batch-partial-user' } + result = Zerobounce.validate_batch(['good@example.com', 'bad@']) + expect(result['email_batch'].first['address']).to eq('good@example.com') + expect(result['errors'].first['email_address']).to eq('bad@') + + Zerobounce.configure { |config| config.apikey = 'batch-account-user' } + expect { Zerobounce.validate_batch(['a@example.com']) }.to raise_error(RuntimeError, /Invalid API Key/) + end + + it 'returns short errors for html, empty, malformed, cloudflare, and doubled prefixes' do + Zerobounce.configure { |config| config.apikey = 'html-403-user' } + expect { Zerobounce.credits }.to raise_error(RuntimeError) { |error| + expect(error.message.length).to be < 500 + expect(error.message).to include('Access denied') + expect(error.message).not_to include('

blocked

') + } + expect(html_page.length).to be > 100_000 + + Zerobounce.configure { |config| config.apikey = 'html-200-user' } + expect { Zerobounce.validate('a@example.com') }.to raise_error(RuntimeError, /Access denied/) + + Zerobounce.configure { |config| config.apikey = 'empty-user' } + expect { Zerobounce.validate('a@example.com') }.to raise_error(RuntimeError, /Empty response/) + + Zerobounce.configure { |config| config.apikey = 'malformed-user' } + expect { Zerobounce.validate('a@example.com') }.to raise_error(RuntimeError, /not valid JSON/) { |error| + expect(error.message).not_to include('<<>>') + } + + Zerobounce.configure { |config| config.apikey = 'cf-json-user' } + expect { Zerobounce.credits }.to raise_error(RuntimeError, /Access denied/) { |error| + expect(error.message).not_to include('developers.cloudflare.com') + } + + Zerobounce.configure { |config| config.apikey = 'double-error-user' } + expect { Zerobounce.activity('a@example.com') }.to raise_error(RuntimeError, 'api_key is invalid') + end + + it 'rejects an empty key and a bad timeout before a request' do + Zerobounce.configure { |config| config.apikey = '' } + expect { Zerobounce.credits }.to raise_error(RuntimeError, /API key must be assigned/) + expect(@requests).to be_empty + + Zerobounce.configure { |config| config.apikey = 'keyed' } + expect { Zerobounce.validate('a@example.com', nil, timeout: 2) }.to raise_error(ArgumentError, /timeout/) + expect { Zerobounce.validate_batch(['a@example.com'], timeout: 9) }.to raise_error(ArgumentError, /timeout/) + expect(@requests).to be_empty + end + + it 'does not mix keys across client threads' do + echoes = Array.new(8) + threads = 8.times.map do |index| + Thread.new do + key = "conc-#{index}" + echoes[index] = Zerobounce::Client.new(key).validate('person@example.com')['address'] + end + end + threads.each(&:join) + keys = @requests.map { |request| request.uri.query_values['api_key'] } + expect(keys.sort).to eq(8.times.map { |index| "conc-#{index}" }.sort) + expect(echoes).to eq(8.times.map { |index| "conc-#{index}" }) + end +end