broadcast-ruby 0.1.4 → 0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3bd138dfa8311b17c14ef537073cdf62406503d605b83f3d49b96659e28cda9
4
- data.tar.gz: 6d7f2dccd5e01060b566bcd35a65a74349a078b9ed68348a4d510a98a8faf219
3
+ metadata.gz: 361d356c1fde238ba1734363607796b005a839f72b70885e52c2ed0eaa2b4209
4
+ data.tar.gz: 9a1d12cc2b3680689951e325be2100eb64ed9f2ca021c3d69421f336099dec6d
5
5
  SHA512:
6
- metadata.gz: '0164853b6f09a1f00227adf8201e0cd53a6ff84ece799471f839994e8b2dae6cc65486e08bc3877b9640056eabf9bba8b9659fc2c8a2a4172117b5b1fbc482cf'
7
- data.tar.gz: b31dfa8c5e5f6ebcf6cb3861e92e82fe3b13f66e60d163da7dfbdc5d80abbd669f3a066259aa6316aaa3a1a5017ade083f12a67d82acb84299e1cb271f87b680
6
+ metadata.gz: 128a65f8767c17b979e98b8516f7c31a99703449487f6fd7cefe93c9940a1c3cb5c4ae21a5144a7236769ec5f41072626694e169ec2eea041f3c1327265c834d
7
+ data.tar.gz: e54a4fa7d5f1e96f9fbc26a8932c05067a6eda7575ee21f3762cc261b0973d42219536ed1650872746ce2854fa449de9ebc3c0192445ab705ceec0933e36df1d
data/.rubocop.yml CHANGED
@@ -24,6 +24,8 @@ Metrics/AbcSize:
24
24
 
25
25
  Metrics/ClassLength:
26
26
  Max: 200
27
+ Exclude:
28
+ - test/**/*
27
29
 
28
30
  Metrics/CyclomaticComplexity:
29
31
  Max: 15
