broadcast-ruby 0.1.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.
data/README.md ADDED
@@ -0,0 +1,522 @@
1
+ # broadcast-ruby
2
+
3
+ Ruby client for the [Broadcast](https://sendbroadcast.com) email platform.
4
+
5
+ Works with [sendbroadcast.com](https://sendbroadcast.com) or any self-hosted Broadcast instance.
6
+
7
+ ## Installation
8
+
9
+ ```ruby
10
+ gem 'broadcast-ruby'
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```ruby
16
+ client = Broadcast::Client.new(
17
+ api_token: 'your-token',
18
+ host: 'https://sendbroadcast.com' # or your self-hosted URL
19
+ )
20
+
21
+ client.send_email(
22
+ to: 'jane@example.com',
23
+ subject: 'Welcome!',
24
+ body: '<h1>Hello Jane</h1><p>Welcome aboard.</p>'
25
+ )
26
+ ```
27
+
28
+ ### Options
29
+
30
+ | Option | Default | Description |
31
+ |--------|---------|-------------|
32
+ | `api_token` | *required* | Your Broadcast API token |
33
+ | `host` | `https://sendbroadcast.com` | Broadcast instance URL |
34
+ | `timeout` | `30` | Read timeout (seconds) |
35
+ | `open_timeout` | `10` | Connection timeout (seconds) |
36
+ | `retry_attempts` | `3` | Max total attempts (1 initial + 2 retries). Server errors (5xx) and timeouts are retried; client errors (4xx) are not |
37
+ | `retry_delay` | `1` | Base delay between retries in seconds (multiplied by attempt number) |
38
+ | `debug` | `false` | Log request/response details |
39
+ | `logger` | `nil` | Logger instance for debug output (e.g. `Rails.logger`) |
40
+
41
+ All methods return parsed JSON as Ruby Hashes with string keys.
42
+
43
+ > **Note on module naming:** This gem defines a top-level `Broadcast` module. If your application already has a `Broadcast` class or module (e.g. an ActiveRecord model), you may encounter a namespace collision.
44
+
45
+ ---
46
+
47
+ ## Transactional Email
48
+
49
+ Send one-off emails triggered by application events. Transactional emails bypass unsubscribe status (for password resets, receipts, order confirmations, etc.).
50
+
51
+ The sender address is configured at the channel level in your Broadcast instance — there is no `from` parameter.
52
+
53
+ ```ruby
54
+ # Send an email
55
+ result = client.send_email(
56
+ to: 'user@example.com',
57
+ subject: 'Your password reset link',
58
+ body: '<p>Click <a href="...">here</a> to reset your password.</p>',
59
+ reply_to: 'support@yourapp.com' # optional
60
+ )
61
+
62
+ result['id'] # => 42
63
+ result['recipient_email'] # => 'user@example.com'
64
+ result['status_url'] # => '/api/v1/transactionals/42.json'
65
+ ```
66
+
67
+ ```ruby
68
+ # Check delivery status
69
+ email = client.get_email(42)
70
+ email['sent_at'] # => '2026-03-17T08:00:00Z' (nil if not yet sent)
71
+ email['queue_at'] # => '2026-03-17T07:59:58Z'
72
+ ```
73
+
74
+ **Parameters for `send_email`:**
75
+
76
+ | Param | Required | Description |
77
+ |-------|----------|-------------|
78
+ | `to` | yes | Recipient email address |
79
+ | `subject` | yes | Email subject line |
80
+ | `body` | yes | Email content (HTML or plain text) |
81
+ | `reply_to` | no | Reply-to address |
82
+
83
+ ---
84
+
85
+ ## Subscribers
86
+
87
+ Manage your contact list. Subscribers can have tags and custom data fields for segmentation and personalization.
88
+
89
+ ```ruby
90
+ # List subscribers (paginated, 250 per page)
91
+ result = client.subscribers.list(page: 1)
92
+ result['subscribers'] # => [{'email' => '...', 'tags' => [...], ...}, ...]
93
+ result['pagination']['total'] # => 1500
94
+ result['pagination']['current'] # => 1
95
+
96
+ # Filter by status, tags, dates, or custom data
97
+ client.subscribers.list(is_active: true)
98
+ client.subscribers.list(tags: ['newsletter', 'premium'])
99
+ client.subscribers.list(created_after: '2026-01-01T00:00:00Z')
100
+ client.subscribers.list(custom_data: { plan: 'pro' })
101
+ ```
102
+
103
+ ```ruby
104
+ # Find by email
105
+ subscriber = client.subscribers.find(email: 'jane@example.com')
106
+ subscriber['email'] # => 'jane@example.com'
107
+ subscriber['first_name'] # => 'Jane'
108
+ subscriber['tags'] # => ['newsletter', 'premium']
109
+ subscriber['custom_data'] # => {'plan' => 'pro'}
110
+ subscriber['is_active'] # => true
111
+ ```
112
+
113
+ ```ruby
114
+ # Create a subscriber
115
+ # Returns the created subscriber as a Hash
116
+ client.subscribers.create(
117
+ email: 'jane@example.com',
118
+ first_name: 'Jane',
119
+ last_name: 'Doe',
120
+ tags: ['newsletter', 'free-tier'],
121
+ custom_data: { plan: 'free', signup_source: 'landing_page' }
122
+ )
123
+
124
+ # Update a subscriber (identified by email)
125
+ # Returns the updated subscriber as a Hash
126
+ client.subscribers.update('jane@example.com',
127
+ first_name: 'Janet',
128
+ tags: ['newsletter', 'premium'], # replaces all tags
129
+ custom_data: { plan: 'pro' }
130
+ )
131
+
132
+ # Change a subscriber's email address
133
+ client.subscribers.update('old@example.com', email: 'new@example.com')
134
+ ```
135
+
136
+ ```ruby
137
+ # Add tags without replacing existing ones
138
+ client.subscribers.add_tags('jane@example.com', ['vip', 'early-adopter'])
139
+
140
+ # Remove specific tags
141
+ client.subscribers.remove_tags('jane@example.com', ['free-tier'])
142
+ ```
143
+
144
+ ```ruby
145
+ # Deactivate (stop all email delivery, keep record)
146
+ client.subscribers.deactivate('jane@example.com')
147
+
148
+ # Activate (resume email delivery)
149
+ client.subscribers.activate('jane@example.com')
150
+
151
+ # Unsubscribe (marks as unsubscribed AND deactivates)
152
+ client.subscribers.unsubscribe('jane@example.com')
153
+
154
+ # Resubscribe (clears unsubscribed status AND activates)
155
+ client.subscribers.resubscribe('jane@example.com')
156
+
157
+ # Redact — GDPR "right to be forgotten" (irreversible)
158
+ # Removes PII but preserves aggregate campaign statistics
159
+ client.subscribers.redact('jane@example.com')
160
+ ```
161
+
162
+ ---
163
+
164
+ ## Sequences
165
+
166
+ Automated drip campaigns. Add subscribers to a sequence and they flow through the steps automatically.
167
+
168
+ ```ruby
169
+ # List all sequences
170
+ result = client.sequences.list
171
+ result # => [{'id' => 1, 'label' => 'Onboarding', 'active' => true, 'subscribers_count' => 42, ...}, ...]
172
+
173
+ # Get a single sequence
174
+ sequence = client.sequences.get_sequence(1)
175
+
176
+ # Get with steps included
177
+ sequence = client.sequences.get_sequence(1, include_steps: true)
178
+ sequence['steps'] # => [{'id' => 10, 'action' => 'delay', ...}, ...]
179
+
180
+ # Create
181
+ result = client.sequences.create(label: 'Onboarding', active: false)
182
+ result['id'] # => 5
183
+
184
+ # Create with auto-enrollment by tag
185
+ # Subscribers are auto-added when this tag is applied to them
186
+ client.sequences.create(
187
+ label: 'Free Trial Nurture',
188
+ init_tag: 'free-trial',
189
+ active: true
190
+ )
191
+
192
+ # Create with auto-enrollment by segment
193
+ # Matching subscribers are synced every 15 minutes
194
+ client.sequences.create(
195
+ label: 'Re-engagement',
196
+ init_segment_id: 5,
197
+ active: true
198
+ )
199
+
200
+ # Update
201
+ client.sequences.update(1, label: 'Updated Onboarding', active: true)
202
+
203
+ # Delete (and all its steps)
204
+ client.sequences.delete(1)
205
+ ```
206
+
207
+ ### Subscriber Enrollment
208
+
209
+ ```ruby
210
+ # Add subscriber to sequence (creates subscriber if new)
211
+ client.sequences.add_subscriber(1, email: 'jane@example.com', first_name: 'Jane')
212
+
213
+ # Remove subscriber from sequence
214
+ client.sequences.remove_subscriber(1, email: 'jane@example.com')
215
+
216
+ # List subscribers in a sequence
217
+ # Returns subscriber_sequences objects with enrollment status
218
+ result = client.sequences.list_subscribers(1, page: 1)
219
+ result['subscriber_sequences']
220
+ # => [{'id' => 1, 'status' => 'active', 'started_at' => '...', 'next_trigger_at' => '...',
221
+ # 'subscriber' => {'id' => '123', 'email' => 'jane@example.com'}}, ...]
222
+ ```
223
+
224
+ ### Steps
225
+
226
+ ```ruby
227
+ # List steps in a sequence
228
+ client.sequences.list_steps(sequence_id)
229
+
230
+ # Get a single step
231
+ step = client.sequences.get_step(sequence_id, step_id)
232
+
233
+ # Create steps — each step needs an action, label, and parent_id
234
+ # The parent_id links steps into a tree (entry point is the sequence's root step)
235
+
236
+ # Delay step (seconds)
237
+ client.sequences.create_step(sequence_id,
238
+ action: 'delay', label: 'Wait 1 day',
239
+ parent_id: entry_point_id, delay: 86400
240
+ )
241
+
242
+ # Email step
243
+ client.sequences.create_step(sequence_id,
244
+ action: 'send_email', label: 'Welcome email',
245
+ parent_id: delay_step_id,
246
+ subject: 'Welcome!', body: '<h1>Welcome!</h1>'
247
+ )
248
+
249
+ # Condition step (branches based on engagement)
250
+ client.sequences.create_step(sequence_id,
251
+ action: 'condition', label: 'Opened welcome email?',
252
+ parent_id: email_step_id,
253
+ condition_setting: 'previous_email_opened'
254
+ )
255
+
256
+ # Tag step
257
+ client.sequences.create_step(sequence_id,
258
+ action: 'add_tag_to_subscriber', label: 'Tag as engaged',
259
+ parent_id: condition_yes_id, taggify_list: 'engaged,onboarded'
260
+ )
261
+
262
+ # Update a step
263
+ client.sequences.update_step(sequence_id, step_id, label: 'Updated')
264
+
265
+ # Move a step under a different parent
266
+ client.sequences.move_step(sequence_id, step_id, under_id: new_parent_id)
267
+
268
+ # Delete a step
269
+ client.sequences.delete_step(sequence_id, step_id)
270
+ ```
271
+
272
+ **Step actions:** `send_email`, `delay`, `delay_until_time`, `condition`, `move_to_sequence`, `add_tag_to_subscriber`, `remove_tag_from_subscriber`, `deactivate_subscriber`, `make_http_request`
273
+
274
+ **Condition settings:** `any_email_opened`, `previous_email_opened`, `any_email_clicked`, `previous_email_clicked`
275
+
276
+ ---
277
+
278
+ ## Broadcasts
279
+
280
+ One-time email campaigns sent to your subscriber list or targeted segments.
281
+
282
+ ```ruby
283
+ # List broadcasts
284
+ result = client.broadcasts.list(limit: 10, offset: 0)
285
+ # => [{'id' => 1, 'name' => '...', 'subject' => '...', 'status' => 'draft', ...}, ...]
286
+
287
+ # Get a broadcast
288
+ broadcast = client.broadcasts.get_broadcast(1)
289
+ broadcast['status'] # => 'draft'
290
+
291
+ # Create a broadcast (starts as draft)
292
+ result = client.broadcasts.create(
293
+ subject: 'March Newsletter',
294
+ body: '<h1>What is new</h1><p>Updates...</p>',
295
+ name: 'march-2026-newsletter',
296
+ preheader: 'Product updates and tips',
297
+ reply_to: 'hello@yourapp.com',
298
+ track_opens: true,
299
+ track_clicks: true,
300
+ segment_ids: [1, 3],
301
+ taggify_list: 'newsletter,march-2026'
302
+ )
303
+ result['id'] # => 5
304
+
305
+ # Update (draft or scheduled only)
306
+ client.broadcasts.update(1, subject: 'Updated Subject')
307
+
308
+ # Send immediately (draft or failed only)
309
+ result = client.broadcasts.send_broadcast(1)
310
+ result['status'] # => 'queueing'
311
+
312
+ # Schedule for later
313
+ result = client.broadcasts.schedule(1,
314
+ scheduled_send_at: '2026-03-20T09:00:00Z',
315
+ scheduled_timezone: 'America/Toronto'
316
+ )
317
+ result['status'] # => 'future_scheduled'
318
+
319
+ # Cancel a scheduled broadcast (returns to draft)
320
+ client.broadcasts.cancel_schedule(1)
321
+
322
+ # Delete (draft or scheduled only)
323
+ client.broadcasts.delete(1)
324
+ ```
325
+
326
+ ### Statistics
327
+
328
+ ```ruby
329
+ # Delivery and engagement stats
330
+ stats = client.broadcasts.statistics(1)
331
+ stats['delivery']['sent'] # => 1500
332
+ stats['engagement']['opens']['count'] # => 723
333
+ stats['engagement']['clicks']['count'] # => 184
334
+ stats['issues']['bounces']['count'] # => 12
335
+ stats['issues']['unsubscribes']['count'] # => 3
336
+
337
+ # Timeline stats (for charts)
338
+ client.broadcasts.statistics_timeline(1, timeframe: '24h', metrics: 'opens,clicks')
339
+ # timeframes: 60m, 120m, 3h, 6h, 12h, 18h, 24h, 48h, 72h, 7d, 14d
340
+
341
+ # Per-link click stats
342
+ client.broadcasts.statistics_links(1, sort: 'clicks', order: 'desc')
343
+ ```
344
+
345
+ **Broadcast statuses:** `draft`, `future_scheduled`, `scheduled`, `queueing`, `sending`, `sent`, `failed`, `partial_failure`, `aborted`, `paused`
346
+
347
+ ---
348
+
349
+ ## Segments
350
+
351
+ Define subscriber groups using rules for targeted broadcasts and sequence enrollment.
352
+
353
+ ```ruby
354
+ # List segments
355
+ result = client.segments.list
356
+ result['segments'] # => [{'id' => 1, 'name' => 'Active Users', ...}, ...]
357
+
358
+ # Get segment with matching subscribers (paginated)
359
+ result = client.segments.get_segment(1, page: 1)
360
+ result['segment'] # => {'id' => 1, 'name' => 'Active Users', ...}
361
+ result['subscribers'] # => [{'email' => '...', ...}, ...]
362
+ result['pagination']['total'] # => 150
363
+
364
+ # Create a segment with rules
365
+ # Groups are OR'd together; rules within a group are combined by match_type (all = AND, any = OR)
366
+ result = client.segments.create(
367
+ name: 'Active Gmail Users',
368
+ description: 'Gmail users who opened an email in the last 30 days',
369
+ segment_groups_attributes: [
370
+ {
371
+ match_type: 'all',
372
+ segment_rules_attributes: [
373
+ { field: 'email', operator: 'contains', value: 'gmail.com',
374
+ rule_type: 'text', value_type: 'string' },
375
+ { field: 'last_email_opened_at', operator: 'within_last_days', value: '30',
376
+ rule_type: 'date', value_type: 'string' }
377
+ ]
378
+ }
379
+ ]
380
+ )
381
+ result['id'] # => 5
382
+
383
+ # Update
384
+ client.segments.update(1, name: 'Updated Name')
385
+
386
+ # Delete
387
+ client.segments.delete(1)
388
+ ```
389
+
390
+ **Rule fields:** `email`, `first_name`, `last_name`, `tags`, `is_active`, `created_at`, `last_email_sent_at`, `last_email_opened_at`, `last_email_clicked_at`, `total_emails_sent`, `total_emails_opened`, `total_emails_clicked`, `has_opened_any_email`, `has_clicked_any_email`
391
+
392
+ **Operators by type:**
393
+ - **Text:** `equals`, `not_equals`, `contains`, `not_contains`, `starts_with`, `ends_with`, `is_empty`, `is_not_empty`
394
+ - **Number:** `equals`, `not_equals`, `greater_than`, `less_than`, `greater_than_or_equal`, `less_than_or_equal`
395
+ - **Date:** `equals`, `before`, `after`, `within_last_days`, `not_within_last_days`, `never`, `is_empty`, `is_not_empty`
396
+ - **Boolean:** `is_true`, `is_false`
397
+
398
+ ---
399
+
400
+ ## Templates
401
+
402
+ Reusable email templates with [Liquid](https://shopify.github.io/liquid/) variable support for personalization (e.g. `{{first_name}}`, `{{email}}`).
403
+
404
+ ```ruby
405
+ # List all templates
406
+ result = client.templates.list
407
+ result['data'] # => [{'id' => 1, 'label' => 'Welcome', 'subject' => '...', ...}, ...]
408
+
409
+ # Get a template
410
+ template = client.templates.get_template(1)
411
+ template['label'] # => 'Welcome'
412
+ template['subject'] # => 'Hello {{first_name}}'
413
+ template['body'] # => '<h1>Welcome!</h1>'
414
+
415
+ # Create a template
416
+ # Returns {'id' => number}
417
+ client.templates.create(
418
+ label: 'Monthly Newsletter',
419
+ subject: 'Your {{month}} update',
420
+ body: '<h1>Hello {{first_name}}</h1><p>Here is what happened...</p>',
421
+ preheader: 'Your monthly digest',
422
+ html_body: true
423
+ )
424
+
425
+ # Update
426
+ client.templates.update(1, subject: 'Updated subject')
427
+
428
+ # Delete
429
+ client.templates.delete(1)
430
+ ```
431
+
432
+ ---
433
+
434
+ ## Webhook Endpoints
435
+
436
+ Receive real-time notifications when events occur (email delivered, subscriber created, sequence completed, etc.).
437
+
438
+ ```ruby
439
+ # List endpoints
440
+ result = client.webhook_endpoints.list
441
+ result['data'] # => [{'id' => 1, 'url' => '...', 'active' => true, ...}, ...]
442
+
443
+ # Get an endpoint
444
+ endpoint = client.webhook_endpoints.get_endpoint(1)
445
+
446
+ # Create an endpoint
447
+ result = client.webhook_endpoints.create(
448
+ url: 'https://yourapp.com/webhooks/broadcast',
449
+ event_types: [
450
+ 'email.sent', 'email.delivered', 'email.opened', 'email.clicked',
451
+ 'subscriber.created', 'subscriber.unsubscribed',
452
+ 'sequence.subscriber_added', 'sequence.subscriber_completed'
453
+ ],
454
+ retries_to_attempt: 6
455
+ )
456
+ # IMPORTANT: Save the secret from the response — it is only shown once
457
+ secret = result['secret']
458
+
459
+ # Update (url and secret cannot be changed — create a new endpoint instead)
460
+ client.webhook_endpoints.update(1, active: false)
461
+ client.webhook_endpoints.update(1, event_types: ['email.delivered', 'email.opened'])
462
+
463
+ # Delete
464
+ client.webhook_endpoints.delete(1)
465
+
466
+ # Send a test webhook
467
+ client.webhook_endpoints.test(1, event_type: 'email.delivered')
468
+
469
+ # View delivery history
470
+ result = client.webhook_endpoints.deliveries(1, limit: 10)
471
+ result['data'] # => [{'id' => 1, 'event_type' => 'email.sent', 'response_status' => 200, ...}, ...]
472
+ ```
473
+
474
+ **Event types:**
475
+
476
+ | Category | Events |
477
+ |----------|--------|
478
+ | Email | `email.sent`, `email.delivered`, `email.delivery_delayed`, `email.opened`, `email.clicked`, `email.bounced`, `email.complained`, `email.failed` |
479
+ | Subscriber | `subscriber.created`, `subscriber.updated`, `subscriber.deleted`, `subscriber.subscribed`, `subscriber.unsubscribed`, `subscriber.bounced`, `subscriber.complained` |
480
+ | Broadcast | `broadcast.scheduled`, `broadcast.queueing`, `broadcast.sending`, `broadcast.sent`, `broadcast.failed`, `broadcast.partial_failure`, `broadcast.aborted`, `broadcast.paused` |
481
+ | Sequence | `sequence.subscriber_added`, `sequence.subscriber_completed`, `sequence.subscriber_moved`, `sequence.subscriber_removed`, `sequence.subscriber_paused`, `sequence.subscriber_resumed`, `sequence.subscriber_error` |
482
+ | System | `message.attempt.exhausted`, `test.webhook` |
483
+
484
+ ### Webhook Signature Verification
485
+
486
+ All incoming webhooks are signed with HMAC-SHA256. Verify them in your controller:
487
+
488
+ ```ruby
489
+ Broadcast::Webhook.verify(
490
+ request.raw_post, # raw request body
491
+ request.headers['broadcast-webhook-signature'], # v1,<base64 signature>
492
+ request.headers['broadcast-webhook-timestamp'], # unix timestamp
493
+ secret: ENV['BROADCAST_WEBHOOK_SECRET']
494
+ )
495
+ # => true or false
496
+ ```
497
+
498
+ The signature is computed as `HMAC-SHA256(timestamp + "." + payload, secret)`. Timestamps older than 5 minutes are rejected to prevent replay attacks.
499
+
500
+ ---
501
+
502
+ ## Error Handling
503
+
504
+ All API errors inherit from `Broadcast::Error`:
505
+
506
+ ```ruby
507
+ begin
508
+ client.send_email(to: 'user@example.com', subject: 'Hi', body: 'Hello')
509
+ rescue Broadcast::AuthenticationError # 401 — invalid or expired API token
510
+ rescue Broadcast::NotFoundError # 404 — resource does not exist
511
+ rescue Broadcast::ValidationError # 422 — missing or invalid parameters
512
+ rescue Broadcast::RateLimitError # 429 — exceeded 120 requests/minute
513
+ rescue Broadcast::TimeoutError # connection or read timeout
514
+ rescue Broadcast::APIError # 5xx or unexpected status codes
515
+ end
516
+ ```
517
+
518
+ Server errors (5xx) and timeouts are automatically retried with linear backoff. Client errors (401, 404, 422, 429) are raised immediately.
519
+
520
+ ## License
521
+
522
+ MIT License. See [LICENSE.txt](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'minitest/test_task'
5
+
6
+ Minitest::TestTask.create do |t|
7
+ t.test_globs = ['test/test_*.rb']
8
+ t.test_prelude = 'ENV["BROADCAST_SKIP_LIVE"] = "1"'
9
+ end
10
+
11
+ Minitest::TestTask.create(:test_live) do |t|
12
+ t.test_globs = ['test/test_live.rb']
13
+ end
14
+
15
+ task default: :test