zerobounce-sdk 2.1.8 → 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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +172 -19
  3. data/README.md +477 -410
  4. data/lib/zerobounce/api_key.rb +21 -0
  5. data/lib/zerobounce/api_urls.rb +9 -0
  6. data/lib/zerobounce/base_request.rb +260 -8
  7. data/lib/zerobounce/client.rb +30 -0
  8. data/lib/zerobounce/configuration.rb +24 -3
  9. data/lib/zerobounce/error.rb +20 -0
  10. data/lib/zerobounce/get_file_helper.rb +6 -8
  11. data/lib/zerobounce/priority3.rb +351 -0
  12. data/lib/zerobounce/request.rb +84 -42
  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 +471 -0
  17. data/lib/zerobounce.rb +112 -26
  18. metadata +46 -41
  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/LICENSE.txt +0 -21
  36. data/Rakefile +0 -26
  37. data/SECURITY.md +0 -21
  38. data/bin/bundle +0 -105
  39. data/bin/console +0 -7
  40. data/bin/setup +0 -6
  41. data/data/zerobounce-ai-scoring.csv +0 -31
  42. data/data/zerobounce-batch-validation.csv +0 -31
  43. data/documentation.md +0 -530
  44. data/files/scoring.csv +0 -1
  45. data/files/validation.csv +0 -1
  46. data/files/zerobounce-ai-scoring.csv +0 -31
  47. data/files/zerobounce-batch-validation.csv +0 -31
  48. data/lib/zerobounce/mock_request.rb +0 -86
  49. data/zerobounce.gemspec +0 -49
