lettermint 0.1.0 → 0.2.1

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.
data/docs/sdk.md ADDED
@@ -0,0 +1,510 @@
1
+ # Lettermint Ruby SDK Reference
2
+
3
+ `gem 'lettermint'` v0.2.1 · Ruby >= 3.2
4
+
5
+ ---
6
+
7
+ ## Quick Start
8
+
9
+ ```ruby
10
+ require 'lettermint'
11
+
12
+ # Sending API (project-level email sending)
13
+ client = Lettermint::SendingAPI.new(api_token: 'lm_project_xxx')
14
+
15
+ # Team API (team-level management)
16
+ team = Lettermint::TeamAPI.new(team_token: 'lm_team_xxx')
17
+ ```
18
+
19
+ ---
20
+
21
+ ## Authentication
22
+
23
+ | API | Token Format | Header |
24
+ |-----|--------------|--------|
25
+ | SendingAPI | `lm_project_*` | `x-lettermint-token` |
26
+ | TeamAPI | `lm_team_*` | `Authorization: Bearer` |
27
+
28
+ ```ruby
29
+ # Global configuration (optional)
30
+ Lettermint.configure do |config|
31
+ config.base_url = 'https://api.lettermint.co/v1'
32
+ config.timeout = 30
33
+ end
34
+
35
+ # Instance-level configuration
36
+ client = Lettermint::SendingAPI.new(api_token: 'xxx', base_url: 'https://...', timeout: 60)
37
+ ```
38
+
39
+ ---
40
+
41
+ ## Sending Emails
42
+
43
+ ```ruby
44
+ client = Lettermint::SendingAPI.new(api_token: 'lm_project_xxx')
45
+
46
+ # Fluent builder API
47
+ response = client.email
48
+ .from('sender@example.com')
49
+ .to('recipient@example.com')
50
+ .subject('Hello')
51
+ .html('<h1>Hello World</h1>')
52
+ .text('Hello World')
53
+ .deliver
54
+
55
+ response.message_id # => "msg_xxx"
56
+ response.status # => "queued"
57
+ ```
58
+
59
+ ### EmailMessage Methods
60
+
61
+ | Method | Description |
62
+ |--------|-------------|
63
+ | `from(email)` | Sender address (required) |
64
+ | `to(*emails)` | Recipient(s) (required, accepts array) |
65
+ | `subject(str)` | Subject line (required) |
66
+ | `html(str)` | HTML body |
67
+ | `text(str)` | Plain text body |
68
+ | `cc(*emails)` | CC recipients |
69
+ | `bcc(*emails)` | BCC recipients |
70
+ | `reply_to(*emails)` | Reply-to addresses |
71
+ | `route(slug)` | Route slug |
72
+ | `tag(str)` | Message tag |
73
+ | `headers(hash)` | Custom headers |
74
+ | `metadata(hash)` | Custom metadata |
75
+ | `attach(filename, content, content_id:)` | Add attachment |
76
+ | `idempotency_key(key)` | Idempotency key header |
77
+ | `deliver` | Send the email, returns `SendEmailResponse` |
78
+
79
+ ### Attachments
80
+
81
+ ```ruby
82
+ # Simple attachment
83
+ client.email
84
+ .from('sender@example.com')
85
+ .to('recipient@example.com')
86
+ .subject('Report')
87
+ .html('<p>See attached</p>')
88
+ .attach('report.pdf', Base64.strict_encode64(pdf_data))
89
+ .deliver
90
+
91
+ # With content ID (for inline images)
92
+ client.email
93
+ .attach('logo.png', base64_data, content_id: 'logo')
94
+ .html('<img src="cid:logo">')
95
+ ...
96
+
97
+ # Using EmailAttachment type
98
+ attachment = Lettermint::EmailAttachment.new(
99
+ filename: 'doc.pdf',
100
+ content: base64_data,
101
+ content_id: nil
102
+ )
103
+ client.email.attach(attachment)...
104
+ ```
105
+
106
+ ---
107
+
108
+ ## Team API Resources
109
+
110
+ ```ruby
111
+ team = Lettermint::TeamAPI.new(team_token: 'lm_team_xxx')
112
+ ```
113
+
114
+ ### Generic
115
+
116
+ ```ruby
117
+ team.ping # => { 'ok' => true }
118
+ ```
119
+
120
+ ### Team
121
+
122
+ ```ruby
123
+ # Get team details
124
+ team.team.get # => Hash
125
+ team.team.get(include: 'features,featuresCount') # with includes
126
+
127
+ # Update team
128
+ team.team.update(name: 'New Name')
129
+
130
+ # Usage statistics
131
+ team.team.usage # => { current_period: ..., historical_usage: [...] }
132
+
133
+ # List members (paginated)
134
+ team.team.members(page_size: 50, page_cursor: 'cursor_xxx')
135
+ ```
136
+
137
+ ### Domains
138
+
139
+ ```ruby
140
+ # List domains
141
+ team.domains.list
142
+ team.domains.list(status: 'verified', sort: '-created_at', page_size: 10)
143
+
144
+ # Create domain
145
+ team.domains.create(domain: 'mail.example.com')
146
+
147
+ # Get domain
148
+ team.domains.find('dom_xxx')
149
+ team.domains.find('dom_xxx', include: 'dnsRecords')
150
+
151
+ # Delete domain
152
+ team.domains.delete('dom_xxx')
153
+
154
+ # Verify DNS records
155
+ team.domains.verify_dns('dom_xxx') # all records
156
+ team.domains.verify_dns_record('dom_xxx', 'rec_xxx') # single record
157
+
158
+ # Update associated projects
159
+ team.domains.update_projects('dom_xxx', project_ids: ['proj_1', 'proj_2'])
160
+ ```
161
+
162
+ ### Projects
163
+
164
+ ```ruby
165
+ # List projects
166
+ team.projects.list
167
+ team.projects.list(search: 'api', sort: 'name')
168
+
169
+ # Create project
170
+ team.projects.create(
171
+ name: 'My Project',
172
+ smtp_enabled: true,
173
+ initial_routes: 'transactional' # both, transactional, broadcast
174
+ )
175
+ # => { ..., 'api_token' => 'lm_project_xxx' }
176
+
177
+ # Get project
178
+ team.projects.find('proj_xxx')
179
+ team.projects.find('proj_xxx', include: 'routes,domains,messageStats')
180
+
181
+ # Update project
182
+ team.projects.update('proj_xxx',
183
+ name: 'Renamed',
184
+ smtp_enabled: false,
185
+ default_route_id: 'route_xxx'
186
+ )
187
+
188
+ # Delete project
189
+ team.projects.delete('proj_xxx')
190
+
191
+ # Rotate API token
192
+ team.projects.rotate_token('proj_xxx') # => { 'new_token' => '...' }
193
+
194
+ # Manage members
195
+ team.projects.update_members('proj_xxx', team_member_ids: ['mem_1', 'mem_2'])
196
+ team.projects.add_member('proj_xxx', 'mem_xxx')
197
+ team.projects.remove_member('proj_xxx', 'mem_xxx')
198
+ ```
199
+
200
+ ### Routes
201
+
202
+ ```ruby
203
+ # Project-scoped routes
204
+ routes = team.projects.routes('proj_xxx')
205
+
206
+ routes.list
207
+ routes.list(route_type: 'transactional', is_default: true, sort: '-created_at')
208
+
209
+ routes.create(
210
+ name: 'Notifications',
211
+ route_type: 'transactional', # transactional, broadcast, inbound
212
+ slug: 'notifications'
213
+ )
214
+
215
+ # Direct route access (any route by ID)
216
+ team.routes.find('route_xxx')
217
+ team.routes.find('route_xxx', include: 'project,statistics')
218
+
219
+ team.routes.update('route_xxx',
220
+ name: 'Renamed',
221
+ settings: { track_opens: true, track_clicks: true },
222
+ inbound_settings: { inbound_domain: 'in.example.com', inbound_spam_threshold: 5 }
223
+ )
224
+
225
+ team.routes.delete('route_xxx')
226
+ team.routes.verify_inbound_domain('route_xxx')
227
+ ```
228
+
229
+ ### Messages
230
+
231
+ ```ruby
232
+ # List messages
233
+ team.messages.list
234
+ team.messages.list(
235
+ type: 'outbound',
236
+ status: 'delivered',
237
+ route_id: 'route_xxx',
238
+ from_date: '2024-01-01',
239
+ to_date: '2024-01-31',
240
+ sort: '-created_at'
241
+ )
242
+
243
+ # Get message
244
+ team.messages.find('msg_xxx')
245
+
246
+ # Message events (delivery history)
247
+ team.messages.events('msg_xxx')
248
+ team.messages.events('msg_xxx', sort: '-timestamp')
249
+
250
+ # Message content
251
+ team.messages.source('msg_xxx') # RFC822 format
252
+ team.messages.html('msg_xxx') # HTML body
253
+ team.messages.text('msg_xxx') # Plain text body
254
+ ```
255
+
256
+ ### Webhooks
257
+
258
+ ```ruby
259
+ # List webhooks
260
+ team.webhooks.list
261
+ team.webhooks.list(enabled: true, event: 'message.delivered', route_id: 'route_xxx')
262
+
263
+ # Create webhook (returns secret once)
264
+ result = team.webhooks.create(
265
+ route_id: 'route_xxx',
266
+ name: 'Delivery notifications',
267
+ url: 'https://example.com/webhooks',
268
+ events: ['message.delivered', 'message.hard_bounced'],
269
+ enabled: true
270
+ )
271
+ secret = result['secret'] # Store this securely
272
+
273
+ # Get webhook
274
+ team.webhooks.find('wh_xxx')
275
+
276
+ # Update webhook
277
+ team.webhooks.update('wh_xxx', name: 'Renamed', enabled: false)
278
+
279
+ # Delete webhook
280
+ team.webhooks.delete('wh_xxx')
281
+
282
+ # Test webhook
283
+ team.webhooks.test('wh_xxx') # => { 'delivery_id' => '...' }
284
+
285
+ # Regenerate secret
286
+ team.webhooks.regenerate_secret('wh_xxx') # => { 'secret' => '...' }
287
+
288
+ # Webhook deliveries
289
+ team.webhooks.deliveries('wh_xxx')
290
+ team.webhooks.deliveries('wh_xxx', status: 'failed', from_date: '2024-01-01')
291
+ team.webhooks.delivery('wh_xxx', 'del_xxx') # Full delivery details
292
+ ```
293
+
294
+ ### Suppressions
295
+
296
+ ```ruby
297
+ # List suppressions
298
+ team.suppressions.list
299
+ team.suppressions.list(scope: 'team', reason: 'hard_bounce')
300
+
301
+ # Create suppression
302
+ team.suppressions.create(
303
+ reason: 'manual', # spam_complaint, hard_bounce, unsubscribe, manual
304
+ scope: 'team', # global, team, project, route
305
+ email: 'bad@example.com'
306
+ )
307
+
308
+ # Bulk suppress (up to 1000)
309
+ team.suppressions.create(
310
+ reason: 'manual',
311
+ scope: 'project',
312
+ project_id: 'proj_xxx',
313
+ emails: ['a@example.com', 'b@example.com']
314
+ )
315
+
316
+ # Delete suppression
317
+ team.suppressions.delete('sup_xxx')
318
+ ```
319
+
320
+ ### Stats
321
+
322
+ ```ruby
323
+ # Get statistics (max 90 day range)
324
+ team.stats.get(from: '2024-01-01', to: '2024-03-31')
325
+ team.stats.get(from: '2024-01-01', to: '2024-01-31', project_id: 'proj_xxx')
326
+ team.stats.get(from: '2024-01-01', to: '2024-01-31', route_ids: ['r1', 'r2'])
327
+
328
+ # Returns:
329
+ # {
330
+ # 'from' => '2024-01-01',
331
+ # 'to' => '2024-01-31',
332
+ # 'totals' => { 'sent' => 1000, 'delivered' => 980, ... },
333
+ # 'daily' => [{ 'date' => '2024-01-01', 'sent' => 50, ... }, ...]
334
+ # }
335
+ ```
336
+
337
+ ---
338
+
339
+ ## Webhook Verification
340
+
341
+ Standalone module, no client dependency.
342
+
343
+ ```ruby
344
+ # Instance-based
345
+ webhook = Lettermint::Webhook.new(secret: 'whsec_xxx', tolerance: 300)
346
+
347
+ # Verify from headers (recommended)
348
+ payload = webhook.verify_headers(request.headers, request.raw_body)
349
+ # => parsed JSON hash
350
+
351
+ # Verify from signature string
352
+ payload = webhook.verify(raw_body, signature_header)
353
+
354
+ # Class method (one-shot)
355
+ payload = Lettermint::Webhook.verify_signature(
356
+ raw_body,
357
+ signature_header,
358
+ secret: 'whsec_xxx',
359
+ tolerance: 300
360
+ )
361
+ ```
362
+
363
+ ### Signature Format
364
+
365
+ Header: `x-lettermint-signature: t=<unix_ts>,v1=<hex_hmac>`
366
+ Timestamp: `x-lettermint-delivery: <unix_ts>`
367
+
368
+ ---
369
+
370
+ ## Error Handling
371
+
372
+ ```ruby
373
+ begin
374
+ client.email.from('x').to('y').subject('z').html('...').deliver
375
+ rescue Lettermint::ValidationError => e
376
+ e.message # Error message
377
+ e.status_code # 422
378
+ e.error_type # Validation error type (same as e.code for ApiError bodies)
379
+ e.response_body # Raw response
380
+ rescue Lettermint::ClientError => e
381
+ e.status_code # 400
382
+ rescue Lettermint::AuthenticationError => e
383
+ e.status_code # 401 or 403
384
+ rescue Lettermint::NotFoundError => e
385
+ e.status_code # 404
386
+ e.code # e.g. "RESOURCE_NOT_FOUND" (may be nil)
387
+ e.details # Hash, Array or nil; shape depends on code
388
+ rescue Lettermint::RateLimitError => e
389
+ e.retry_after # Seconds to wait (may be nil)
390
+ rescue Lettermint::TimeoutError
391
+ # Request timed out
392
+ rescue Lettermint::ConnectionError => e
393
+ e.original_exception # Underlying Faraday error
394
+ rescue Lettermint::ResponseParsingError => e
395
+ e.original_exception # Faraday::ParsingError
396
+ rescue Lettermint::HttpRequestError => e
397
+ # Catch-all for HTTP errors
398
+ e.status_code # Integer
399
+ e.code # API error code string (may be nil)
400
+ e.details # Hash, Array or nil
401
+ e.response_body # Hash (JSON), String (non-JSON) or nil (empty)
402
+ rescue Lettermint::Error => e
403
+ # Catch-all for SDK errors
404
+ end
405
+ ```
406
+
407
+ ### Webhook Errors
408
+
409
+ ```ruby
410
+ begin
411
+ webhook.verify_headers(headers, body)
412
+ rescue Lettermint::InvalidSignatureError
413
+ # HMAC mismatch
414
+ rescue Lettermint::TimestampToleranceError
415
+ # Timestamp too old or future
416
+ rescue Lettermint::WebhookJsonDecodeError => e
417
+ e.original_exception # JSON::ParserError
418
+ rescue Lettermint::WebhookVerificationError
419
+ # Catch-all for webhook errors
420
+ end
421
+ ```
422
+
423
+ ---
424
+
425
+ ## Error Hierarchy
426
+
427
+ ```
428
+ Lettermint::Error
429
+ ├── HttpRequestError (status_code, response_body, code, details)
430
+ │ ├── ValidationError (422, error_type)
431
+ │ ├── ClientError (400)
432
+ │ ├── AuthenticationError (401/403)
433
+ │ ├── NotFoundError (404)
434
+ │ └── RateLimitError (429, retry_after)
435
+ ├── TimeoutError
436
+ ├── ConnectionError (original_exception)
437
+ ├── ResponseParsingError (original_exception)
438
+ └── WebhookVerificationError
439
+ ├── InvalidSignatureError
440
+ ├── TimestampToleranceError
441
+ └── WebhookJsonDecodeError (original_exception)
442
+ ```
443
+
444
+ ### Error Bodies
445
+
446
+ `HttpRequestError` and its subclasses read three JSON body shapes, matching the official Lettermint SDKs:
447
+
448
+ | Body | Sets |
449
+ |------|------|
450
+ | `{"error":{"code":"...","message":"...","details":...}}` | `code`, `message`, `details` |
451
+ | `{"error":"<string>"}` | `code` |
452
+ | `{"message":"<string>"}` | `message` |
453
+
454
+ `message` resolves in order: `error.message`, top-level `message`, then `"HTTP <status>"`.
455
+
456
+ `response_body` is the parsed Hash for JSON responses, the stripped String for non-JSON responses (for example an HTML 404 page from a proxy), and nil for an empty body.
457
+
458
+ Limitation: a non-JSON body served with a JSON Content-Type raises `ResponseParsingError` before status mapping, so such a proxy 404 is not a `NotFoundError`.
459
+
460
+ ---
461
+
462
+ ## Types
463
+
464
+ ```ruby
465
+ # SendEmailResponse (Data.define, frozen)
466
+ response = client.email...deliver
467
+ response.message_id # String
468
+ response.status # String
469
+
470
+ # EmailAttachment (Data.define, frozen)
471
+ attachment = Lettermint::EmailAttachment.new(
472
+ filename: 'report.pdf',
473
+ content: Base64.strict_encode64(data),
474
+ content_id: nil # optional
475
+ )
476
+ attachment.to_h # => { filename: ..., content: ... }
477
+ ```
478
+
479
+ ---
480
+
481
+ ## Pagination
482
+
483
+ All list methods support cursor-based pagination:
484
+
485
+ ```ruby
486
+ # First page
487
+ result = team.domains.list(page_size: 10)
488
+ items = result['data']
489
+ cursor = result.dig('meta', 'cursor')
490
+
491
+ # Next page
492
+ if cursor
493
+ result = team.domains.list(page_size: 10, page_cursor: cursor)
494
+ end
495
+ ```
496
+
497
+ ---
498
+
499
+ ## Raw HTTP Access
500
+
501
+ For endpoints not yet wrapped in typed methods:
502
+
503
+ ```ruby
504
+ client = Lettermint::SendingAPI.new(api_token: 'xxx')
505
+
506
+ client.get('/some/endpoint', params: { key: 'value' })
507
+ client.post('/some/endpoint', data: { key: 'value' })
508
+ client.put('/some/endpoint', data: { key: 'value' })
509
+ client.delete('/some/endpoint')
510
+ ```
@@ -1,33 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lettermint
4
- class Client
5
- attr_reader :configuration
6
-
7
- def initialize(api_token:, base_url: nil, timeout: nil)
8
- validate_api_token!(api_token)
9
-
10
- @configuration = Configuration.new
11
- @configuration.base_url = base_url || Lettermint.configuration.base_url
12
- @configuration.timeout = timeout || Lettermint.configuration.timeout
13
-
14
- yield @configuration if block_given?
15
-
16
- @http_client = HttpClient.new(
17
- api_token: api_token,
18
- base_url: @configuration.base_url,
19
- timeout: @configuration.timeout
20
- )
21
- end
22
-
23
- def email
24
- EmailMessage.new(http_client: @http_client)
25
- end
26
-
27
- private
28
-
29
- def validate_api_token!(token)
30
- raise ArgumentError, 'API token cannot be empty' if token.nil? || token.to_s.strip.empty?
31
- end
32
- end
4
+ # Backward compatibility: Client is an alias for SendingAPI.
5
+ # Use Lettermint::SendingAPI explicitly for clarity.
6
+ Client = SendingAPI
33
7
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lettermint
4
- class EmailMessage
4
+ class EmailMessage # rubocop:disable Metrics/ClassLength
5
5
  def initialize(http_client:)
