zerobounce-sdk 2.1.7 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +146 -19
  3. data/README.md +472 -410
  4. data/lib/zerobounce/api_urls.rb +9 -0
  5. data/lib/zerobounce/base_request.rb +208 -8
  6. data/lib/zerobounce/client.rb +22 -0
  7. data/lib/zerobounce/configuration.rb +24 -3
  8. data/lib/zerobounce/error.rb +18 -0
  9. data/lib/zerobounce/get_file_helper.rb +3 -7
  10. data/lib/zerobounce/mock_request.rb +31 -12
  11. data/lib/zerobounce/priority3.rb +331 -0
  12. data/lib/zerobounce/request.rb +77 -38
  13. data/lib/zerobounce/response_error.rb +117 -0
  14. data/lib/zerobounce/validate_sub_status.rb +1 -0
  15. data/lib/zerobounce/version.rb +1 -1
  16. data/lib/zerobounce/warmup.rb +467 -0
  17. data/lib/zerobounce.rb +101 -14
  18. metadata +8 -33
  19. data/.dockerignore +0 -17
  20. data/.env.example +0 -3
  21. data/.github/auto_assign.yml +0 -5
  22. data/.github/dependabot.yml +0 -35
  23. data/.github/workflows/auto_assign_ci.yaml +0 -21
  24. data/.github/workflows/codeql.yml +0 -105
  25. data/.github/workflows/publish.yml +0 -131
  26. data/.github/workflows/sdk_ci.yml +0 -29
  27. data/.gitignore +0 -70
  28. data/.rspec +0 -2
  29. data/.ruby-version +0 -1
  30. data/CODE_OF_CONDUCT.md +0 -69
  31. data/CONTRIBUTING.md +0 -112
  32. data/Dockerfile +0 -17
  33. data/Gemfile +0 -13
  34. data/Gemfile.lock +0 -135
  35. data/Rakefile +0 -26
  36. data/SECURITY.md +0 -21
  37. data/bin/bundle +0 -105
  38. data/bin/console +0 -7
  39. data/bin/setup +0 -6
  40. data/data/zerobounce-ai-scoring.csv +0 -31
  41. data/data/zerobounce-batch-validation.csv +0 -31
  42. data/documentation.md +0 -530
  43. data/files/scoring.csv +0 -1
  44. data/files/validation.csv +0 -1
  45. data/files/zerobounce-ai-scoring.csv +0 -31
  46. data/files/zerobounce-batch-validation.csv +0 -31
  47. data/zerobounce.gemspec +0 -49
