paubox 1.3.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 03c144256b933b90453a794a96dd10a17979ebd6672c4718c09105a440c5ec9e
4
- data.tar.gz: 3b85993acff1094aea7ff20c415857e38e51ebde9329584bc5ab48ccb31dffb5
3
+ metadata.gz: aa4eb8413cee31b007b8dd6adb1dc4342891fab4f46869f68adabb7d3ac4a7a2
4
+ data.tar.gz: e310784bb8c7963d5e791662ecccebc50253f33c341864bd3260409e5594293a
5
5
  SHA512:
6
- metadata.gz: c9453c28e1e1357ba079e274b46a0b7ec12414272e809658bf58e05abfaeedbcb68409f580089a72b95c24b9570214a1cabf2ee25fd4c740d0a30ba2424c66e5
7
- data.tar.gz: 9ffef90ede4234b5b867670e3ba709512130b5af1aabb498567c1b333ada21d127cb4f1ce6b147e773443064af6e14621cb8ad4405a716c504df63f9e8eec2f1
6
+ metadata.gz: baff84836c819530292367f0ab8b2990afb4d5e46f9a8991fb72a2b93263f3566c4e37b8b5c842e965c6815445c23e895e86bf0b23357ac698796a74e27d2968
7
+ data.tar.gz: 9d2ba57f05e491258e4fba02d4d3c6cbc1690789b1a8eaf9ba4504b8c771d645a39f9ff61588c3fe939891ca9935b5fe825920e28119b74f1fffb07cac271308
@@ -58,4 +58,11 @@ jobs:
58
58
  puts Paubox::Client.new.send(:api_base_endpoint)
59
59
  f = Paubox::FormsClient
60
60
  puts "#{f::FORMS_PROTOCOL}#{f::FORMS_HOST}#{f::FORMS_BASE}"