6
6
  @http_client = http_client
7
7
  reset
@@ -108,7 +108,8 @@ module Lettermint
108
108
 
109
109
  def validate_required_fields
110
110
  missing = %i[from subject].select { |f| blank?(f) }
111
- missing << :to if @payload[:to].nil? || @payload[:to].none? { |e| e.is_a?(String) && !e.strip.empty? }
111
+ missing << :to unless valid_recipients?
112
+ missing << :body unless body?
112
113
  return if missing.empty?
113
114
 
114
115
  raise ArgumentError, "Missing required field(s): #{missing.join(', ')}"
@@ -118,6 +119,14 @@ module Lettermint
118
119
  @payload[field].nil? || @payload[field].to_s.strip.empty?
119
120
  end
120
121
 
122
+ def valid_recipients?
123
+ @payload[:to]&.any? { |e| e.is_a?(String) && !e.strip.empty? }
124
+ end
125
+
126
+ def body?
127
+ !blank?(:html) || !blank?(:text)
128
+ end
129
+
121
130
  def reset
122
131
  @payload = {}
123
132
  @idempotency_key = nil
@@ -4,11 +4,15 @@ module Lettermint
4
4
  class Error < StandardError; end
5
5
 
6
6
  class HttpRequestError < Error
7
- attr_reader :status_code, :response_body
7
+ # code: the API error code string, when the response carried one.
8
+ # details: Hash, Array or nil; the shape depends on code.
9
+ attr_reader :status_code, :response_body, :code, :details
8
10
 