@@ -0,0 +1,467 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'erb'
5
+ require 'json'
6
+ require 'rest-client'
7
+
8
+ require 'zerobounce/error'
9
+ require 'zerobounce/response_error'
10
+
11
+ module Zerobounce
12
+ # Client for the ZeroBounce Warmup API
13
+ # (https://www.zerobounce.net/docs/warmup-api).
14
+ #
15
+ # The Warmup API is separate from the email validation API: its own host, its
16
+ # own team-scoped key, and an "API-Key" header instead of an api_key
17
+ # parameter. Use Zerobounce::Warmup with a Warmup API key; the Zerobounce
18
+ # module stays for the validation API key.
19
+ #
20
+ # warmup = Zerobounce::Warmup.new('<YOUR_WARMUP_API_KEY>')
21
+ # warmup.list_sending_accounts
22
+ #
23
+ # A documented empty success (204, or an empty 200) comes back as nil. A
24
+ # payload that is not a JSON object - /seeds is an array, /seed-count a bare
25
+ # number - comes back as the parsed value itself.
26
+ class Warmup
27
+ DEFAULT_BASE_URL = 'https://warmup-api.zerobounce.net'
28
+ BASE_PATH = '/api/v1/warmup'
29
+
30
+ ONBOARDING_MODELS = %w[self_managed zb_managed].freeze
31
+ EMAIL_TYPES = %w[transactional newsletter marketing].freeze
32
+
33
+ # Required by the schema but always replaced with the key's own team.
34
+ ANY_TEAM_ID = 1
35
+
36
+ # @param api_key [String] your Warmup API key, sent in the API-Key header
37
+ # @param base_url [String] defaults to https://warmup-api.zerobounce.net
38
+ def initialize(api_key, base_url = DEFAULT_BASE_URL)
39
+ raise ArgumentError, 'api_key is required' if api_key.nil? || api_key.to_s.strip.empty?
40
+
41
+ # A key with surrounding whitespace or control characters would be
42
+ # 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
46
+ unless base_url.to_s.downcase.start_with?('https://')
47
+ raise ArgumentError, 'base_url must be an https:// URL'
48
+ end
49
+
50
+ @api_key = api_key
51
+ root = base_url.to_s
52
+ root = root.chomp('/') while root.end_with?('/')
53
+ @root = "#{root}#{BASE_PATH}"
54
+ end
55
+
56
+ # -- sending accounts ---------------------------------------------------
57
+
58
+ # Every sending account of the key's team. GET /sending-accounts
59
+ def list_sending_accounts
60
+ get('/sending-accounts')
61
+ end
62
+
63
+ # One sending account. GET /sending-account
64
+ def get_sending_account(from_address)
65
+ require_value(from_address, 'from_address')
66
+ get('/sending-account', from_address: from_address)
67
+ end
68
+
69
+ # Adds a from-address to warmup. POST /sending-accounts
70
+ #
71
+ # +self_managed+ runs your own mailbox and needs +engagement_rule_id+ 0-4.
72
+ # +zb_managed+ has ZeroBounce provision it and takes +email_content+ and
73
+ # +managed_sending+; a 200 means accepted, and the mailbox is provisioned
74
+ # afterwards.
75
+ def add_sending_account(from_address, onboarding_model, **options)
76
+ require_value(from_address, 'from_address')
77
+ unless ONBOARDING_MODELS.include?(onboarding_model.to_s)
78
+ raise ArgumentError, 'onboarding_model must be self_managed or zb_managed'
79
+ end
80
+
81
+ body = { 'from_address' => from_address, 'onboarding_model' => onboarding_model.to_s }
82
+ if onboarding_model.to_s == 'self_managed'
83
+ rule = options[:engagement_rule_id]
84
+ raise ArgumentError, 'engagement_rule_id is required for self_managed' if rule.nil?
85
+
86
+ body['engagement_rule_id'] = in_range(rule, 0, 4, 'engagement_rule_id')
87
+ if options.key?(:email_content) || options.key?(:managed_sending)
88
+ raise ArgumentError, 'email_content and managed_sending are for zb_managed'
89
+ end
90
+ end
91
+ body['ignore_conflict'] = !options[:ignore_conflict].nil? && options[:ignore_conflict] ? true : false if
92
+ options.key?(:ignore_conflict)
93
+ body['email_content'] = email_content_body(options[:email_content]) if options.key?(:email_content)
94
+ body['managed_sending'] = managed_sending_body(options[:managed_sending]) if options.key?(:managed_sending)
95
+
96
+ json_request(:post, '/sending-accounts', {}, body)
97
+ end
98
+
99
+ # Changes an account's engagement rule, status and auto-enable flag.
100
+ # PUT /sending-accounts. +status_id+ is 0 to 8 (4 Paused, 5 Disabled).
101
+ def update_sending_account(from_address, engagement_rule_id, status_id, auto_enable)
102
+ require_value(from_address, 'from_address')
103
+ unless [true, false].include?(auto_enable)
104
+ raise ArgumentError, 'auto_enable must be true or false'
105
+ end
106
+
107
+ json_request(:put, '/sending-accounts', {},
108
+ { 'team_id' => ANY_TEAM_ID,
109
+ 'from_address' => from_address,
110
+ 'engagement_rule_id' => in_range(engagement_rule_id, 0, 4, 'engagement_rule_id'),
111
+ 'status_id' => in_range(status_id, 0, 8, 'status_id'),
112
+ 'auto_enable' => auto_enable })
113
+ end
114
+
115
+ # Removes a sending account. DELETE /sending-accounts. Destructive and final.
116
+ def delete_sending_account(from_address)
117
+ require_value(from_address, 'from_address')
118
+ json_request(:delete, '/sending-accounts', { from_address: from_address })
119
+ end
120
+
121
+ # Moves an account and its history to a new from-address.
122
+ # PUT /smtp-credentials - despite the path, no SMTP credentials are stored.
123
+ def change_from_address(old_from_address, new_from_address)
124
+ require_value(old_from_address, 'old_from_address')
125
+ require_value(new_from_address, 'new_from_address')
126
+ json_request(:put, '/smtp-credentials', {},
127
+ { 'team_id' => ANY_TEAM_ID,
128
+ 'old_from_address' => old_from_address,
129
+ 'new_from_address' => new_from_address })
130
+ end
131
+
132
+ # Registered from-addresses. GET /from-addresses
133
+ #
134
+ # +search_from+ takes letters, digits and . - _ @ + only. +offset+ and
135
+ # +limit+ go on the wire as strings.
136
+ def list_from_addresses(search_from: nil, offset: nil, limit: nil)
137
+ params = {}
138
+ unless search_from.nil?
139
+ unless search_from.to_s.match?(/\A[A-Za-z0-9.\-_@+]*\z/)
140
+ raise ArgumentError, 'search_from takes letters, digits and . - _ @ + only'
141
+ end
142
+
143
+ params[:search_from] = search_from
144
+ end
145
+ { offset: offset, limit: limit }.each do |name, value|
146
+ next if value.nil?
147
+ raise ArgumentError, "#{name} must be a non-negative integer" unless value.is_a?(Integer) && value >= 0
148
+
149
+ params[name] = value.to_s
150
+ end
151
+ get('/from-addresses', **params)
152
+ end
153
+
154
+ # -- engagement rules ---------------------------------------------------
155
+
156
+ # -1 Custom, 0 Default, 1 Slow Pace, 2 Repair, 3 Boost, 4 Protect.
157
+ def engagement_rules
158
+ get('/engagement-rules')
159
+ end
160
+
161
+ # Sets a standard rule (0 to 4) on an address. PUT /engagement-rule
162
+ def set_engagement_rule(c_from, rule_id)
163
+ require_value(c_from, 'c_from')
164
+ json_request(:put, '/engagement-rule', { c_from: c_from },
165
+ { 'rule_id' => in_range(rule_id, 0, 4, 'rule_id') })
166
+ end
167
+
168
+ # An address's custom rule, with can_update and remaining_seconds.
169
+ def custom_engagement(c_from)
170
+ require_value(c_from, 'c_from')
171
+ segment = c_from.to_s.strip
172
+ raise ArgumentError, 'c_from is invalid' if ['.', '..'].include?(segment)
173
+
174
+ get("/custom-engagement/#{ERB::Util.url_encode(segment)}")
175
+ end
176
+
177
+ # Creates a custom rule. Starts a 7 day cooldown on updates.
178
+ def create_custom_engagement(c_from, reply_pct, important_pct, click_pct)
179
+ json_request(:post, '/custom-engagement', {},
180
+ engagement_body(c_from, reply_pct, important_pct, click_pct))
181
+ end
182
+
183
+ # Updates a custom rule. During the 7 day cooldown the API answers 429.
184
+ def update_custom_engagement(c_from, reply_pct, important_pct, click_pct)
185
+ json_request(:put, '/custom-engagement', {},
186
+ engagement_body(c_from, reply_pct, important_pct, click_pct))
187
+ end
188
+
189
+ # Deletes a custom rule and falls back to standard rule 1-4.
190
+ def delete_custom_engagement(c_from, engagement_rule_id)
191
+ require_value(c_from, 'c_from')
192
+ json_request(:delete, '/custom-engagement', {},
193
+ { 'c_from' => c_from,
194
+ 'engagement_rule_id' => in_range(engagement_rule_id, 1, 4, 'engagement_rule_id') })
195
+ end
196
+
197
+ # -- statistics, seeds and utilities ------------------------------------
198
+
199
+ # The seed list. GET /seeds
200
+ def seeds
201
+ get('/seeds')
202
+ end
203
+
204
+ # The seed list as CSV (columns seed,provider). GET /seeds/download
205
+ def download_seeds
206
+ csv('/seeds/download')
207
+ end
208
+
209
+ # Number of seeds. GET /seed-count
210
+ def seed_count
211
+ get('/seed-count')
212
+ end
213
+
214
+ # Today's warmup progress. GET /progress-today
215
+ def progress_today
216
+ get('/progress-today')
217
+ end
218
+
219
+ # Messages sent today. GET /sent-today
220
+ def sent_today
221
+ get('/sent-today')
222
+ end
223
+
224
+ # Current daily volume. GET /emails-per-day
225
+ def emails_per_day
226
+ get('/emails-per-day')
227
+ end
228
+
229
+ # Statistics for an address. An address with no history returns zeros.
230
+ def statistics(from_address)
231
+ require_value(from_address, 'from_address')
232
+ get('/statistics', from_address: from_address)
233
+ end
234
+
235
+ # Daily statistics between two dates, inclusive. GET /statistics-per-day
236
+ def statistics_per_day(from_address, start_date, end_date)
237
+ require_value(from_address, 'from_address')
238
+ first = api_date(start_date, 'start_date')
239
+ last = api_date(end_date, 'end_date')
240
+ raise ArgumentError, 'start_date is later than end_date' if first > last
241
+
242
+ get('/statistics-per-day', from_address: from_address, start_date: first, end_date: last)
243
+ end
244
+
245
+ # Detailed statistics for an address. GET /statistics-detailed
246
+ def statistics_detailed(c_from)
247
+ require_value(c_from, 'c_from')
248
+ get('/statistics-detailed', c_from: c_from)
249
+ end
250
+
251
+ # An address's statistics as CSV. GET /statistics/download
252
+ def download_statistics(c_from)
253
+ require_value(c_from, 'c_from')
254
+ csv('/statistics/download', c_from: c_from)
255
+ end
256
+
257
+ # Statistics for one from-address. GET /from-address-statistics
258
+ def from_address_statistics(from_address)
259
+ require_value(from_address, 'from_address')
260
+ get('/from-address-statistics', from_address: from_address)
261
+ end
262
+
263
+ # SPF, DKIM and DMARC status of a from-address's domain.
264
+ # GET /check-domain-dmarc
265
+ #
266
+ # LIVE: pass an EMAIL ADDRESS. The live API answers "Please provide a valid
267
+ # email address" for a bare domain, although the docs describe a domain.
268
+ def check_domain_dmarc(from_address, dkim_selector: nil)
269
+ require_value(from_address, 'from_address')
270
+ unless from_address.to_s.include?('@')
271
+ raise ArgumentError, 'from_address must be an email address, e.g. sender@example.com'
272
+ end
273
+
274
+ params = { from_address: from_address }
275
+ unless dkim_selector.nil?
276
+ require_value(dkim_selector, 'dkim_selector')
277
+ params[:dkim_selector] = dkim_selector
278
+ end
279
+ get('/check-domain-dmarc', **params)
280
+ end
281
+
282
+ # Checks that SMTP credentials connect and hand off a message.
283
+ # POST /test-smtp-configuration
284
+ #
285
+ # Nothing is stored, and the test message goes to a ZeroBounce test
286
+ # recipient, not +from_address+.
287
+ #
288
+ # LIVE: the API answers any failed SMTP login with HTTP 500. That is an API
289
+ # bug. The username and password are masked in every error from this method.
290
+ def test_smtp_configuration(from_address, username, password, server, port)
291
+ require_value(from_address, 'from_address')
292
+ require_value(username, 'username')
293
+ require_value(password, 'password')
294
+ require_value(server, 'server')
295
+ raise ArgumentError, 'port must be 1 to 65535' unless port.is_a?(Integer) && port.between?(1, 65_535)
296
+
297
+ json_request(:post, '/test-smtp-configuration', {},
298
+ { 'from_address' => from_address,
299
+ 'username' => username,
300
+ 'password' => password,
301
+ 'server' => server,
302
+ 'port' => port },
303
+ secrets: [password, username])
304
+ end
305
+
306
+ private
307
+
308
+ attr_reader :api_key, :root
309
+
310
+ # -- argument checks ----------------------------------------------------
311
+
312
+ def require_value(value, name)
313
+ raise ArgumentError, "#{name} is required" if value.nil? || value.to_s.strip.empty?
314
+
315
+ value
316
+ end
317
+
318
+ def in_range(value, low, high, name)
319
+ unless value.is_a?(Integer) && value.between?(low, high)
320
+ raise ArgumentError, "#{name} must be #{low} to #{high}"
321
+ end
322
+
323
+ value
324
+ end
325
+
326
+ # A date for the API: a Date/Time or a YYYY-MM-DD string.
327
+ def api_date(value, name)
328
+ return value.strftime('%Y-%m-%d') if value.respond_to?(:strftime)
329
+ return value if value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
330
+
331
+ raise ArgumentError, "#{name} must be YYYY-MM-DD"
332
+ end
333
+
334
+ def engagement_body(c_from, reply_pct, important_pct, click_pct)
335
+ require_value(c_from, 'c_from')
336
+ in_range(reply_pct, 5, 45, 'reply_pct')
337
+ in_range(important_pct, 5, 40, 'important_pct')
338
+ in_range(click_pct, 0, 70, 'click_pct')
339
+ if click_pct > important_pct * 1.75
340
+ raise ArgumentError, 'click_pct must be at most important_pct x 1.75'
341
+ end
342
+
343
+ { 'c_from' => c_from, 'reply_pct' => reply_pct,
344
+ 'important_pct' => important_pct, 'click_pct' => click_pct }
345
+ end
346
+
347
+ def email_content_body(content)
348
+ raise ArgumentError, 'email_content must be a Hash' unless content.is_a?(Hash)
349
+
350
+ body = {}
351
+ types = content[:types_of_emails] || content['types_of_emails']
352
+ unless types.nil?
353
+ Array(types).each do |kind|
354
+ unless EMAIL_TYPES.include?(kind.to_s)
355
+ raise ArgumentError, 'types_of_emails must be transactional, newsletter and/or marketing'
356
+ end
357
+ end
358
+ body['types_of_emails'] = Array(types).map(&:to_s)
359
+ end
360
+ %i[industry company_name website].each do |key|
361
+ value = content[key] || content[key.to_s]
362
+ body[key.to_s] = value unless value.nil?
363
+ end
364
+ body
365
+ end
366
+
367
+ def managed_sending_body(sending)
368
+ raise ArgumentError, 'managed_sending must be a Hash' unless sending.is_a?(Hash)
369
+
370
+ body = {}
371
+ offset = sending[:timezone_offset] || sending['timezone_offset']
372
+ body['timezone_offset'] = in_range(offset, -11, 14, 'timezone_offset') unless offset.nil?
373
+ start = sending[:warmup_start_date] || sending['warmup_start_date']
374
+ body['warmup_start_date'] = api_date(start, 'warmup_start_date') unless start.nil?
375
+ limit = sending[:daily_limit] || sending['daily_limit']
376
+ body['daily_limit'] = limit unless limit.nil?
377
+ body
378
+ end
379
+
380
+ # -- transport ----------------------------------------------------------
381
+
382
+ # Replaces every form of a secret in a message with ***.
383
+ def redact(text, secrets = [])
384
+ value = text.to_s
385
+ ([api_key] + secrets).each do |secret|
386
+ next if secret.nil? || secret.to_s.empty?
387
+
388
+ value = value.gsub(secret.to_s, '***').gsub(ERB::Util.url_encode(secret.to_s), '***')
389
+ end
390
+ value
391
+ end
392
+
393
+ def get(path, **params)
394
+ json_request(:get, path, params)
395
+ end
396
+
397
+ def send_request(method, path, params, body, secrets)
398
+ url = "#{root}#{path}"
399
+ options = {
400
+ method: method,
401
+ url: url,
402
+ timeout: 120,
403
+ max_redirects: 0,
404
+ headers: { 'API-Key' => api_key, accept: :json, params: params }
405
+ }
406
+ if body
407
+ options[:payload] = body.to_json
408
+ options[:headers][:content_type] = :json
409
+ end
410
+ RestClient::Request.execute(**options)
411
+ rescue RestClient::ExceptionWithResponse => e
412
+ response = e.response
413
+ status = response.respond_to?(:code) ? response.code : 0
414
+ if status >= 300 && status < 400
415
+ raise ApiResponseError.new(
416
+ 'ZeroBounce returned a redirect, which this client does not follow.', status
417
+ )
418
+ end
419
+ raise ApiResponseError.new(
420
+ redact(
421
+ ResponseError.message_from_parts(
422
+ status, (response.headers[:content_type] || '').to_s, response.body.to_s
423
+ ), secrets
424
+ ), status
425
+ )
426
+ rescue ApiResponseError
427
+ raise
428
+ rescue StandardError => e
429
+ # Rebuilt rather than re-raised: the original holds the URL and headers.
430
+ raise e.class, redact(e.message, secrets)
431
+ end
432
+
433
+ # The JSON payload of a success, or nil for the documented empty successes
434
+ # (204, and an empty 200).
435
+ def json_request(method, path, params = {}, body = nil, secrets: [])
436
+ response = send_request(method, path, params, body, secrets)
437
+ text = response.body.to_s
438
+ return nil if response.code == 204 || text.strip.empty?
439
+
440
+ begin
441
+ JSON.parse(text, quirks_mode: true)
442
+ rescue JSON::ParserError
443
+ raise ApiResponseError.new(
444
+ redact(
445
+ ResponseError.message_from_parts(
446
+ response.code, (response.headers[:content_type] || '').to_s, text
447
+ ), secrets
448
+ ), response.code
449
+ )
450
+ end
451
+ end
452
+
453
+ # A CSV download, returned as text.
454
+ def csv(path, **params)
455
+ response = send_request(:get, path, params, nil, [])
456
+ text = response.body.to_s
457
+ content_type = (response.headers[:content_type] || '').to_s
458
+ if text.strip.empty? || content_type.downcase.include?('application/json')
459
+ raise ApiResponseError.new(
460
+ redact(ResponseError.message_from_parts(response.code, content_type, text)),
461
+ response.code
462
+ )
463
+ end
464
+ text
465
+ end
466
+ end
467
+ end
data/lib/zerobounce.rb CHANGED
@@ -5,6 +5,8 @@ require 'date'
5
5
  require 'tempfile'