61
+ # The webhooks base is probed with /endpoints appended. The gateway
62
+ # scopes its rule to that one resource so the producers event-ingest
63
+ # route stays off the public host, which leaves the bare base
64
+ # unrouted by design — probing it directly would fail this check for
65
+ # a URL the client never requests on its own.
66
+ w = Paubox::WebhooksClient
67
+ puts "#{w::WEBHOOKS_PROTOCOL}#{w::WEBHOOKS_HOST}#{w::WEBHOOKS_BASE}/endpoints"
61
68
  ' | .github/scripts/route-check.sh
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "1.3.0"
2
+ ".": "2.0.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [2.0.0](https://github.com/Paubox/paubox-ruby/compare/v1.3.1...v2.0.0) (2026-10-14)
6
+
7
+
8
+ ### ⚠ BREAKING CHANGES
9
+
10
+ * move webhook CRUD to the pb_webhooks service ([#36](https://github.com/Paubox/paubox-ruby/issues/36))
11
+
12
+ ### Features
13
+
14
+ * move webhook CRUD to the pb_webhooks service ([#36](https://github.com/Paubox/paubox-ruby/issues/36)) ([a27d3b6](https://github.com/Paubox/paubox-ruby/commit/a27d3b6c2643d21368235077e7a92cf28da3d7e4))
15
+
16
+ ## [1.3.1](https://github.com/Paubox/paubox-ruby/compare/v1.3.0...v1.3.1) (2026-10-02)
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * **receiving:** key attachment downloads on attachment_id and pass list filters ([#33](https://github.com/Paubox/paubox-ruby/issues/33)) ([875dea3](https://github.com/Paubox/paubox-ruby/commit/875dea3c1f897286061766aac6f73070288f4eab))
22
+
5
23
  ## [1.3.0](https://github.com/Paubox/paubox-ruby/compare/v1.2.0...v1.3.0) (2026-09-17)
6
24
 
7
25
 
data/README.md CHANGED
@@ -497,6 +497,117 @@ pdf = client.submission_pdf('550e8400-e29b-41d4-a716-446655440000',
497
497
  File.binwrite('submission.pdf', pdf)
498
498
  ```
499
499
 
500
+ <a name="#webhooks"></a>
501
+ ## Webhooks
502
+
503
+ A webhook endpoint is a URL you own that Paubox notifies when something happens.
504
+ `Paubox::WebhooksClient` manages those subscriptions: which URL to notify, and for which events.
505
+
506
+ Every method needs an API key scoped for the events you want, sent as an `Authorization: Bearer`
507
+ header — not the Email API's `Token token=`. Pass it to the constructor or configure it globally:
508
+
509
+ ```ruby
510
+ require 'Paubox'
511
+
512
+ client = Paubox::WebhooksClient.new(api_key: ENV['PAUBOX_WEBHOOKS_API_KEY'])
513
+
514
+ # or globally:
515
+ Paubox.configure { |config| config.webhooks_api_key = ENV['PAUBOX_WEBHOOKS_API_KEY'] }
516
+ client = Paubox::WebhooksClient.new
517
+ ```
518
+
519
+ Which events a key may subscribe to follows from its scopes. The SDK does not inspect them — the
520
+ service refuses an event the key isn't scoped for with 403, and an unrecognised event with 422.
521
+
522
+ **Available events**
523
+
524
+ | Event | Fires when |
525
+ | --- | --- |
526
+ | `forms.submission.created` | A form submission is received |
527
+ | `api_mail_log_delivered` | An Email API message is delivered |
528
+ | `api_mail_log_opened` | A delivered message is opened |
529
+ | `api_mail_log_temporary_failure` | Delivery fails temporarily |
530
+ | `api_mail_log_permanent_failure` | Delivery fails permanently |
531
+ | `email.inbound.received` | Inbound mail is received |
532
+
533
+ The four Email API events keep their legacy `api_mail_log_` names, so a receiver written
534
+ against the old Email API webhooks keeps working unchanged. Note these are the names on
535
+ the wire: `delivered` or `failed` on their own are rejected as unknown.
536
+
537
+ The SDK does not check these names. The catalog lives in the service, so an event added
538
+ later works here without upgrading the SDK.
539
+
540
+ ### Creating a webhook endpoint
541
+
542
+ ```ruby
543
+ endpoint = client.create_webhook_endpoint(
544
+ target_url: 'https://example.com/paubox-webhook',
545
+ events: ['forms.submission.created']
546
+ )
547
+
548
+ endpoint['id'] # UUID
549
+ endpoint['signing_secret'] # whsec_... — store this now
550
+ ```
551
+
552
+ `signing_secret` is returned **once**, here. It is absent from `get_webhook_endpoint` and
553
+ `list_webhook_endpoints`, and there is no way to read it back — recovering from a lost secret
554
+ means deleting the endpoint and creating a new one. Use it to verify that deliveries really came
555
+ from Paubox.
556
+
557
+ ### Listing webhook endpoints
558
+
559
+ ```ruby
560
+ result = client.list_webhook_endpoints
561
+
562
+ result['data'].each do |endpoint|
563
+ puts [endpoint['id'], endpoint['target_url'], endpoint['status']].join(' ')
564
+ end
565
+
566
+ result['page_info']['count'] # total matching, not the length of this page
567
+
568
+ # Paginated
569
+ client.list_webhook_endpoints(page: 2, items: 25)
570
+ ```
571
+
572
+ ### Getting, updating and deleting
573
+
574
+ ```ruby
575
+ endpoint = client.get_webhook_endpoint('2ec66c21-bf48-48eb-8d28-f80b2d6b77c7')
576
+
577
+ # Only the keyword arguments you pass are sent
578
+ endpoint = client.update_webhook_endpoint(id, status: 'disabled')
579
+
580
+ client.delete_webhook_endpoint(id) # => true
581
+ ```
582
+
583
+ Pausing deliveries without losing the subscription is what `disabled` is for; pass `active` to
584
+ resume. Passing `events:` replaces the list rather than adding to it. Deleting answers `204 No
585
+ Content`, stops every event on that endpoint, and takes the signing secret with it.
586
+
587
+ ### Error handling
588
+
589
+ A non-2xx response raises `Paubox::WebhooksClient::Error`, carrying the service's own message
590
+ rather than a bare status:
591
+
592
+ ```ruby
593
+ begin
594
+ client.create_webhook_endpoint(target_url: url, events: ['forms.submission.created'])
595
+ rescue Paubox::WebhooksClient::Error => e
596
+ e.status_code # 422
597
+ e.message # "target_url: an endpoint already exists for this URL"
598
+ e.response_body # raw response
599
+ e.url
600
+ end
601
+ ```
602
+
603
+ The error deliberately carries only those three fields, so an error reporter capturing locals from
604
+ the raising frame never picks up the `Authorization` header or the API key.
605
+
606
+ Two cases worth handling explicitly: subscribing a `target_url` that already has an endpoint comes
607
+ back as 422, and an id that doesn't belong to your account is a 404 rather than a 403. An id that
608
+ isn't a UUID raises `ArgumentError` locally, before any request is made, as does calling any
609
+ method without an API key.
610
+
500
611
  <a name="#contributing"></a>
501
612
  ## Contributing
502
613
 
data/api.md CHANGED
@@ -281,3 +281,217 @@ client.submit_form('550e8400-e29b-41d4-a716-446655440000',
281
281
  { name: 'consent.pdf', content: Base64.strict_encode64(File.binread('consent.pdf')) }
282
282
  ])
283
283
  ```
284
+
285
+ ---
286
+
287
+ ## Webhooks API
288
+
289
+ A webhook endpoint is a URL you own that Paubox notifies when something happens. These endpoints
290
+ manage those subscriptions.
291
+
292
+ ### Authentication
293
+
294
+ An API key scoped for the events you want, sent as a Bearer header:
295
+
296
+ ```
297
+ Authorization: Bearer <SCOPED_API_KEY>
298
+ ```
299
+
300
+ Not the Email API's `Token token=`. Which events a key may subscribe to follows from its scopes;
301
+ the SDK does not inspect them, so an event the key is not scoped for returns 403 and an
302
+ unrecognised one 422.
303
+
304
+ **Available events**
305
+
306
+ | Event | Fires when |
307
+ | --- | --- |
308
+ | `forms.submission.created` | A form submission is received |
309
+ | `api_mail_log_delivered` | An Email API message is delivered |
310
+ | `api_mail_log_opened` | A delivered message is opened |
311
+ | `api_mail_log_temporary_failure` | Delivery fails temporarily |
312
+ | `api_mail_log_permanent_failure` | Delivery fails permanently |
313
+ | `email.inbound.received` | Inbound mail is received |
314
+
315
+ The four Email API events keep their legacy `api_mail_log_` names, so a receiver written
316
+ against the old Email API webhooks keeps working unchanged. Note these are the names on
317
+ the wire: `delivered` or `failed` on their own are rejected as unknown.
318
+
319
+ The SDK does not check these names. The catalog lives in the service, so an event added
320
+ later works here without upgrading the SDK.
321
+
322
+ ### Base URL
323
+
324
+ ```
325
+ https://api.paubox.com/v1/webhooks
326
+ ```
327
+
328
+ Every request carries the `/endpoints` resource. The bare base is unrouted on the public gateway
329
+ by design: the rule is scoped to `/endpoints` so the producers' event-ingest route stays private.
330
+
331
+ ---
332
+
333
+ ### List Webhook Endpoints
334
+
335
+ **GET** `/endpoints`
336
+
337
+ Returns the endpoints this key can act on. Endpoints carrying an event the key is not scoped for
338
+ are filtered out by the service.
339
+
340
+ **Query parameters:**
341
+ - `page` (integer, optional) — 1-based page number
342
+ - `items` (integer, optional) — page size
343
+
344
+ **Response (200):**
345
+ ```json
346
+ {
347
+ "data": [
348
+ {
349
+ "id": "2ec66c21-bf48-48eb-8d28-f80b2d6b77c7",
350
+ "target_url": "https://example.com/paubox-webhook",
351
+ "status": "active",
352
+ "events": ["forms.submission.created"],
353
+ "created_at": "2026-10-09T01:43:41.639968+00:00",
354
+ "updated_at": "2026-10-09T01:43:41.639968+00:00"
355
+ }
356
+ ],
357
+ "page_info": { "count": 1, "items": 50 }
358
+ }
359
+ ```
360
+
361
+ `page_info.count` is the total number of matching endpoints, not the length of `data`.
362
+
363
+ **Ruby usage:**
364
+ ```ruby
365
+ client = Paubox::WebhooksClient.new(api_key: ENV['PAUBOX_WEBHOOKS_API_KEY'])
366
+
367
+ result = client.list_webhook_endpoints
368
+ result = client.list_webhook_endpoints(page: 2, items: 25)
369
+ ```
370
+
371
+ Returns the full document — `data` plus `page_info` — since a caller paginating needs both.
372
+
373
+ ---
374
+
375
+ ### Create Webhook Endpoint
376
+
377
+ **POST** `/endpoints`
378
+
379
+ **Request body:**
380
+ - `target_url` (string, required) — an https URL resolving to a publicly routable address
381
+ - `events` (array of strings, required, non-empty)
382
+
383
+ **Response (201):**
384
+ ```json
385
+ {
386
+ "data": {
387
+ "id": "2ec66c21-bf48-48eb-8d28-f80b2d6b77c7",
388
+ "target_url": "https://example.com/paubox-webhook",
389
+ "status": "active",
390
+ "events": ["forms.submission.created"],
391
+ "created_at": "2026-10-09T01:43:41.639968+00:00",
392
+ "updated_at": "2026-10-09T01:43:41.639968+00:00",
393
+ "signing_secret": "whsec_..."
394
+ },
395
+ "message": "Store this signing_secret now — it is not shown again."
396
+ }
397
+ ```
398
+
399
+ `signing_secret` is returned only here. It is absent from get and list, and cannot be read back:
400
+ recovering from a lost secret means replacing the endpoint.
401
+
402
+ **Errors:**
403
+ - `422` — missing or malformed `target_url`, an empty `events`, an unrecognised event, or a
404
+ `target_url` that already has an endpoint
405
+ - `403` — an event this key is not scoped for
406
+
407
+ **Ruby usage:**
408
+ ```ruby
409
+ endpoint = client.create_webhook_endpoint(
410
+ target_url: 'https://example.com/paubox-webhook',
411
+ events: ['forms.submission.created']
412
+ )
413
+ endpoint['signing_secret']
414
+ ```
415
+
416
+ Returns the endpoint itself, unwrapped from the `data` envelope.
417
+
418
+ ---
419
+
420
+ ### Get Webhook Endpoint
421
+
422
+ **GET** `/endpoints/<id>`
423
+
424
+ **Path parameter:**
425
+ - `id` (UUID, required)
426
+
427
+ **Response (200):** the endpoint, wrapped in `data`. The signing secret is not included.
428
+
429
+ **Errors:**
430
+ - `404` — no such endpoint, or it belongs to another account
431
+
432
+ **Ruby usage:**
433
+ ```ruby
434
+ endpoint = client.get_webhook_endpoint('2ec66c21-bf48-48eb-8d28-f80b2d6b77c7')
435
+ ```
436
+
437
+ An id that is not UUID-shaped raises `ArgumentError` before any request is sent.
438
+
439
+ ---
440
+
441
+ ### Update Webhook Endpoint
442
+
443
+ **PATCH** `/endpoints/<id>`
444
+
445
+ A partial update: only the fields sent are changed.
446
+
447
+ **Request body (all optional, at least one required):**
448
+ - `target_url` (string)
449
+ - `status` (string) — `"active"` or `"disabled"`
450
+ - `events` (array of strings) — replaces the list rather than adding to it
451
+
452
+ **Response (200):** the updated endpoint, wrapped in `data`.
453
+
454
+ **Ruby usage:**
455
+ ```ruby
456
+ client.update_webhook_endpoint(id, status: 'disabled')
457
+ client.update_webhook_endpoint(id, target_url: 'https://example.com/new-hook')
458
+ ```
459
+
460
+ `disabled` pauses deliveries without losing the subscription; `active` resumes them.
461
+
462
+ ---
463
+
464
+ ### Delete Webhook Endpoint
465
+
466
+ **DELETE** `/endpoints/<id>`
467
+
468
+ **Response:**
469
+ - `204` — deleted, no body
470
+ - `404` — no such endpoint, or it belongs to another account
471
+
472
+ Deleting stops every event on that endpoint and discards its signing secret.
473
+
474
+ **Ruby usage:**
475
+ ```ruby
476
+ client.delete_webhook_endpoint(id) # => true
477
+ ```
478
+
479
+ ---
480
+
481
+ ### Webhook Errors
482
+
483
+ Every non-2xx response raises `Paubox::WebhooksClient::Error`:
484
+
485
+ ```ruby
486
+ begin
487
+ client.get_webhook_endpoint(id)
488
+ rescue Paubox::WebhooksClient::Error => e
489
+ e.status_code # Integer
490
+ e.message # the service's own message, e.g. "webhook endpoint not found"
491
+ e.url
492
+ e.response_body # raw response
493
+ end
494
+ ```
495
+
496
+ The error carries only those fields, so an error reporter capturing locals from the raising frame
497
+ never sees the `Authorization` header or the API key.
data/lib/paubox/client.rb CHANGED
@@ -142,8 +142,9 @@ module Paubox
142
142
  JSON.parse(response.body)
143
143
  end
144
144
 
145
- def list_received_emails(limit: nil, after: nil, before: nil)
146
- params = { limit: limit, after: after, before: before }.compact
145
+ def list_received_emails(limit: nil, after: nil, before: nil, search: nil, sort: nil, ascending: nil)
146
+ params = { limit: limit, after: after, before: before,
147
+ search: search, sort: sort, ascending: ascending }.compact
147
148
  query = params.map { |k, v| "#{k}=#{CGI.escape(v.to_s)}" }.join('&')
148
149
  url = request_endpoint('receiving')
149
150
  url = "#{url}?#{query}" unless query.empty?
@@ -157,41 +158,9 @@ module Paubox
157
158
  JSON.parse(response.body)
158
159
  end
159
160
 
160
- def get_received_email_attachment(email_id, blob_id)
161
- url = request_endpoint("receiving/#{email_id}/attachments/#{blob_id}")
162
- RestClient.get(url, auth_header)
163
- end
164
-
165
- def list_webhook_endpoints
166
- url = request_endpoint('webhook_endpoints')
167
- response = RestClient.get(url, auth_header)
168
- JSON.parse(response.body)
169
- end
170
-
171
- def create_webhook_endpoint(target_url:, events:, signing_key: nil, api_key: nil, active: true)
172
- url = request_endpoint('webhook_endpoints')
173
- payload = { target_url: target_url, events: events, active: active,
174
- signing_key: signing_key, api_key: api_key }.compact.to_json
175
- response = RestClient.post(url, payload, auth_header)
176
- JSON.parse(response.body)
177
- end
178
-
179
- def get_webhook_endpoint(id)
180
- url = request_endpoint("webhook_endpoints/#{id}")
181
- response = RestClient.get(url, auth_header)
182
- JSON.parse(response.body)
183
- end
184
-
185
- def update_webhook_endpoint(id, **params)
186
- url = request_endpoint("webhook_endpoints/#{id}")
187
- response = RestClient.patch(url, params.to_json, auth_header)
188
- JSON.parse(response.body)
189
- end
190
-
191
- def delete_webhook_endpoint(id)
192
- url = request_endpoint("webhook_endpoints/#{id}")
193
- response = RestClient.delete(url, auth_header)
194
- JSON.parse(response.body)
161
+ def get_received_email_attachment(email_id, attachment_id)
162
+ url = request_endpoint("receiving/#{email_id}/attachments/#{attachment_id}")
163
+ RestClient.get(url, auth_header.merge(accept: '*/*'))
195
164
  end
196
165
 
197
166
  def send_request(method: :get, payload: {}, path: '')
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Paubox
4
- VERSION = '1.3.0'
4
+ VERSION = '2.0.0'
5
5
  end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+ require 'json'
5
+ require 'rest-client'
6
+
7
+ module Paubox
8
+ # Client for the Paubox webhooks service.
9
+ #
10
+ # Authenticates with a scoped API key sent as "Authorization: Bearer <key>",
11
+ # the same scheme as FormsClient and unlike the Email API's "Token token=".
12
+ # Which events a key may subscribe to is decided by its scopes; asking for
13
+ # one it is not scoped for is refused with 403.
14
+ class WebhooksClient
15
+ WEBHOOKS_HOST = 'api.paubox.com'
16
+ WEBHOOKS_PROTOCOL = 'https://'
17
+ # The public gateway exposes only /v1/webhooks/endpoints and rewrites it
18
+ # onto the service's own /v1/endpoints, so the producers' event-ingest
19
+ # route is unreachable from here. The bare base is deliberately unrouted;
20
+ # every request below carries the /endpoints resource.
21
+ WEBHOOKS_BASE = '/v1/webhooks'
22
+
23
+ TIMEOUT_SECONDS = 30
24
+
25
+ UUID_RE = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
26
+
27
+ # Raised on any non-2xx response. Carries only status_code / url /
28
+ # response_body so error reporters that capture raising-frame locals never
29
+ # see the Authorization header or api_key.
30
+ class Error < StandardError
31
+ attr_reader :status_code, :url, :response_body
32
+
33
+ def initialize(message, status_code: nil, url: nil, response_body: nil)
34
+ super(message)
35
+ @status_code = status_code
36
+ @url = url
37
+ @response_body = response_body
38
+ end
39
+ end
40
+
41
+ def initialize(args = {})
42
+ @host = args[:host] || WEBHOOKS_HOST
43
+ @protocol = args[:protocol] || WEBHOOKS_PROTOCOL
44
+ @base = args[:base] || WEBHOOKS_BASE
45
+ @api_key = args[:api_key] ||
46
+ (Paubox.configuration && Paubox.configuration.webhooks_api_key)
47
+ end
48
+
49
+ # Lists the endpoints this key can act on. Endpoints carrying an event the
50
+ # key is not scoped for are filtered out by the service, so this is what
51
+ # the key may manage rather than everything on the account.
52
+ #
53
+ # Returns the full document: 'data' plus 'page_info'. Note
54
+ # page_info['count'] is the total number of matching endpoints, not the
55
+ # length of this page.
56
+ def list_webhook_endpoints(page: nil, items: nil)
57
+ query = { page: page, items: items }.compact
58
+ url = endpoint_url
59
+ url = "#{url}?#{URI.encode_www_form(query)}" unless query.empty?
60
+
61
+ parse(request(method: :get, url: url, headers: auth_headers))
62
+ end
63
+
64
+ # Subscribes a URL to one or more events.
65
+ #
66
+ # The returned hash carries 'signing_secret', which the service sends only
67
+ # here — it is absent from get_webhook_endpoint and from the list. Store it
68
+ # on receipt; a lost secret means replacing the endpoint.
69
+ #
70
+ # Event names are not validated here on purpose: the catalog belongs to the
71
+ # service and grows without a gem release. An unknown event comes back as
72
+ # 422, one this key is not scoped for as 403.
73
+ def create_webhook_endpoint(target_url:, events:)
74
+ raise ArgumentError, 'target_url is required' if target_url.nil? || target_url.to_s.empty?
75
+ raise ArgumentError, 'events must not be empty' if events.nil? || Array(events).empty?
76
+
77
+ payload = { target_url: target_url, events: Array(events) }.to_json
78
+ response = request(
79
+ method: :post, url: endpoint_url,
80
+ headers: auth_headers(content_type: :json), payload: payload
81
+ )
82
+ unwrap_data(parse(response))
83
+ end
84
+
85
+ def get_webhook_endpoint(id)
86
+ assert_uuid!(id, 'id')
87
+ unwrap_data(parse(request(method: :get, url: endpoint_url(id), headers: auth_headers)))
88
+ end
89
+
90
+ # Partial update: only the arguments given are sent, so changing
91
+ # target_url leaves events and status untouched. status accepts
92
+ # 'active' or 'disabled'.
93
+ def update_webhook_endpoint(id, target_url: nil, status: nil, events: nil)
94
+ assert_uuid!(id, 'id')
95
+
96
+ body = {}
97
+ body[:target_url] = target_url unless target_url.nil?
98
+ body[:status] = status unless status.nil?
99
+ body[:events] = Array(events) unless events.nil?
100
+ raise ArgumentError, 'provide at least one of target_url:, status:, events:' if body.empty?
101
+
102
+ response = request(
103
+ method: :patch, url: endpoint_url(id),
104
+ headers: auth_headers(content_type: :json), payload: body.to_json
105
+ )
106
+ unwrap_data(parse(response))
107
+ end
108
+
109
+ # Deletes an endpoint, stopping every event on it. The service answers 204
110
+ # with no body, so this returns true rather than a document.
111
+ def delete_webhook_endpoint(id)
112
+ assert_uuid!(id, 'id')
113
+ request(method: :delete, url: endpoint_url(id), headers: auth_headers)
114
+ true
115
+ end
116
+
117
+ private
118
+
119
+ def endpoint_url(id = nil)
120
+ base = "#{@protocol}#{@host}#{@base}/endpoints"
121
+ id.nil? ? base : "#{base}/#{encode_segment(id)}"
122
+ end
123
+
124
+ def auth_headers(extra = {})
125
+ if @api_key.nil? || @api_key.to_s.empty?
126
+ raise ArgumentError, 'api_key is required for this endpoint. Pass a scoped ' \
127
+ 'API key: Paubox::WebhooksClient.new(api_key: ...), or set ' \
128
+ 'Paubox.configuration.webhooks_api_key'
129
+ end
130
+
131
+ { authorization: "Bearer #{@api_key}", accept: :json }.merge(extra)
132
+ end
133
+
134
+ def assert_uuid!(value, name)
135
+ return if value.is_a?(String) && value.match?(UUID_RE)
136
+
137
+ raise ArgumentError, "#{name} must be a UUID string (received #{value.inspect})"
138
+ end
139
+
140
+ def encode_segment(value)
141
+ CGI.escape(value.to_s)
142
+ end
143
+
144
+ def parse(response)
145
+ body = response.body.to_s
146
+ body.empty? ? nil : JSON.parse(body)
147
+ end
148
+
149
+ # Single resources arrive wrapped as {"data" => {...}}. Unwrapped so
150
+ # callers get the endpoint rather than an envelope carrying nothing else.
151
+ #
152
+ # list_webhook_endpoints deliberately skips this: its envelope also carries
153
+ # 'page_info', which a caller paginating needs.
154
+ def unwrap_data(parsed)
155
+ parsed.is_a?(Hash) && parsed.key?('data') ? parsed['data'] : parsed
156
+ end
157
+
158
+ # Funnels every request through RestClient::Request.execute so we can
159
+ # always set an explicit timeout (rest-client's default is nil) and
160
+ # translate ExceptionWithResponse into Paubox::WebhooksClient::Error,
161
+ # which carries only status_code / url / response_body — never the
162
+ # Authorization header or api_key. Keeps the Bearer token out of any error
163
+ # reporter that captures raising-frame locals.
164
+ #
165
+ # Unlike FormsClient the service's own message is promoted onto the
166
+ # exception when present, so a caller sees "target_url: must be an https
167
+ # URL" rather than only a status code.
168
+ def request(method:, url:, headers: {}, payload: nil)
169
+ RestClient::Request.execute(
170
+ method: method, url: url, payload: payload,
171
+ headers: headers, timeout: TIMEOUT_SECONDS
172
+ )
173
+ rescue RestClient::ExceptionWithResponse => e
174
+ response = e.response
175
+ code = response && response.code
176
+ raise Error.new(
177
+ service_message(response) ||
178
+ "Paubox webhooks API request failed: #{e.class.name.split('::').last} " \
179
+ "(status #{code || 'unknown'})",
180
+ status_code: code,
181
+ url: url,
182
+ response_body: response && response.body
183
+ )
184
+ end
185
+
186
+ # The service answers failures as {"message": "..."}; anything else falls
187
+ # back to the generic message above.
188
+ def service_message(response)
189
+ return nil unless response
190
+
191
+ parsed = JSON.parse(response.body.to_s)
192
+ parsed.is_a?(Hash) && parsed['message'].is_a?(String) ? parsed['message'] : nil
193
+ rescue JSON::ParserError
194
+ nil
195
+ end
196
+ end
197
+ end
data/lib/paubox.rb CHANGED
@@ -11,6 +11,7 @@ require 'paubox/email_disposition'
11
11
  require 'paubox/form'
12
12
  require 'paubox/form_submission'
13
13
  require 'paubox/forms_client'
14
+ require 'paubox/webhooks_client'
14
15
  require 'mail/paubox'
15
16
 
16
17
  module Paubox
@@ -24,7 +25,7 @@ module Paubox
24
25
  end
25
26
 
26
27
  class Configuration
27
- attr_accessor :api_key, :forms_api_key
28
+ attr_accessor :api_key, :forms_api_key, :webhooks_api_key
28
29
 
29
30
  # Deprecated: api_user is no longer used by the Email API client.
30
31
  # Kept only for backward compatibility; it has no effect on requests.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: paubox
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paubox
@@ -178,6 +178,7 @@ files:
178
178
  - lib/paubox/message.rb
179
179
  - lib/paubox/templated_message.rb
180
180
  - lib/paubox/version.rb
181
+ - lib/paubox/webhooks_client.rb
181
182
  - lib/paubox_ruby.rb
182
183
  - paubox_ruby.gemspec
183
184
  - release-please-config.json