9
- def initialize(message:, status_code:, response_body: nil)
11
+ def initialize(message:, status_code:, response_body: nil, code: nil, details: nil)
10
12
  @status_code = status_code
11
13
  @response_body = response_body
14
+ @code = code
15
+ @details = details
12
16
  super(message)
13
17
  end
14
18
  end
@@ -16,20 +20,26 @@ module Lettermint
16
20
  class ValidationError < HttpRequestError
17
21
  attr_reader :error_type
18
22
 
19
- def initialize(message:, error_type:, response_body: nil)
23
+ def initialize(message:, error_type:, response_body: nil, code: nil, details: nil)
20
24
  @error_type = error_type
21
- super(message: message, status_code: 422, response_body: response_body)
25
+ super(message: message, status_code: 422, response_body: response_body, code: code, details: details)
22
26
  end
23
27
  end
24
28
 
25
29
  class ClientError < HttpRequestError
26
- def initialize(message:, response_body: nil)
27
- super(message: message, status_code: 400, response_body: response_body)
30
+ def initialize(message:, response_body: nil, code: nil, details: nil)
31
+ super(message: message, status_code: 400, response_body: response_body, code: code, details: details)
32
+ end
33
+ end
34
+
35
+ class NotFoundError < HttpRequestError
36
+ def initialize(message:, response_body: nil, code: nil, details: nil)
37
+ super(message: message, status_code: 404, response_body: response_body, code: code, details: details)
28
38
  end