6
6
 
7
7
  require 'zerobounce/error'
8
+ require 'zerobounce/response_error'
9
+ require 'zerobounce/client'
8
10
  require 'zerobounce/version'
9
11
  require 'zerobounce/download_type'
10
12
  require 'zerobounce/get_file_options'
@@ -15,11 +17,14 @@ require 'zerobounce/request'
15
17
  require 'zerobounce/mock_request'
16
18
  require 'zerobounce/configuration'
17
19
  require 'zerobounce/api_urls'
20
+ require 'zerobounce/priority3'
21
+ require 'zerobounce/warmup'
18
22
 
19
23
  # Validate an email address with Zerobounce.net
20
24
  module Zerobounce
21
25
 
22
26
  class << self
27
+ include Priority3
23
28
  attr_writer :configuration
24
29
 
25
30
  @@request = ENV['TEST']=='unit' ? MockRequest : Request
@@ -52,6 +57,15 @@ module Zerobounce
52
57
  yield configuration
53
58
  end
54
59
 
60
+ # Run a block with an API key that is visible only on this thread.
61
+ def with_api_key(api_key)
62
+ previous = Thread.current[:zerobounce_api_key]
63
+ Thread.current[:zerobounce_api_key] = api_key
64
+ yield
65
+ ensure
66
+ Thread.current[:zerobounce_api_key] = previous
67
+ end
68
+
55
69
  # Validates the email address and gets geoip information for an IP if provided.