data/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.2.0] - 2026-04-28
6
+
7
+ ### Added
8
+ - `client.opt_in_forms` resource: list, get, create, update, delete, analytics, create_variant, duplicate
9
+ - `client.email_servers` resource: list, get, create, update, delete, test_connection, copy_to_channel
10
+ - `client.transactionals` resource with full create surface (`template_id`, `preheader`, `include_unsubscribe_link`, `subscriber:` attrs); `client.send_email` and `client.get_email` are now thin shims that delegate
11
+ - Double opt-in support: pass `double_opt_in: true` (or a hash with `reply_to:` / `confirmation_template_id:` / `include_unsubscribe_link:`) to `transactionals.create` and `subscribers.create`. Optional top-level `confirmation_template_id:` is also accepted
12
+ - `Configuration#broadcast_channel_id` plus `client.with_channel(id) { ... }` block API for admin/system tokens — auto-includes the channel on every request inside the block (or globally when set on config), without overriding callers that pass it explicitly
13
+ - `Broadcast::AuthorizationError` for 403 responses (previously fell through to a generic `APIError`)
14
+ - Credential redaction scrubber on `email_servers.update`: values matching the API's bullet-redaction shape on known credential fields are stripped from the payload (with a logger warning) so callers can't accidentally round-trip a redacted response back into the model
15
+
16
+ ### Notes
17
+ - `opt_in_forms.list` returns up to 250 results per page with `pagination` metadata; only main forms are returned (variants are excluded)
18
+ - `opt_in_forms` `index`/`show` JSON shape (rendered via JBuilder views) differs slightly from `create`/`update` (rendered via the controller's inline serializer)
19
+ - `email_servers.copy_to_channel` requires an admin token and is account-scoped in SaaS mode
20
+
21
+ ## [0.1.4] - 2026-03-18
22
+
23
+ ### Fixed
24
+ - Register delivery method at class load time instead of in an initializer. ActionMailer's railtie applies config settings (including `broadcast_settings`) inside `on_load(:action_mailer)`, which ran before our initializer-based registration. This caused `undefined method broadcast_settings=` on boot in Rails 8.1.
25
+
26
+ ## [0.1.3] - 2026-03-18
27
+
28
+ ### Fixed
29
+ - Attempted fix for Railtie timing: use `before: :load_config_initializers`. Did not fully resolve the issue — superseded by 0.1.4.
30
+
5
31
  ## [0.1.2] - 2026-03-18
6
32
 
7
33
  ### Added
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- broadcast-ruby (0.1.4)
4
+ broadcast-ruby (0.2.0)
5
5
  base64
6
6
 
7
7
  GEM
@@ -176,7 +176,7 @@ CHECKSUMS
176
176
  ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
177
177
  base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
178
178
  bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7
179
- broadcast-ruby (0.1.4)
179
+ broadcast-ruby (0.2.0)
180
180
  builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f
181
181
  concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab
182
182
  connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
data/README.md CHANGED
@@ -59,6 +59,7 @@ client.send_email(
59
59
  | `retry_delay` | `1` | Base delay between retries in seconds (multiplied by attempt number) |
60
60
  | `debug` | `false` | Log request/response details |
61
61
  | `logger` | `nil` | Logger instance for debug output (e.g. `Rails.logger`) |
62
+ | `broadcast_channel_id` | `nil` | Auto-included on every request when set. Required when using an admin/system token (regular tokens are channel-scoped already). Can be overridden per-call or via `client.with_channel(id) { ... }` |
62
63
 
63
64
  All methods return parsed JSON as Ruby Hashes with string keys.
64
65
 
@@ -182,6 +183,75 @@ email['queue_at'] # => '2026-03-17T07:59:58Z'
182
183
  | `body` | yes | Email content (HTML or plain text) |
183
184
  | `reply_to` | no | Reply-to address |
184
185
 
186
+ `send_email` is a thin convenience wrapper. For template-based sends, double opt-in, preheaders, and other advanced options, use `client.transactionals.create`:
187
+
188
+ ```ruby
189
+ # Send via a saved Template (resolves subject/body/preheader server-side)
190
+ client.transactionals.create(
191
+ to: 'user@example.com',
192
+ template_id: 42,
193
+ reply_to: 'support@yourapp.com',
194
+ include_unsubscribe_link: true
195
+ )
196
+
197
+ # Override individual fields while still using a template
198
+ client.transactionals.create(
199
+ to: 'user@example.com',
200
+ template_id: 42,
201
+ subject: 'Custom subject for this send'
202
+ )
203
+
204
+ # Set first/last name on a brand-new subscriber created by this send
205
+ client.transactionals.create(
206
+ to: 'new@example.com',
207
+ subject: 'Welcome!',
208
+ body: '<p>Hi {{first_name}}</p>',
209
+ subscriber: { first_name: 'Jane', last_name: 'Doe' }
210
+ )
211
+
212
+ # Get delivery status (alias of client.get_email)
213
+ client.transactionals.get_transactional(42)
214
+ ```
215
+
216
+ ### Double Opt-In
217
+
218
+ Pass `double_opt_in: true` to require email confirmation before delivery. The recipient receives a confirmation email; the actual transactional email is held until they confirm. If the recipient is already a confirmed subscriber, `double_opt_in` is ignored and the email sends normally.
219
+
220
+ ```ruby
221
+ # Boolean form: uses the channel's default confirmation template
222
+ result = client.transactionals.create(
223
+ to: 'new@example.com',
224
+ subject: 'Welcome!',
225
+ body: '<p>Confirmation email coming first...</p>',
226
+ double_opt_in: true
227
+ )
228
+ result['confirmation_status'] # => 'pending'
229
+ result['confirmation_url'] # => 'https://...'
230
+
231
+ # Hash form: customize the confirmation flow
232
+ client.transactionals.create(
233
+ to: 'new@example.com',
234
+ subject: 'Welcome!',
235
+ body: '<p>...</p>',
236
+ double_opt_in: {
237
+ reply_to: 'support@yourapp.com',
238
+ confirmation_template_id: 7,
239
+ include_unsubscribe_link: true
240
+ }
241
+ )
242
+
243
+ # Equivalent shortcut for confirmation_template_id at the top level
244
+ client.transactionals.create(
245
+ to: 'new@example.com',
246
+ subject: 'Welcome!',
247
+ body: '<p>...</p>',
248
+ double_opt_in: true,
249
+ confirmation_template_id: 7
250
+ )
251
+ ```
252
+
253
+ A transactional email server must be configured for the channel and a confirmation template (or default) must exist, otherwise the request returns 422.
254
+
185
255
  ---
186
256
 
187
257
  ## Subscribers
@@ -263,6 +333,42 @@ client.subscribers.resubscribe('jane@example.com')
263
333
  client.subscribers.redact('jane@example.com')
264
334
  ```
265
335
 
336
+ ### Double Opt-In
337
+
338
+ Pass `double_opt_in: true` (or a hash) to create the subscriber in unconfirmed state and queue a confirmation email. If the subscriber already exists and is confirmed, `double_opt_in` is ignored and the existing record is returned.
339
+
340
+ ```ruby
341
+ # Boolean form (uses the channel's default confirmation template)
342
+ result = client.subscribers.create(
343
+ email: 'new@example.com',
344
+ first_name: 'Jane',
345
+ double_opt_in: true
346
+ )
347
+ result['confirmation_status'] # => 'pending'
348
+ result['confirmation_url'] # => '...'
349
+
350
+ # Hash form -- customize reply-to, template, and unsubscribe link
351
+ client.subscribers.create(
352
+ email: 'new@example.com',
353
+ double_opt_in: {
354
+ reply_to: 'support@yourapp.com',
355
+ confirmation_template_id: 7,
356
+ include_unsubscribe_link: true
357
+ }
358
+ )
359
+
360
+ # `confirmation_template_id` is also accepted at the top level
361
+ client.subscribers.create(
362
+ email: 'new@example.com',
363
+ double_opt_in: true,
364
+ confirmation_template_id: 7
365
+ )
366
+ ```
367
+
368
+ `double_opt_in` and `confirmation_template_id` are top-level options -- they do **not** go inside the `subscriber:` envelope on the wire (the gem extracts them automatically).
369
+
370
+ The channel must have an active transactional email server and a confirmation template (default or `confirmation_template_id`), otherwise the request returns 422.
371
+
266
372
  ---
267
373
 
268
374
  ## Sequences
@@ -543,6 +649,201 @@ client.templates.delete(1)
543
649
 
544
650
  ---
545
651
 
652
+ ## Opt-In Forms
653
+
654
+ Embeddable subscription forms with theming, A/B variants, and analytics.
655
+
656
+ **Required permissions:** `opt_in_forms_read`, `opt_in_forms_write`
657
+
658
+ ```ruby
659
+ # List forms (paginated, up to 250 per page; only main forms -- variants are excluded)
660
+ result = client.opt_in_forms.list
661
+ result['opt_in_forms'] # => [{'id' => 1, 'label' => 'Newsletter', ...}, ...]
662
+ result['pagination']['current'] # => 1
663
+ result['pagination']['total'] # => 12
664
+
665
+ # Filter
666
+ client.opt_in_forms.list(filter: 'newsletter', widget_type: 'inline', enabled: 'true')
667
+
668
+ # Get a single form (full payload incl. blocks and settings)
669
+ form = client.opt_in_forms.get_opt_in_form(1)
670
+
671
+ # Create a form
672
+ result = client.opt_in_forms.create(
673
+ label: 'Newsletter Signup',
674
+ form_type: 'inline',
675
+ widget_type: 'inline',
676
+ enabled: true,
677
+ theme_settings: {
678
+ colors: { primary: '#3b82f6', background: '#ffffff', text: '#111827', border: '#d1d5db' }
679
+ },
680
+ automation_settings: {
681
+ tag_list: 'newsletter',
682
+ send_welcome_email: true,
683
+ double_opt_in: true,
684
+ sequence_ids: [3]
685
+ }
686
+ )
687
+
688
+ # Update (deeply nested settings hashes pass through verbatim)
689
+ client.opt_in_forms.update(1, enabled: false)
690
+
691
+ # Delete
692
+ client.opt_in_forms.delete(1)
693
+ ```
694
+
695
+ ### Analytics
696
+
697
+ ```ruby
698
+ # Last 30 days by default
699
+ client.opt_in_forms.analytics(1)
700
+
701
+ # Custom date range -- accepts Date, Time, or ISO-8601 strings
702
+ client.opt_in_forms.analytics(1,
703
+ start_date: Date.new(2026, 1, 1),
704
+ end_date: Date.new(2026, 1, 31)
705
+ )
706
+ # Returns: { totals: { views, unique_views, submissions, conversion_rate },
707
+ # daily: [...], variants: [...] }
708
+ ```
709
+
710
+ ### A/B Variants
711
+
712
+ ```ruby
713
+ # Create a variant of a form (defaults to "Variant N" / weight 50)
714
+ client.opt_in_forms.create_variant(1, name: 'B', weight: 50)
715
+
716
+ # Duplicate a form into a new top-level form (counts toward your plan limit)
717
+ client.opt_in_forms.duplicate(1, label: 'Newsletter Signup (Copy)')
718
+ ```
719
+
720
+ > **Note:** `index`/`show` and `create`/`update` return slightly different JSON shapes (the index/show responses go through JBuilder views; create/update use a richer inline serializer with analytics counts and embed URL). Don't depend on field-level parity between these paths.
721
+
722
+ ---
723
+
724
+ ## Email Servers
725
+
726
+ Configure outbound email providers for a channel (SMTP, AWS SES, Postmark, Inboxroad, SMTP.com, etc.).
727
+
728
+ **Required permissions:** `email_servers_read`, `email_servers_write`
729
+
730
+ ```ruby
731
+ # List email servers
732
+ result = client.email_servers.list
733
+ result['data'] # => [{'id' => 1, 'label' => 'Primary SES', 'vendor' => 'aws_ses', ...}, ...]
734
+ result['total'] # => 3
735
+
736
+ # Pagination
737
+ client.email_servers.list(limit: 10, offset: 0)
738
+
739
+ # Get a single email server
740
+ es = client.email_servers.get_email_server(1)
741
+
742
+ # Create -- example: AWS SES
743
+ client.email_servers.create(
744
+ label: 'Primary SES',
745
+ vendor: 'aws_ses',
746
+ delivery_method: 'aws_ses',
747
+ active: true,
748
+ aws_region: 'us-east-1',
749
+ aws_access_key_id: 'AKIA...',
750
+ aws_secret_access_key: 'secret...',
751
+ use_for_broadcasts: true,
752
+ use_for_sequences: true,
753
+ use_for_transactionals: true
754
+ )
755
+
756
+ # Create -- example: SMTP
757
+ client.email_servers.create(
758
+ label: 'Backup SMTP',
759
+ vendor: 'smtp',
760
+ delivery_method: 'smtp',
761
+ smtp_address: 'smtp.example.com',
762
+ smtp_port: 587,
763
+ smtp_username: 'user',
764
+ smtp_password: 'pass',
765
+ smtp_authentication: 'plain',
766
+ smtp_enable_starttls_auto: true,
767
+ emails_per_hour: 10000
768
+ )
769
+
770
+ # Update -- pass only the fields you want to change
771
+ client.email_servers.update(1, label: 'Primary SES (updated)', emails_per_hour: 50000)
772
+
773
+ # Test the connection (toggles `active` based on result)
774
+ result = client.email_servers.test_connection(1)
775
+ result['success'] # => true / false
776
+ result['message'] # => 'Connection successful' / 'Connection failed'
777
+
778
+ # Delete
779
+ client.email_servers.delete(1)
780
+ ```
781
+
782
+ ### Credential Redaction
783
+
784
+ API responses redact credential fields with bullet characters (e.g. `smtp_password: "abcd••••••wxyz"`). **Never round-trip a fetched response back into `update`** -- the gem detects redacted-shape values on known credential fields (`smtp_password`, `aws_*`, `postmark_api_token`, `inboxroad_api_token`, `smtp_com_api_key`) and silently strips them from the payload (with a warning) so they don't overwrite the real value:
785
+
786
+ ```ruby
787
+ # Safe -- only the fields you actually want to change
788
+ client.email_servers.update(1, label: 'New label', emails_per_hour: 25000)
789
+
790
+ # This would corrupt credentials WITHOUT the gem's scrubber:
791
+ es = client.email_servers.get_email_server(1)
792
+ client.email_servers.update(1, **es) # bullets get scrubbed, label still updates
793
+
794
+ # To rotate a credential, pass the real new value
795
+ client.email_servers.update(1, smtp_password: 'new-real-secret')
796
+ ```
797
+
798
+ ### Cross-Channel Copy (admin tokens only)
799
+
800
+ `copy_to_channel` clones an email server (with all settings and headers) into another channel. **Requires an admin/system token** with `email_servers_write` permission. Regular per-channel tokens get `Broadcast::AuthorizationError`. In SaaS mode, the target channel must be in the admin token creator's account.
801
+
802
+ ```ruby
803
+ admin_client = Broadcast::Client.new(
804
+ api_token: ENV['BROADCAST_ADMIN_TOKEN'],
805
+ broadcast_channel_id: 1 # the source server's channel
806
+ )
807
+
808
+ admin_client.email_servers.copy_to_channel(99, target_channel_id: 7)
809
+ ```
810
+
811
+ ---
812
+
813
+ ## Channel Scoping (Admin/System Tokens)
814
+
815
+ Regular API tokens are scoped to a single broadcast channel automatically. Admin/system tokens are not -- they require `broadcast_channel_id` on every request to indicate which channel they're acting on.
816
+
817
+ The gem auto-includes `broadcast_channel_id` from your `Configuration` on every request:
818
+
819
+ ```ruby
820
+ # Set globally
821
+ client = Broadcast::Client.new(
822
+ api_token: ENV['BROADCAST_ADMIN_TOKEN'],
823
+ broadcast_channel_id: 1
824
+ )
825
+ client.email_servers.list # broadcast_channel_id=1 is appended automatically
826
+ ```
827
+
828
+ For multi-channel scripts, use `with_channel` to scope a block of calls to a specific channel:
829
+
830
+ ```ruby
831
+ client = Broadcast::Client.new(api_token: ENV['BROADCAST_ADMIN_TOKEN'])
832
+
833
+ client.with_channel(1) do
834
+ client.email_servers.list
835
+ client.opt_in_forms.list
836
+ end
837
+
838
+ client.with_channel(2) do
839
+ client.subscribers.list
840
+ end
841
+ ```
842
+
843
+ `with_channel` overrides the config-level value inside the block. Per-call `broadcast_channel_id:` always wins over both. The override is thread-local, so it's safe to share a `Client` instance across threads.
844
+
845
+ ---
846
+
546
847
  ## Webhook Endpoints
547
848
 
548
849
  Receive real-time notifications when events occur (email delivered, subscriber created, sequence completed, etc.).
@@ -640,6 +941,7 @@ All API errors inherit from `Broadcast::Error`. Put specific errors before gener
640
941
  begin
641
942
  client.send_email(to: 'user@example.com', subject: 'Hi', body: 'Hello')
642
943
  rescue Broadcast::AuthenticationError # 401 -- invalid or expired API token
944
+ rescue Broadcast::AuthorizationError # 403 -- token lacks the required permission, or admin-only endpoint
643
945
  rescue Broadcast::NotFoundError # 404 -- resource does not exist
644
946
  rescue Broadcast::ValidationError # 422 -- missing or invalid parameters
645
947
  rescue Broadcast::RateLimitError # 429 -- exceeded 120 requests/minute
@@ -665,6 +967,8 @@ Each token can be scoped to specific resources. The ActionMailer delivery method
665
967
  | Broadcasts | `broadcasts_read` -- list, get, statistics | `broadcasts_write` -- create, update, delete, send, schedule |
666
968
  | Segments | `segments_read` -- list, get | `segments_write` -- create, update, delete |
667
969
  | Templates | `templates_read` -- list, get | `templates_write` -- create, update, delete |
970
+ | Opt-In Forms | `opt_in_forms_read` -- list, get, analytics | `opt_in_forms_write` -- create, update, delete, create_variant, duplicate |
971
+ | Email Servers | `email_servers_read` -- list, get | `email_servers_write` -- create, update, delete, test_connection, copy_to_channel (admin) |
668
972
  | Webhook Endpoints | `webhook_endpoints_read` -- list, get, deliveries | `webhook_endpoints_write` -- create, update, delete, test |
669
973
 
670
974
  ---
@@ -677,6 +981,12 @@ Each token can be scoped to specific resources. The ActionMailer delivery method
677
981
  - **Wrong host:** If you're self-hosting, make sure `host` points to your Broadcast instance, not `sendbroadcast.com`.
678
982
  - **Missing permissions:** Your token may not have the required permissions for the resource you're accessing. Check the [permissions table](#api-token-permissions).
679
983
 
984
+ ### `Broadcast::AuthorizationError` (403)
985
+
986
+ - **Missing permission:** Your token doesn't have the read or write permission for the resource (e.g. calling `client.opt_in_forms.create` with a token that only has `opt_in_forms_read`).
987
+ - **Admin-only endpoint:** `email_servers.copy_to_channel` requires an admin/system token. Regular per-channel tokens cannot perform cross-channel operations.
988
+ - **Missing channel scope on admin token:** Admin tokens require `broadcast_channel_id` on every request. Set it on `Broadcast::Client.new(broadcast_channel_id: …)` or wrap calls with `client.with_channel(id) { … }`.
989
+
680
990
  ### `Broadcast::ValidationError` (422)
681
991
 
682
992
  - **Missing required fields:** Check the parameters table for the method you're calling.
@@ -6,6 +6,8 @@ require 'uri'
6
6
 
7
7
  module Broadcast
8
8
  class Client
9
+ CHANNEL_OVERRIDE_KEY = :__broadcast_ruby_channel_override
10
+
9
11
  attr_reader :config
10
12
 
11
13
  def initialize(**settings)
@@ -14,16 +16,35 @@ module Broadcast
14
16
  @config.validate!
15
17
  end
16
18
 
17
- # --- Transactional email ---
19
+ # --- Channel scoping (admin/system tokens) ---
18
20
 
19
- def send_email(to:, subject:, body:, reply_to: nil)
20
- payload = { to: to, subject: subject, body: body }
21
- payload[:reply_to] = reply_to if reply_to
22
- request(:post, '/api/v1/transactionals.json', payload)
21
+ # Run a block with a temporary broadcast_channel_id override that will be
22
+ # auto-included on every request inside the block. Useful for admin/system
23
+ # tokens that need to scope each call to a specific channel.
24
+ #
25
+ # client.with_channel(123) do
26
+ # client.email_servers.list
27
+ # end
28
+ def with_channel(broadcast_channel_id)
29
+ key = channel_override_key
30
+ previous = Thread.current[key]
31
+ Thread.current[key] = broadcast_channel_id
32
+ yield self
33
+ ensure
34
+ Thread.current[key] = previous
35
+ end
36
+
37
+ # --- Transactional email (convenience shims) ---
38
+
39
+ # Thin convenience wrapper around `transactionals.create`. Use
40
+ # `client.transactionals.create` directly for template_id, double_opt_in,
41
+ # preheader, and other advanced options.
42
+ def send_email(to:, subject: nil, body: nil, reply_to: nil)
43
+ transactionals.create(to: to, subject: subject, body: body, reply_to: reply_to)
23
44
  end
24
45
 
25
46
  def get_email(id)
26
- request(:get, "/api/v1/transactionals/#{id}.json")
47
+ transactionals.get_transactional(id)
27
48
  end
28
49
 
29
50
  # --- Resource sub-clients ---
@@ -52,35 +73,76 @@ module Broadcast
52
73
  @webhook_endpoints ||= Resources::WebhookEndpoints.new(self)
53
74
  end
54
75
 
55
- # @api private
56
- def request(method, path, body_or_params = nil)
57
- uri = URI("#{@config.host}#{path}")
58
-
59
- if method == :get && body_or_params.is_a?(Hash) && body_or_params.any?
60
- uri.query = URI.encode_www_form(flatten_params(body_or_params))
61
- end
76
+ def transactionals
77
+ @transactionals ||= Resources::Transactionals.new(self)
78
+ end
62
79
 
63
- retry_with_backoff do
64
- http = Net::HTTP.new(uri.host, uri.port)
65
- http.use_ssl = uri.scheme == 'https'
66
- http.open_timeout = @config.open_timeout
67
- http.read_timeout = @config.timeout
80
+ def opt_in_forms
81
+ @opt_in_forms ||= Resources::OptInForms.new(self)
82
+ end
68
83
 
69
- req = build_request(method, uri)
70
- req.body = body_or_params.to_json if method != :get && body_or_params.is_a?(Hash) && body_or_params.any?
84
+ def email_servers
85
+ @email_servers ||= Resources::EmailServers.new(self)
86
+ end
71
87
 
72
- log_request(req, method == :get ? nil : body_or_params) if @config.debug
88
+ # @api private
89
+ def request(method, path, body_or_params = nil)
90
+ payload = inject_channel_scope(body_or_params)
91
+ uri = build_uri(path, method, payload)
73
92
 
74
- response = http.request(req)
75
- log_response(response) if @config.debug
76
- handle_response(response)
77
- end
93
+ retry_with_backoff { execute(method, uri, payload) }
78
94
  rescue Net::OpenTimeout, Net::ReadTimeout => e
79
95
  raise Broadcast::TimeoutError, "Request timeout: #{e.message}"
80
96
  end
81
97
 
82
98
  private
83
99
 
100
+ def channel_override_key
101
+ :"#{CHANNEL_OVERRIDE_KEY}_#{object_id}"
102
+ end
103
+
104
+ def active_channel_id
105
+ Thread.current[channel_override_key] || @config.broadcast_channel_id
106
+ end
107
+
108
+ # Auto-include broadcast_channel_id in request payload when configured (or
109
+ # set via with_channel) and not already specified by the caller.
110
+ def inject_channel_scope(body_or_params)
111
+ channel_id = active_channel_id
112
+ return body_or_params if channel_id.nil?
113
+
114
+ payload = body_or_params.is_a?(Hash) ? body_or_params.dup : {}
115
+ return payload if payload[:broadcast_channel_id] || payload['broadcast_channel_id']
116
+
117
+ payload[:broadcast_channel_id] = channel_id
118
+ payload
119
+ end
120
+
121
+ def build_uri(path, method, payload)
122
+ uri = URI("#{@config.host}#{path}")
123
+ uri.query = URI.encode_www_form(flatten_params(payload)) if method == :get && payload_present?(payload)
124
+ uri
125
+ end
126
+
127
+ def execute(method, uri, payload)
128
+ http = Net::HTTP.new(uri.host, uri.port)
129
+ http.use_ssl = uri.scheme == 'https'
130
+ http.open_timeout = @config.open_timeout
131
+ http.read_timeout = @config.timeout
132
+
133
+ req = build_request(method, uri)
134
+ req.body = payload.to_json if method != :get && payload_present?(payload)
135
+
136
+ log_request(req, method == :get ? nil : payload) if @config.debug
137
+ response = http.request(req)
138
+ log_response(response) if @config.debug
139
+ handle_response(response)
140
+ end
141
+
142
+ def payload_present?(payload)
143
+ payload.is_a?(Hash) && payload.any?
144
+ end
145
+
84
146
  def build_request(method, uri)
85
147
  klass = case method
86
148
  when :get then Net::HTTP::Get
@@ -97,25 +159,34 @@ module Broadcast
97
159
  req
98
160
  end
99
161
 
162
+ ERROR_MAPPING = {
163
+ 401 => [AuthenticationError, 'Authentication failed'],
164
+ 403 => [AuthorizationError, 'Not authorized'],
165
+ 404 => [NotFoundError, 'Resource not found'],
166
+ 422 => [ValidationError, 'Validation failed'],
167
+ 429 => [RateLimitError, 'Rate limit exceeded']
168
+ }.freeze
169
+ SERVER_ERROR_CODES = [500, 502, 503, 504].freeze
170
+ private_constant :ERROR_MAPPING, :SERVER_ERROR_CODES
171
+
100
172
  def handle_response(response)
101
- case response.code.to_i
102
- when 200, 201
103
- return {} if response.body.nil? || response.body.strip.empty?
104
-
105
- JSON.parse(response.body)
106
- when 401
107
- raise AuthenticationError, parse_error(response) || 'Authentication failed'
108
- when 404
109
- raise NotFoundError, parse_error(response) || 'Resource not found'
110
- when 422
111
- raise ValidationError, parse_error(response) || 'Validation failed'
112
- when 429
113
- raise RateLimitError, parse_error(response) || 'Rate limit exceeded'
114
- when 500, 502, 503, 504
115
- raise APIError, parse_error(response) || "Server error (#{response.code})"
116
- else
117
- raise APIError, parse_error(response) || "Unexpected response: #{response.code}"
173
+ code = response.code.to_i
174
+ return parse_success_body(response) if [200, 201].include?(code)
175
+
176
+ if (mapping = ERROR_MAPPING[code])
177
+ klass, default = mapping
178
+ raise klass, parse_error(response) || default
118
179
  end
180
+
181
+ raise APIError, parse_error(response) || "Server error (#{code})" if SERVER_ERROR_CODES.include?(code)
182
+
183
+ raise APIError, parse_error(response) || "Unexpected response: #{code}"
184
+ end
185
+
186
+ def parse_success_body(response)
187
+ return {} if response.body.nil? || response.body.strip.empty?
188
+
189
+ JSON.parse(response.body)
119
190
  end
120
191
 
121
192
  def parse_error(response)
@@ -9,7 +9,8 @@ module Broadcast
9
9
  :retry_attempts,
10
10
  :retry_delay,
11
11
  :logger,
12
- :debug
12
+ :debug,
13
+ :broadcast_channel_id
13
14
 
14
15
  def initialize
15
16
  @api_token = nil
@@ -20,6 +21,7 @@ module Broadcast
20
21
  @retry_delay = 1
21
22
  @logger = nil
22
23
  @debug = false
24
+ @broadcast_channel_id = nil
23
25
  end
24
26
 
25
27
  def validate!
@@ -9,6 +9,8 @@ module Broadcast
9
9
 
10
10
  class AuthenticationError < APIError; end
11
11
 
12
+ class AuthorizationError < APIError; end
13
+
12
14
  class NotFoundError < APIError; end
13
15
 
14
16
  class RateLimitError < APIError; end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Broadcast
4
+ module Resources
5
+ class EmailServers < Base
6
+ # Fields the API returns redacted (bullet-masked). When updating, never
7
+ # round-trip these values back from a fetch — the gem strips them out
8
+ # of the payload (with a logger warning) to prevent corrupting credentials.
9
+ REDACTED_FIELDS = %i[
10
+ smtp_password
11
+ aws_access_key_id
12
+ aws_secret_access_key
13
+ outbound_aws_access_key_id
14
+ outbound_aws_secret_access_key
15
+ postmark_api_token
16
+ inboxroad_api_token
17
+ smtp_com_api_key
18
+ ].freeze
19
+
20
+ # Matches the API's redaction shape: 8 bullets, OR 4-char prefix + bullets + 4-char suffix.
21
+ REDACTED_PATTERN = /\A(?:•{8}|.{0,4}•+.{0,4})\z/
22
+
23
+ def list(limit: nil, offset: nil)
24
+ params = {}
25
+ params[:limit] = limit unless limit.nil?
26
+ params[:offset] = offset unless offset.nil?
27
+ get('/api/v1/email_servers', params)
28
+ end
29
+
30
+ def get_email_server(id)
31
+ get("/api/v1/email_servers/#{id}")
32
+ end
33
+
34
+ def create(**attrs)
35
+ post('/api/v1/email_servers', { email_server: attrs })
36
+ end
37
+
38
+ # Update an email server. Attrs are wrapped under `email_server:` on the wire.
39
+ #
40
+ # CAUTION: API responses redact credential fields (e.g. `smtp_password`)
41
+ # with bullet characters. Never echo a fetched response back into update —
42
+ # this method scrubs values that match the redaction pattern, but you
43
+ # should pass only the fields you actually want to change.
44
+ def update(id, **attrs)
45
+ scrubbed = scrub_redacted(attrs)
46
+ patch("/api/v1/email_servers/#{id}", { email_server: scrubbed })
47
+ end
48
+
49
+ def delete(id)
50
+ @client.request(:delete, "/api/v1/email_servers/#{id}")
51
+ end
52
+
53
+ def test_connection(id)
54
+ post("/api/v1/email_servers/#{id}/test_connection")
55
+ end
56
+
57
+ # Copy an email server to another channel. Requires an admin/system token.
58
+ # In SaaS mode, target_channel_id is scoped to the token creator's account.
59
+ def copy_to_channel(id, target_channel_id:)
60
+ post("/api/v1/email_servers/#{id}/copy_to_channel", { target_channel_id: target_channel_id })
61
+ end
62
+
63
+ private
64
+
65
+ def scrub_redacted(attrs)
66
+ scrubbed = {}
67
+ attrs.each do |key, value|
68
+ if REDACTED_FIELDS.include?(key.to_sym) && value.is_a?(String) && value.match?(REDACTED_PATTERN)
69
+ warn_redacted(key)
70
+ next
71
+ end
72
+ scrubbed[key] = value
73
+ end
74
+ scrubbed
75
+ end
76
+
77
+ def warn_redacted(field)
78
+ msg = "[broadcast-ruby] Dropped redacted #{field} from update payload — " \
79
+ 'pass the real credential or omit the field'
80
+ if @client.config.logger
81
+ @client.config.logger.warn(msg)
82
+ else
83
+ Kernel.warn(msg)
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ module Broadcast
6
+ module Resources
7
+ class OptInForms < Base
8
+ # List opt-in forms.
9
+ #
10
+ # NOTE: returns up to 250 results per page along with `pagination`
11
+ # metadata. Variants are excluded (only main forms are returned).
12
+ # Pass `page:` to advance.
13
+ #
14
+ # Optional filters: filter: (label substring), widget_type:, enabled: 'true'
15
+ def list(**params)
16
+ get('/api/v1/opt_in_forms', params)
17
+ end
18
+
19
+ def get_opt_in_form(id)
20
+ get("/api/v1/opt_in_forms/#{id}")
21
+ end
22
+
23
+ # Create an opt-in form. Attrs are wrapped under `opt_in_form:` on the wire.
24
+ # Nested settings hashes (theme_settings, automation_settings, security_settings,
25
+ # trigger_settings, widget_settings) and arrays (opt_in_form_blocks_attributes,
26
+ # opt_in_post_submission_blocks_attributes) are passed through verbatim.
27
+ def create(**attrs)
28
+ post('/api/v1/opt_in_forms', { opt_in_form: attrs })
29
+ end
30
+
31
+ def update(id, **attrs)
32
+ patch("/api/v1/opt_in_forms/#{id}", { opt_in_form: attrs })
33
+ end
34
+
35
+ def delete(id)
36
+ @client.request(:delete, "/api/v1/opt_in_forms/#{id}")
37
+ end
38
+
39
+ # Performance analytics for the form. start_date/end_date accept Date,
40
+ # Time, or ISO-8601 strings (default: last 30 days).
41
+ def analytics(id, start_date: nil, end_date: nil)
42
+ params = {}
43
+ params[:start_date] = coerce_date(start_date) if start_date
44
+ params[:end_date] = coerce_date(end_date) if end_date
45
+ get("/api/v1/opt_in_forms/#{id}/analytics", params)
46
+ end
47
+
48
+ def create_variant(id, name: nil, weight: nil)
49
+ body = {}
50
+ body[:name] = name unless name.nil?
51
+ body[:weight] = weight unless weight.nil?
52
+ post("/api/v1/opt_in_forms/#{id}/variants", body)
53
+ end
54
+
55
+ def duplicate(id, label: nil)
56
+ body = {}
57
+ body[:label] = label unless label.nil?
58
+ post("/api/v1/opt_in_forms/#{id}/duplicate", body)
59
+ end
60
+
61
+ private
62
+
63
+ def coerce_date(value)
64
+ case value
65
+ when Date, Time, DateTime then value.iso8601
66
+ else value.to_s
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -11,8 +11,26 @@ module Broadcast
11
11
  get('/api/v1/subscribers/find.json', { email: email })
12
12
  end
13
13
 
14
+ # Create or upsert a subscriber.
15
+ #
16
+ # Subscriber attributes (wrapped under `subscriber:` on the wire):
17
+ # email:, first_name:, last_name:, is_active:, source:,
18
+ # subscribed_at:, ip_address:, tags: [...], custom_data: {...}
19
+ #
20
+ # Top-level options (NOT wrapped under `subscriber:`):
21
+ # double_opt_in: true | { reply_to:, confirmation_template_id:, include_unsubscribe_link: }
22
+ # When set, the subscriber is created in unconfirmed state
23
+ # and a confirmation email is queued.
24
+ # confirmation_template_id: custom confirmation template (used with double_opt_in: true)
14
25
  def create(**attrs)
15
- post('/api/v1/subscribers.json', { subscriber: attrs })
26
+ double_opt_in = attrs.delete(:double_opt_in)
27
+ confirmation_template_id = attrs.delete(:confirmation_template_id)
28
+
29
+ payload = { subscriber: attrs }
30
+ payload[:double_opt_in] = double_opt_in unless double_opt_in.nil?
31
+ payload[:confirmation_template_id] = confirmation_template_id unless confirmation_template_id.nil?
32
+
33
+ post('/api/v1/subscribers.json', payload)
16
34
  end
17
35
 
18
36
  def update(email, **attrs)
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Broadcast
4
+ module Resources
5
+ class Transactionals < Base
6
+ # Send a transactional email.
7
+ #
8
+ # Required:
9
+ # to: recipient email address
10
+ #
11
+ # One of subject/body or template_id is required (template_id resolves
12
+ # subject and body server-side; subject/body override the template).
13
+ #
14
+ # Optional:
15
+ # subject:, body:, preheader:
16
+ # reply_to:
17
+ # template_id: resolve subject/body/preheader from a Template
18
+ # include_unsubscribe_link: boolean
19
+ # double_opt_in: true | { reply_to:, confirmation_template_id:, include_unsubscribe_link: }
20
+ # Holds the email until the recipient confirms.
21
+ # confirmation_template_id: custom confirmation template (used with double_opt_in: true)
22
+ # subscriber: { first_name:, last_name: } — populates Subscriber on first send
23
+ # rubocop:disable Metrics/ParameterLists -- mirrors the API's flat param surface
24
+ def create(to:, subject: nil, body: nil, reply_to: nil, preheader: nil,
25
+ template_id: nil, include_unsubscribe_link: nil,
26
+ double_opt_in: nil, confirmation_template_id: nil,
27
+ subscriber: nil, **extra)
28
+ # rubocop:enable Metrics/ParameterLists
29
+ payload = { to: to }
30
+ payload[:subject] = subject unless subject.nil?
31
+ payload[:body] = body unless body.nil?
32
+ payload[:preheader] = preheader unless preheader.nil?
33
+ payload[:reply_to] = reply_to unless reply_to.nil?
34
+ payload[:template_id] = template_id unless template_id.nil?
35
+ payload[:include_unsubscribe_link] = include_unsubscribe_link unless include_unsubscribe_link.nil?
36
+ payload[:double_opt_in] = double_opt_in unless double_opt_in.nil?
37
+ payload[:confirmation_template_id] = confirmation_template_id unless confirmation_template_id.nil?
38
+ payload[:subscriber] = subscriber unless subscriber.nil?
39
+ payload.merge!(extra) unless extra.empty?
40
+
41
+ post('/api/v1/transactionals.json', payload)
42
+ end
43
+
44
+ def get_transactional(id)
45
+ get("/api/v1/transactionals/#{id}.json")
46
+ end
47
+ end
48
+ end
49
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Broadcast
4
- VERSION = '0.1.4'
4
+ VERSION = '0.2.0'
5
5
  end
data/lib/broadcast.rb CHANGED
@@ -12,6 +12,9 @@ require_relative 'broadcast/resources/broadcasts'
12
12
  require_relative 'broadcast/resources/segments'
13
13
  require_relative 'broadcast/resources/templates'
14
14
  require_relative 'broadcast/resources/webhook_endpoints'
15
+ require_relative 'broadcast/resources/transactionals'
16
+ require_relative 'broadcast/resources/opt_in_forms'
17
+ require_relative 'broadcast/resources/email_servers'
15
18
 
16
19
  # ActionMailer integration — only loaded when Rails is present
17
20
  if defined?(Rails::Railtie)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: broadcast-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Simon Chiu
@@ -47,10 +47,13 @@ files:
47
47
  - lib/broadcast/railtie.rb
48
48
  - lib/broadcast/resources/base.rb
49
49
  - lib/broadcast/resources/broadcasts.rb
50
+ - lib/broadcast/resources/email_servers.rb
51
+ - lib/broadcast/resources/opt_in_forms.rb
50
52
  - lib/broadcast/resources/segments.rb
51
53
  - lib/broadcast/resources/sequences.rb
52
54
  - lib/broadcast/resources/subscribers.rb
53
55
  - lib/broadcast/resources/templates.rb
56
+ - lib/broadcast/resources/transactionals.rb
54
57
  - lib/broadcast/resources/webhook_endpoints.rb
55
58
  - lib/broadcast/version.rb
56
59
  - lib/broadcast/webhook.rb