29
39
  end
30
40
 
31
41
  class AuthenticationError < HttpRequestError
32
- def initialize(message:, status_code:, response_body: nil)
42
+ def initialize(message:, status_code:, response_body: nil, code: nil, details: nil)
33
43
  super
34
44
  end
35
45
  end
@@ -37,14 +47,70 @@ module Lettermint
37
47
  class RateLimitError < HttpRequestError
38
48
  attr_reader :retry_after
39
49
 
40
- def initialize(message:, retry_after: nil, response_body: nil)
50
+ def initialize(message:, retry_after: nil, response_body: nil, code: nil, details: nil)
41
51
  @retry_after = retry_after
42
- super(message: message, status_code: 429, response_body: response_body)
52
+ super(message: message, status_code: 429, response_body: response_body, code: code, details: details)
53
+ end
54
+ end
55
+
56
+ # Extracts [message, code, details] from an error response body, mirroring the
57
+ # official Node and Python SDKs. Only a Hash body is inspected; any other body
58
+ # yields "HTTP <status>" with no code or details.
59
+ # @api private
60
+ module ErrorBody
61
+ module_function
62
+
63
+ def parse(status, body)
64
+ message, code, details = body.is_a?(Hash) ? fields_from(body) : []
65
+ message = "HTTP #{status}" if blank?(message)
66
+ [message, code, details]
67
+ end
68
+
69
+ def fields_from(body)
70
+ error = body['error']
71
+ message, code, details = fields_from_error(error)
72
+ message = body['message'] if blank?(message) && body['message'].is_a?(String)
73
+ [message, code, details]
74
+ end
75
+
76
+ # A String `error` is a code, never a message.
77
+ def fields_from_error(error)
78
+ case error
79
+ when Hash then [string_or_nil(error['message']), string_or_nil(error['code']), error['details']]
80
+ when String then [nil, error, nil]
81
+ else []
82
+ end
83
+ end
84
+
85
+ def string_or_nil(value)
86
+ value.is_a?(String) ? value : nil
87
+ end
88
+
89
+ def blank?(value)
90
+ value.nil? || value.strip.empty?
43
91
  end
44
92
  end
45
93
 
46
94
  class TimeoutError < Error; end
47
95
 
96
+ class ConnectionError < Error
97
+ attr_reader :original_exception
98
+
99
+ def initialize(message:, original_exception: nil)
100
+ @original_exception = original_exception
101
+ super(message)
102
+ end
103
+ end
104
+
105
+ class ResponseParsingError < Error
106
+ attr_reader :original_exception
107
+
108
+ def initialize(message:, original_exception: nil)
109
+ @original_exception = original_exception
110
+ super(message)
111
+ end
112
+ end
113
+
48
114
  class WebhookVerificationError < Error; end
49
115
 
50
116
  class InvalidSignatureError < WebhookVerificationError; end