56
70
  #
57
71
  # @param [String] :email The email address to validate.
@@ -80,9 +94,43 @@ module Zerobounce
80
94
  # "zipcode": null,
81
95
  # "processed_at": "2023-04-18 12:09:39.074"
82
96
  # }
83
- def validate(email, ip_address=nil)
97
+ def validate(email, ip_address=nil, timeout: nil, activity_data: nil, verify_plus: nil,
98
+ domain_info: nil, greylist_processing: nil, callback_url: nil, use_post: false)
99
+ unless timeout.nil?
100
+ timeout_i = Integer(timeout)
101
+ if timeout_i < 3 || timeout_i > 60
102
+ raise ArgumentError, 'Invalid parameter: timeout (must be between 3 and 60 seconds)'
103
+ end
104
+ end
105
+ unless callback_url.nil?
106
+ raise ArgumentError, 'Empty parameter: callback_url' if callback_url.to_s.strip.empty?
107
+
108
+ # The callback is where the greylist retry posts its result, so it is
109
+ # meaningless - and rejected by the API - without the retry itself.
110
+ unless greylist_processing
111
+ raise ArgumentError, 'callback_url requires greylist_processing to be true'
112
+ end
113
+ end
84
114
  params = {email: email, ip_address: ip_address}
85
- @@request.get('validate', params)
115
+ params[:timeout] = timeout unless timeout.nil?
116
+ unless activity_data.nil?
117
+ params[:activity_data] = activity_data ? 'true' : 'false'
118
+ end
119
+ unless verify_plus.nil?
120
+ params[:verify_plus] = verify_plus ? 'true' : 'false'
121
+ end
122
+ unless domain_info.nil?
123
+ params[:domain_info] = domain_info ? 'true' : 'false'
124
+ end
125
+ unless greylist_processing.nil?
126
+ params[:greylist_processing] = greylist_processing ? 'true' : 'false'
127
+ end
128
+ params[:callback_url] = callback_url unless callback_url.nil?
129
+ if use_post
130
+ @@request.post('validate', params, 'application/x-www-form-urlencoded')
131
+ else
132
+ @@request.get('validate', params)
133
+ end
86
134
  end