@@ -0,0 +1,471 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'erb'
5
+ require 'json'
6
+ require 'rest-client'
7
+
8
+ require 'zerobounce/api_key'
9
+ require 'zerobounce/error'
10
+ require 'zerobounce/response_error'
11
+
12
+ module Zerobounce
13
+ # Client for the ZeroBounce Warmup API
14
+ # (https://www.zerobounce.net/docs/warmup-api).
15
+ #
16
+ # The Warmup API is separate from the email validation API: its own host, its
17
+ # own team-scoped key, and an "API-Key" header instead of an api_key
18
+ # parameter. Use Zerobounce::Warmup with a Warmup API key; the Zerobounce
19
+ # module stays for the validation API key.
20
+ #
21
+ # warmup = Zerobounce::Warmup.new('<YOUR_WARMUP_API_KEY>')
22
+ # warmup.list_sending_accounts
23
+ #
24
+ # A documented empty success (204, or an empty 200) comes back as nil. A
25
+ # payload that is not a JSON object - /seeds is an array, /seed-count a bare
26
+ # number - comes back as the parsed value itself.
27
+ class Warmup
28
+ DEFAULT_BASE_URL = 'https://warmup-api.zerobounce.net'
29
+ BASE_PATH = '/api/v1/warmup'
30
+
31
+ ONBOARDING_MODELS = %w[self_managed zb_managed].freeze
32
+ EMAIL_TYPES = %w[transactional newsletter marketing].freeze
33
+
34
+ # Required by the schema but always replaced with the key's own team.
35
+ ANY_TEAM_ID = 1
36
+
37
+ # @param api_key [String] your Warmup API key, sent in the API-Key header
38
+ # @param base_url [String] defaults to https://warmup-api.zerobounce.net
39
+ def initialize(api_key, base_url = DEFAULT_BASE_URL)
40
+ raise ArgumentError, 'api_key is required' if api_key.nil? || api_key.to_s.strip.empty?
41
+
42
+ # A key with surrounding whitespace or control characters would be
43
+ # quoted, unredacted, in the HTTP library's own error.
44
+ ApiKey.check!(api_key)
45
+ unless base_url.to_s.downcase.start_with?('https://')
46
+ raise ArgumentError, 'base_url must be an https:// URL'
47
+ end
48
+
49
+ @api_key = api_key
50
+ root = base_url.to_s
51
+ root = root.chomp('/') while root.end_with?('/')
52
+ @root = "#{root}#{BASE_PATH}"
53
+ end
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
+
60
+ # -- sending accounts ---------------------------------------------------
61
+
62
+ # Every sending account of the key's team. GET /sending-accounts
63
+ def list_sending_accounts
64
+ get('/sending-accounts')
65
+ end
66
+
67
+ # One sending account. GET /sending-account
68
+ def get_sending_account(from_address)
69
+ require_value(from_address, 'from_address')
70
+ get('/sending-account', from_address: from_address)
71
+ end
72
+
73
+ # Adds a from-address to warmup. POST /sending-accounts
74
+ #
75
+ # +self_managed+ runs your own mailbox and needs +engagement_rule_id+ 0-4.
76
+ # +zb_managed+ has ZeroBounce provision it and takes +email_content+ and
77
+ # +managed_sending+; a 200 means accepted, and the mailbox is provisioned
78
+ # afterwards.
79
+ def add_sending_account(from_address, onboarding_model, **options)
80
+ require_value(from_address, 'from_address')
81
+ unless ONBOARDING_MODELS.include?(onboarding_model.to_s)
82
+ raise ArgumentError, 'onboarding_model must be self_managed or zb_managed'
83
+ end
84
+
85
+ body = { 'from_address' => from_address, 'onboarding_model' => onboarding_model.to_s }
86
+ if onboarding_model.to_s == 'self_managed'
87
+ rule = options[:engagement_rule_id]
88
+ raise ArgumentError, 'engagement_rule_id is required for self_managed' if rule.nil?
89
+
90
+ body['engagement_rule_id'] = in_range(rule, 0, 4, 'engagement_rule_id')
91
+ if options.key?(:email_content) || options.key?(:managed_sending)
92
+ raise ArgumentError, 'email_content and managed_sending are for zb_managed'
93
+ end
94
+ end
95
+ body['ignore_conflict'] = !options[:ignore_conflict].nil? && options[:ignore_conflict] ? true : false if
96
+ options.key?(:ignore_conflict)
97
+ body['email_content'] = email_content_body(options[:email_content]) if options.key?(:email_content)
98
+ body['managed_sending'] = managed_sending_body(options[:managed_sending]) if options.key?(:managed_sending)
99
+
100
+ json_request(:post, '/sending-accounts', {}, body)
101
+ end
102
+
103
+ # Changes an account's engagement rule, status and auto-enable flag.
104
+ # PUT /sending-accounts. +status_id+ is 0 to 8 (4 Paused, 5 Disabled).
105
+ def update_sending_account(from_address, engagement_rule_id, status_id, auto_enable)
106
+ require_value(from_address, 'from_address')
107
+ unless [true, false].include?(auto_enable)
108
+ raise ArgumentError, 'auto_enable must be true or false'
109
+ end
110
+
111
+ json_request(:put, '/sending-accounts', {},
112
+ { 'team_id' => ANY_TEAM_ID,
113
+ 'from_address' => from_address,
114
+ 'engagement_rule_id' => in_range(engagement_rule_id, 0, 4, 'engagement_rule_id'),
115
+ 'status_id' => in_range(status_id, 0, 8, 'status_id'),
116
+ 'auto_enable' => auto_enable })
117
+ end
118
+
119
+ # Removes a sending account. DELETE /sending-accounts. Destructive and final.
120
+ def delete_sending_account(from_address)
121
+ require_value(from_address, 'from_address')
122
+ json_request(:delete, '/sending-accounts', { from_address: from_address })
123
+ end
124
+
125
+ # Moves an account and its history to a new from-address.
126
+ # PUT /smtp-credentials - despite the path, no SMTP credentials are stored.
127
+ def change_from_address(old_from_address, new_from_address)
128
+ require_value(old_from_address, 'old_from_address')
129
+ require_value(new_from_address, 'new_from_address')
130
+ json_request(:put, '/smtp-credentials', {},
131
+ { 'team_id' => ANY_TEAM_ID,
132
+ 'old_from_address' => old_from_address,
133
+ 'new_from_address' => new_from_address })
134
+ end
135
+
136
+ # Registered from-addresses. GET /from-addresses
137
+ #
138
+ # +search_from+ takes letters, digits and . - _ @ + only. +offset+ and
139
+ # +limit+ go on the wire as strings.
140
+ def list_from_addresses(search_from: nil, offset: nil, limit: nil)
141
+ params = {}
142
+ unless search_from.nil?
143
+ unless search_from.to_s.match?(/\A[A-Za-z0-9.\-_@+]*\z/)
144
+ raise ArgumentError, 'search_from takes letters, digits and . - _ @ + only'
145
+ end
146
+
147
+ params[:search_from] = search_from
148
+ end
149
+ { offset: offset, limit: limit }.each do |name, value|
150
+ next if value.nil?
151
+ raise ArgumentError, "#{name} must be a non-negative integer" unless value.is_a?(Integer) && value >= 0
152
+
153
+ params[name] = value.to_s
154
+ end
155
+ get('/from-addresses', **params)
156
+ end
157
+
158
+ # -- engagement rules ---------------------------------------------------
159
+
160
+ # -1 Custom, 0 Default, 1 Slow Pace, 2 Repair, 3 Boost, 4 Protect.
161
+ def engagement_rules
162
+ get('/engagement-rules')
163
+ end
164
+
165
+ # Sets a standard rule (0 to 4) on an address. PUT /engagement-rule
166
+ def set_engagement_rule(c_from, rule_id)
167
+ require_value(c_from, 'c_from')
168
+ json_request(:put, '/engagement-rule', { c_from: c_from },
169
+ { 'rule_id' => in_range(rule_id, 0, 4, 'rule_id') })
170
+ end
171
+
172
+ # An address's custom rule, with can_update and remaining_seconds.
173
+ def custom_engagement(c_from)
174
+ require_value(c_from, 'c_from')
175
+ segment = c_from.to_s.strip
176
+ raise ArgumentError, 'c_from is invalid' if ['.', '..'].include?(segment)
177
+
178
+ get("/custom-engagement/#{ERB::Util.url_encode(segment)}")
179
+ end
180
+
181
+ # Creates a custom rule. Starts a 7 day cooldown on updates.
182
+ def create_custom_engagement(c_from, reply_pct, important_pct, click_pct)
183
+ json_request(:post, '/custom-engagement', {},
184
+ engagement_body(c_from, reply_pct, important_pct, click_pct))
185
+ end
186
+
187
+ # Updates a custom rule. During the 7 day cooldown the API answers 429.
188
+ def update_custom_engagement(c_from, reply_pct, important_pct, click_pct)
189
+ json_request(:put, '/custom-engagement', {},
190
+ engagement_body(c_from, reply_pct, important_pct, click_pct))
191
+ end
192
+
193
+ # Deletes a custom rule and falls back to standard rule 1-4.
194
+ def delete_custom_engagement(c_from, engagement_rule_id)
195
+ require_value(c_from, 'c_from')
196
+ json_request(:delete, '/custom-engagement', {},
197
+ { 'c_from' => c_from,
198
+ 'engagement_rule_id' => in_range(engagement_rule_id, 1, 4, 'engagement_rule_id') })
199
+ end
200
+
201
+ # -- statistics, seeds and utilities ------------------------------------
202
+
203
+ # The seed list. GET /seeds
204
+ def seeds
205
+ get('/seeds')
206
+ end
207
+
208
+ # The seed list as CSV (columns seed,provider). GET /seeds/download
209
+ def download_seeds
210
+ csv('/seeds/download')
211
+ end
212
+
213
+ # Number of seeds. GET /seed-count
214
+ def seed_count
215
+ get('/seed-count')
216
+ end
217
+
218
+ # Today's warmup progress. GET /progress-today
219
+ def progress_today
220
+ get('/progress-today')
221
+ end
222
+
223
+ # Messages sent today. GET /sent-today
224
+ def sent_today
225
+ get('/sent-today')
226
+ end
227
+
228
+ # Current daily volume. GET /emails-per-day
229
+ def emails_per_day
230
+ get('/emails-per-day')
231
+ end
232
+
233
+ # Statistics for an address. An address with no history returns zeros.
234
+ def statistics(from_address)
235
+ require_value(from_address, 'from_address')
236
+ get('/statistics', from_address: from_address)
237
+ end
238
+
239
+ # Daily statistics between two dates, inclusive. GET /statistics-per-day
240
+ def statistics_per_day(from_address, start_date, end_date)
241
+ require_value(from_address, 'from_address')
242
+ first = api_date(start_date, 'start_date')
243
+ last = api_date(end_date, 'end_date')
244
+ raise ArgumentError, 'start_date is later than end_date' if first > last
245
+
246
+ get('/statistics-per-day', from_address: from_address, start_date: first, end_date: last)
247
+ end
248
+
249
+ # Detailed statistics for an address. GET /statistics-detailed
250
+ def statistics_detailed(c_from)
251
+ require_value(c_from, 'c_from')
252
+ get('/statistics-detailed', c_from: c_from)
253
+ end
254
+
255
+ # An address's statistics as CSV. GET /statistics/download
256
+ def download_statistics(c_from)
257
+ require_value(c_from, 'c_from')
258
+ csv('/statistics/download', c_from: c_from)
259
+ end
260
+
261
+ # Statistics for one from-address. GET /from-address-statistics
262
+ def from_address_statistics(from_address)
263
+ require_value(from_address, 'from_address')
264
+ get('/from-address-statistics', from_address: from_address)
265
+ end
266
+
267
+ # SPF, DKIM and DMARC status of a from-address's domain.
268
+ # GET /check-domain-dmarc
269
+ #
270
+ # LIVE: pass an EMAIL ADDRESS. The live API answers "Please provide a valid
271
+ # email address" for a bare domain, although the docs describe a domain.
272
+ def check_domain_dmarc(from_address, dkim_selector: nil)
273
+ require_value(from_address, 'from_address')
274
+ unless from_address.to_s.include?('@')
275
+ raise ArgumentError, 'from_address must be an email address, e.g. sender@example.com'
276
+ end
277
+
278
+ params = { from_address: from_address }
279
+ unless dkim_selector.nil?
280
+ require_value(dkim_selector, 'dkim_selector')
281
+ params[:dkim_selector] = dkim_selector
282
+ end
283
+ get('/check-domain-dmarc', **params)
284
+ end
285
+
286
+ # Checks that SMTP credentials connect and hand off a message.
287
+ # POST /test-smtp-configuration
288
+ #
289
+ # Nothing is stored, and the test message goes to a ZeroBounce test
290
+ # recipient, not +from_address+.
291
+ #
292
+ # LIVE: the API answers any failed SMTP login with HTTP 500. That is an API
293
+ # bug. The username and password are masked in every error from this method.
294
+ def test_smtp_configuration(from_address, username, password, server, port)
295
+ require_value(from_address, 'from_address')
296
+ require_value(username, 'username')
297
+ require_value(password, 'password')
298
+ require_value(server, 'server')
299
+ raise ArgumentError, 'port must be 1 to 65535' unless port.is_a?(Integer) && port.between?(1, 65_535)
300
+
301
+ json_request(:post, '/test-smtp-configuration', {},
302
+ { 'from_address' => from_address,
303
+ 'username' => username,
304
+ 'password' => password,
305
+ 'server' => server,
306
+ 'port' => port },
307
+ secrets: [password, username])
308
+ end
309
+
310
+ private
311
+
312
+ attr_reader :api_key, :root
313
+
314
+ # -- argument checks ----------------------------------------------------
315
+
316
+ def require_value(value, name)
317
+ raise ArgumentError, "#{name} is required" if value.nil? || value.to_s.strip.empty?
318
+
319
+ value
320
+ end
321
+
322
+ def in_range(value, low, high, name)
323
+ unless value.is_a?(Integer) && value.between?(low, high)
324
+ raise ArgumentError, "#{name} must be #{low} to #{high}"
325
+ end
326
+
327
+ value
328
+ end
329
+
330
+ # A date for the API: a Date/Time or a YYYY-MM-DD string.
331
+ def api_date(value, name)
332
+ return value.strftime('%Y-%m-%d') if value.respond_to?(:strftime)
333
+ return value if value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
334
+
335
+ raise ArgumentError, "#{name} must be YYYY-MM-DD"
336
+ end
337
+
338
+ def engagement_body(c_from, reply_pct, important_pct, click_pct)
339
+ require_value(c_from, 'c_from')
340
+ in_range(reply_pct, 5, 45, 'reply_pct')
341
+ in_range(important_pct, 5, 40, 'important_pct')
342
+ in_range(click_pct, 0, 70, 'click_pct')
343
+ if click_pct > important_pct * 1.75
344
+ raise ArgumentError, 'click_pct must be at most important_pct x 1.75'
345
+ end
346
+
347
+ { 'c_from' => c_from, 'reply_pct' => reply_pct,
348
+ 'important_pct' => important_pct, 'click_pct' => click_pct }
349
+ end
350
+
351
+ def email_content_body(content)
352
+ raise ArgumentError, 'email_content must be a Hash' unless content.is_a?(Hash)
353
+
354
+ body = {}
355
+ types = content[:types_of_emails] || content['types_of_emails']
356
+ unless types.nil?
357
+ Array(types).each do |kind|
358
+ unless EMAIL_TYPES.include?(kind.to_s)
359
+ raise ArgumentError, 'types_of_emails must be transactional, newsletter and/or marketing'
360
+ end
361
+ end
362
+ body['types_of_emails'] = Array(types).map(&:to_s)
363
+ end
364
+ %i[industry company_name website].each do |key|
365
+ value = content[key] || content[key.to_s]
366
+ body[key.to_s] = value unless value.nil?
367
+ end
368
+ body
369
+ end
370
+
371
+ def managed_sending_body(sending)
372
+ raise ArgumentError, 'managed_sending must be a Hash' unless sending.is_a?(Hash)
373
+
374
+ body = {}
375
+ offset = sending[:timezone_offset] || sending['timezone_offset']
376
+ body['timezone_offset'] = in_range(offset, -11, 14, 'timezone_offset') unless offset.nil?
377
+ start = sending[:warmup_start_date] || sending['warmup_start_date']
378
+ body['warmup_start_date'] = api_date(start, 'warmup_start_date') unless start.nil?
379
+ limit = sending[:daily_limit] || sending['daily_limit']
380
+ body['daily_limit'] = limit unless limit.nil?
381
+ body
382
+ end
383
+
384
+ # -- transport ----------------------------------------------------------
385
+
386
+ # Replaces every form of a secret in a message with ***.
387
+ def redact(text, secrets = [])
388
+ value = text.to_s
389
+ ([api_key] + secrets).each do |secret|
390
+ next if secret.nil? || secret.to_s.empty?
391
+
392
+ value = value.gsub(secret.to_s, '***').gsub(ERB::Util.url_encode(secret.to_s), '***')
393
+ end
394
+ value
395
+ end
396
+
397
+ def get(path, **params)
398
+ json_request(:get, path, params)
399
+ end
400
+
401
+ def send_request(method, path, params, body, secrets)
402
+ url = "#{root}#{path}"
403
+ options = {
404
+ method: method,
405
+ url: url,
406
+ timeout: 120,
407
+ max_redirects: 0,
408
+ headers: { 'API-Key' => api_key, accept: :json, params: params }
409
+ }
410
+ if body
411
+ options[:payload] = body.to_json
412
+ options[:headers][:content_type] = :json
413
+ end
414
+ RestClient::Request.execute(**options)
415
+ rescue RestClient::ExceptionWithResponse => e
416
+ response = e.response
417
+ status = response.respond_to?(:code) ? response.code : 0
418
+ if status >= 300 && status < 400
419
+ raise ApiResponseError.new(
420
+ 'ZeroBounce returned a redirect, which this client does not follow.', status
421
+ )
422
+ end
423
+ raise ApiResponseError.new(
424
+ redact(
425
+ ResponseError.message_from_parts(
426
+ status, (response.headers[:content_type] || '').to_s, response.body.to_s
427
+ ), secrets
428
+ ), status
429
+ )
430
+ rescue ApiResponseError
431
+ raise
432
+ rescue StandardError => e
433
+ # Rebuilt rather than re-raised: the original holds the URL and headers.
434
+ raise e.class, redact(e.message, secrets)
435
+ end
436
+
437
+ # The JSON payload of a success, or nil for the documented empty successes
438
+ # (204, and an empty 200).
439
+ def json_request(method, path, params = {}, body = nil, secrets: [])
440
+ response = send_request(method, path, params, body, secrets)
441
+ text = response.body.to_s
442
+ return nil if response.code == 204 || text.strip.empty?
443
+
444
+ begin
445
+ JSON.parse(text, quirks_mode: true)
446
+ rescue JSON::ParserError
447
+ raise ApiResponseError.new(
448
+ redact(
449
+ ResponseError.message_from_parts(
450
+ response.code, (response.headers[:content_type] || '').to_s, text
451
+ ), secrets
452
+ ), response.code
453
+ )
454
+ end
455
+ end
456
+
457
+ # A CSV download, returned as text.
458
+ def csv(path, **params)
459
+ response = send_request(:get, path, params, nil, [])
460
+ text = response.body.to_s
461
+ content_type = (response.headers[:content_type] || '').to_s
462
+ if text.strip.empty? || content_type.downcase.include?('application/json')
463
+ raise ApiResponseError.new(
464
+ redact(ResponseError.message_from_parts(response.code, content_type, text)),
465
+ response.code
466
+ )
467
+ end
468
+ text
469
+ end
470
+ end
471
+ end