87
135
 
88
136
  # Get the number of remaining credits on the account.
@@ -183,13 +231,33 @@ module Zerobounce
183
231
  @@request.get('activity', params)
184
232
  end
185
233
 
234
+ # Real-time AI scoring for a single email (GET /scoring by default).
235
+ #
236
+ # @param [String] :email Email to score
237
+ # @param [Boolean] :use_post When true, uses POST /scoring with form body
238
+ #
239
+ # @return [Hash] e.g. {"email"=>"mark@gmail.com", "score"=>10}
240
+ def score_email(email, use_post: false)
241
+ params = { email: email }
242
+ if use_post
243
+ @@request.post('scoring', params, 'application/x-www-form-urlencoded')
244
+ else
245
+ @@request.get('scoring', params)
246
+ end
247
+ end
248
+
186
249
  # Validate email batch
187
250
  #
188
251
  # @param [Array] :emails List of email addresses to validate.
189
252
  # @option [Array] :ip_addresses Corresponding list of IP addresses.
190
253
  #
191
- # @return [Array] list of validate result for each element
192
- # [
254
+ # @return [Hash] the whole response: +email_batch+ holds one validate
255
+ # result per address, +errors+ the addresses that could not be
256
+ # validated. A per-address error does not raise; an account-level one
257
+ # (reported for the address "all", e.g. an invalid key) does.
258
+ # Up to 2.1.8 this returned only the +email_batch+ Array.
259
+ # {
260
+ # "email_batch": [
193
261
  # {
194
262
  # "address": "disposable@example.com",
195
263
  # "status": "do_not_mail",
@@ -216,8 +284,16 @@ module Zerobounce
216
284
  # ...
217
285
  # },
218
286
  # ...
219
- # ]
220
- def validate_batch(emails, ip_addresses=[])
287
+ # ],
288
+ # "errors": []
289
+ # }
290
+ def validate_batch(emails, ip_addresses=[], timeout: nil, activity_data: nil, verify_plus: nil)
291
+ unless timeout.nil?
292
+ timeout_i = Integer(timeout)
293
+ if timeout_i < 10 || timeout_i > 120
294
+ raise ArgumentError, 'Invalid parameter: timeout (must be between 10 and 120 seconds)'
295
+ end
296
+ end
221
297
 
222
298
  raise ArgumentError.new if emails.class != Array
223
299
  emails.each do |email|
@@ -232,8 +308,14 @@ module Zerobounce
232
308
  })
233
309
  end
234
310
  params = {email_batch: email_batch}
311
+ # LIVE: validatebatch takes timeout as a JSON *string*. Sent as a number
312
+ # the API rejects the whole batch with the account-level "Invalid API Key
313
+ # or your account ran out of credits".
314
+ 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?
235
317
  results = @@request.post('validatebatch', params)
236
- return results['email_batch']
318
+ results
237
319
  end
238
320
 
239
321
  # Validate CSV file
@@ -267,10 +349,10 @@ module Zerobounce
267
349
  email_address_column: email_address_column,
268
350
  has_header_row: has_header_row,
269
351
  }
270
- params[:first_name_column] = first_name_column if first_name_column
271
- params[:last_name_column] = last_name_column if last_name_column
272
- params[:gender_column] = gender_column if gender_column
273
- params[:return_url] = return_url if return_url
352
+ params[:first_name_column] = first_name_column unless first_name_column.nil?
353
+ params[:last_name_column] = last_name_column unless last_name_column.nil?
354
+ params[:gender_column] = gender_column unless gender_column.nil?
355
+ params[:return_url] = return_url unless return_url.nil? || return_url.to_s.empty?
274
356
  unless allow_phase_2.nil?
275
357
  params[:allow_phase_2] = allow_phase_2 ? 'true' : 'false'
276
358
  end
@@ -513,7 +595,8 @@ module Zerobounce
513
595
  #
514
596
  # @option [String] domain Domain to search within (e.g. example.com). Required if company_name is not provided.
515
597
  # @option [String] company_name Company name to search within (e.g. Example). Required if domain is not provided.
516
- # @option [String] first_name First name of target.
598
+ # @param [String] first_name First name of target. Optional: left out, the
599
+ # call answers with the domain's format rather than one person's address.
517
600
  # @option [String] middle_name Middle name of target.
518
601
  # @option [String] last_name Last name of target.
519
602
  #
@@ -526,7 +609,10 @@ module Zerobounce
526
609
  # "did_you_mean": "",
527
610
  # "failure_reason": ""
528
611
  # }
529
- def find_email(first_name, domain: '', company_name: '', middle_name: '', last_name: '')
612
+ # first_name is optional: /guessformat only requires a domain or a company
613
+ # name, and answers the domain's format when no name is given. It stays the
614
+ # first positional argument, so every existing call keeps working.
615
+ def find_email(first_name = nil, domain: '', company_name: '', middle_name: '', last_name: '')
530
616
  # Validate that exactly one of domain or company_name is provided
531
617
  if (domain.nil? || domain.empty?) && (company_name.nil? || company_name.empty?)
532
618
  raise ArgumentError, "Either domain or company_name must be provided"
@@ -534,7 +620,8 @@ module Zerobounce
534
620
  raise ArgumentError, "Only one of domain or company_name can be provided"
535
621
  end
536
622
 
537
- params = { first_name: first_name }
623
+ params = {}
624
+ params[:first_name] = first_name unless first_name.nil? || first_name.to_s.empty?
538
625
  params[:domain] = domain unless domain.nil? || domain.empty?
539
626
  params[:company_name] = company_name unless company_name.nil? || company_name.empty?
540
627
  params[:middle_name] = middle_name unless middle_name.nil? || middle_name.empty?