broadcast-ruby 0.3.0 → 0.4.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 +4 -4
- data/CHANGELOG.md +59 -0
- data/Gemfile.lock +2 -2
- data/README.md +171 -14
- data/SDK-COVERAGE.md +60 -13
- data/lib/broadcast/client.rb +29 -2
- data/lib/broadcast/delivery_method.rb +17 -2
- data/lib/broadcast/resources/global_suppressions.rb +43 -0
- data/lib/broadcast/resources/suppressions.rb +56 -0
- data/lib/broadcast/version.rb +1 -1
- data/lib/broadcast.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7866007085ed5d19620669069bdfeb9e7babaad5d08b2198d76e19620ce68bde
|
|
4
|
+
data.tar.gz: '082d0663d0a5a2501f445f0f7152e6b6a56467d1e0a61d0ae96e45b3137380d0'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bd9d8c35c4bdcea360c59609be5e366bcd066084328db6380e7f3995cc49e7121937b053c6e09c36d94262d54cfc4d4bb978dcae93a58410407a3da78a6d9182
|
|
7
|
+
data.tar.gz: c4d0c47df97790a337dc35b882fea9254c1ae8f41d19784f71bebecb253c6b00740356be0cb5b1b7edf2d2c9965f84556f1fcc31fc350dd1e6214adda167fc63
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,65 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.4.0] - 2026-08-14
|
|
8
|
+
|
|
9
|
+
### Fixed — ActionMailer delivery
|
|
10
|
+
|
|
11
|
+
Both reproduced from a real delivered message, and both traced to the same
|
|
12
|
+
ceiling: `Client#send_email` accepted only `to`/`subject`/`body`/`reply_to`, so
|
|
13
|
+
`DeliveryMethod` had no way to describe what it was sending.
|
|
14
|
+
|
|
15
|
+
- **HTML mail arrived as two nested HTML documents.** `deliver!` sent an HTML
|
|
16
|
+
body without flagging it as HTML, so Broadcast recorded the send as plain text
|
|
17
|
+
and wrapped the payload in its own `<html><body>` shell. `deliver!` now sends
|
|
18
|
+
`html_body: true` when the mail has an `html_part`.
|
|
19
|
+
- **Transactional mail carried a one-click unsubscribe.** A password reset
|
|
20
|
+
arrived with `List-Unsubscribe` and `List-Unsubscribe-Post: One-Click`,
|
|
21
|
+
because `deliver!` could not say the send was transactional and the channel's
|
|
22
|
+
unsubscribe setting applied to it. Clicking it marks the recipient
|
|
23
|
+
unsubscribed, silently dropping them from every sequence and broadcast — from
|
|
24
|
+
a click on a security email. `deliver!` now sends
|
|
25
|
+
`include_unsubscribe_link: false` by default; set
|
|
26
|
+
`include_unsubscribe_link: true` in `broadcast_settings` to opt back in. The
|
|
27
|
+
option is consumed by `DeliveryMethod` rather than forwarded, since
|
|
28
|
+
`Configuration` has no such attribute.
|
|
29
|
+
|
|
30
|
+
`Client#send_email` gained matching optional `html_body:` and
|
|
31
|
+
`include_unsubscribe_link:` keywords. Both are omitted from the payload when nil,
|
|
32
|
+
so direct callers of `send_email` are unaffected.
|
|
33
|
+
|
|
34
|
+
**Upgrade note.** ActionMailer deliveries change shape, not just API surface.
|
|
35
|
+
Mail that previously went out flagged as plain text is now flagged as HTML, and
|
|
36
|
+
mail that previously carried `List-Unsubscribe` no longer does. Both are the
|
|
37
|
+
point of the fix, but an app that relied on the unsubscribe footer appearing on
|
|
38
|
+
ActionMailer sends should set `include_unsubscribe_link: true` in
|
|
39
|
+
`broadcast_settings` to keep it.
|
|
40
|
+
|
|
41
|
+
### Documentation
|
|
42
|
+
|
|
43
|
+
The entries below are documentation only; the behaviour changes in this release
|
|
44
|
+
are the ActionMailer fixes above.
|
|
45
|
+
|
|
46
|
+
- **Autopilot is documented in the README.** 0.3.0 shipped the `Autopilots`
|
|
47
|
+
resource with ten endpoints and no README section, so the only user-facing
|
|
48
|
+
description of it was the source. Adds lifecycle, runs, the write-only
|
|
49
|
+
`openrouter_api_key` guard, and the `autopilot_read` / `autopilot_write` row
|
|
50
|
+
in the permissions table.
|
|
51
|
+
- **`SDK-COVERAGE.md` contradicted itself on Autopilot** — listed at 10/10 in
|
|
52
|
+
the endpoint map and simultaneously under "Not in the API at all" with "zero
|
|
53
|
+
autopilot routes". The latter was true when written and is now removed; the
|
|
54
|
+
section explains what closed it, and records that autopilot sources and tone
|
|
55
|
+
samples remain web-only.
|
|
56
|
+
- Corrected the API version header (2.19.0 → **2.20.0**) and the spec size in
|
|
57
|
+
`SDK-TODO.md` (68 paths / 94 operations → **75 / 104**).
|
|
58
|
+
- Marked `openapi:check` in CI as done — it has been running in the self-hosted
|
|
59
|
+
matrix leg but was still listed as pending.
|
|
60
|
+
- Documented how a **demo instance** answers: the migration API returns 403 for
|
|
61
|
+
every request including valid tokens, and transactional sends are accepted
|
|
62
|
+
but never delivered.
|
|
63
|
+
|
|
5
64
|
## [0.3.0] - 2026-07-26
|
|
6
65
|
|
|
7
66
|
Catches the gem up to the Broadcast v2.19 API. The API gained a response-warnings
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
broadcast-ruby (0.
|
|
4
|
+
broadcast-ruby (0.4.0)
|
|
5
5
|
base64
|
|
6
6
|
|
|
7
7
|
GEM
|
|
@@ -177,7 +177,7 @@ CHECKSUMS
|
|
|
177
177
|
ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
|
|
178
178
|
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
|
|
179
179
|
bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7
|
|
180
|
-
broadcast-ruby (0.
|
|
180
|
+
broadcast-ruby (0.4.0)
|
|
181
181
|
builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f
|
|
182
182
|
concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab
|
|
183
183
|
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
|
data/README.md
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# broadcast-ruby
|
|
2
2
|
|
|
3
|
-
Ruby client for
|
|
3
|
+
Official Ruby client for [Broadcast](https://sendbroadcast.net), the self-hosted email marketing platform.
|
|
4
4
|
|
|
5
|
-
Works with any Broadcast instance — self-hosted or SaaS.
|
|
5
|
+
Works with any Broadcast instance — self-hosted or SaaS. Covers **104/104 API operations**, verified against the API's generated OpenAPI document.
|
|
6
|
+
|
|
7
|
+
📖 **[Ruby SDK documentation](https://sendbroadcast.net/docs/ruby-sdk)** · [API reference](https://sendbroadcast.net/docs/api-authentication) · [All docs](https://sendbroadcast.net/docs)
|
|
8
|
+
|
|
9
|
+
Also available: [PHP](https://github.com/send-broadcast/broadcast-php) · [Node/TypeScript](https://github.com/send-broadcast/broadcast-node) · [Python](https://github.com/send-broadcast/broadcast-python)
|
|
6
10
|
|
|
7
11
|
## Installation
|
|
8
12
|
|
|
@@ -60,7 +64,7 @@ export BROADCAST_API_TOKEN=your-token
|
|
|
60
64
|
client = Broadcast::Client.new # picks both up from ENV
|
|
61
65
|
```
|
|
62
66
|
|
|
63
|
-
|
|
67
|
+
## Configuration
|
|
64
68
|
|
|
65
69
|
| Option | Default | Description |
|
|
66
70
|
|--------|---------|-------------|
|
|
@@ -71,21 +75,37 @@ client = Broadcast::Client.new # picks both up from ENV
|
|
|
71
75
|
| `retry_attempts` | `3` | Max total attempts (1 initial + 2 retries). Server errors (5xx), timeouts, and rate limits (429) are retried; other client errors (4xx) are not |
|
|
72
76
|
| `retry_delay` | `1` | Base delay between retries in seconds (multiplied by attempt number) |
|
|
73
77
|
| `max_retry_delay` | `30` | Ceiling for a server-supplied `Retry-After`, so a long rate-limit window can't stall the caller |
|
|
74
|
-
| `warnings_mode` | `:log` | How to handle API warnings — `:log`, `:raise`, or `:ignore`. See [
|
|
78
|
+
| `warnings_mode` | `:log` | How to handle API warnings — `:log`, `:raise`, or `:ignore`. See [Warnings](#warnings) |
|
|
75
79
|
| `debug` | `false` | Log request/response details (credentials are redacted) |
|
|
76
80
|
| `logger` | `nil` | Logger instance for debug output (e.g. `Rails.logger`) |
|
|
77
81
|
| `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) { ... }` |
|
|
78
82
|
|
|
79
|
-
All methods return parsed JSON as Ruby Hashes with string keys. The returned
|
|
80
|
-
object is a `Broadcast::Response` — a Hash subclass that also carries response
|
|
81
|
-
metadata (`#warnings`, `#rate_limit`, `#status`, `#idempotent_replay?`).
|
|
82
|
-
Anything that worked against a plain Hash still works.
|
|
83
|
-
|
|
84
83
|
> **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.
|
|
85
84
|
|
|
86
85
|
---
|
|
87
86
|
|
|
88
|
-
##
|
|
87
|
+
## Responses
|
|
88
|
+
|
|
89
|
+
Every call returns parsed JSON as a Ruby Hash with string keys, so `result['id']`
|
|
90
|
+
works as you would expect. The returned object is a `Broadcast::Response` — a Hash
|
|
91
|
+
subclass that also carries the transport metadata:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
result = client.subscribers.create(email: 'ada@example.com')
|
|
95
|
+
|
|
96
|
+
result['id'] # the response body
|
|
97
|
+
result.status # 201
|
|
98
|
+
result.warnings # parsed warnings, if any
|
|
99
|
+
result.rate_limit&.remaining # requests left in the window
|
|
100
|
+
result.idempotent_replay? # true if the API replayed a stored response
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Anything that worked against a plain Hash still works — `dig`, `is_a?(Hash)`,
|
|
104
|
+
equality against a literal Hash, splatting.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Warnings
|
|
89
109
|
|
|
90
110
|
Broadcast accepts a write and then tells you what it ignored. A misspelled
|
|
91
111
|
attribute, a parameter that only applies in another mode, a value the server
|
|
@@ -942,7 +962,38 @@ admin_client.email_servers.copy_to_channel(99, target_channel_id: 7)
|
|
|
942
962
|
|
|
943
963
|
---
|
|
944
964
|
|
|
945
|
-
##
|
|
965
|
+
## Suppressions
|
|
966
|
+
|
|
967
|
+
A suppressed address is one Broadcast will not email. Each channel has its own list, and the installation has a global one; `client.suppressions` manages the current channel's list:
|
|
968
|
+
|
|
969
|
+
```ruby
|
|
970
|
+
client.suppressions.check('ada@example.com') # will this address receive mail?
|
|
971
|
+
client.suppressions.list(page: 1, email: 'example.com')
|
|
972
|
+
client.suppressions.add('bounced@example.com')
|
|
973
|
+
client.suppressions.remove('bounced@example.com')
|
|
974
|
+
client.suppressions.bulk_add(['a@example.com', 'b@example.com']) # up to 10,000
|
|
975
|
+
client.suppressions.bulk_remove(['a@example.com'])
|
|
976
|
+
```
|
|
977
|
+
|
|
978
|
+
`check` reads across both the channel's list and the global list, so it answers the question integrations actually ask: will this address receive mail? The response's `scope` says which list matched. Adding an already-suppressed address is a success (200 rather than 201), so there is no need to check first.
|
|
979
|
+
|
|
980
|
+
Bulk adds are idempotent -- a retried batch cannot duplicate -- and return `added`, `already_suppressed`, and `invalid` counts. `remove` returns `removed: false` (not an error) when the address was not on the list, and never touches the global list.
|
|
981
|
+
|
|
982
|
+
The global list is a separate resource and **requires an admin/system token**:
|
|
983
|
+
|
|
984
|
+
```ruby
|
|
985
|
+
client.global_suppressions.list
|
|
986
|
+
client.global_suppressions.add('spamtrap@example.com')
|
|
987
|
+
client.global_suppressions.remove('spamtrap@example.com')
|
|
988
|
+
client.global_suppressions.bulk_add([...])
|
|
989
|
+
client.global_suppressions.bulk_remove([...])
|
|
990
|
+
```
|
|
991
|
+
|
|
992
|
+
Removing an address globally does not unblock it in channels that suppressed it on their own account. There is no `check` on the global resource: checking is a per-channel question, so it lives on `client.suppressions`.
|
|
993
|
+
|
|
994
|
+
---
|
|
995
|
+
|
|
996
|
+
## Channel Scoping
|
|
946
997
|
|
|
947
998
|
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.
|
|
948
999
|
|
|
@@ -976,7 +1027,7 @@ end
|
|
|
976
1027
|
|
|
977
1028
|
---
|
|
978
1029
|
|
|
979
|
-
##
|
|
1030
|
+
## Webhooks
|
|
980
1031
|
|
|
981
1032
|
Receive real-time notifications when events occur (email delivered, subscriber created, sequence completed, etc.).
|
|
982
1033
|
|
|
@@ -1079,6 +1130,91 @@ The signature is computed as `HMAC-SHA256(timestamp + "." + payload, secret)`. T
|
|
|
1079
1130
|
|
|
1080
1131
|
---
|
|
1081
1132
|
|
|
1133
|
+
## Autopilot
|
|
1134
|
+
|
|
1135
|
+
AI-generated newsletters. An autopilot reads your configured sources on a
|
|
1136
|
+
schedule, drafts copy in the tone you describe, and produces broadcast drafts
|
|
1137
|
+
for review. Requires `autopilot_read` / `autopilot_write`.
|
|
1138
|
+
|
|
1139
|
+
```ruby
|
|
1140
|
+
client.autopilots.list
|
|
1141
|
+
client.autopilots.get_autopilot(id)
|
|
1142
|
+
|
|
1143
|
+
autopilot = client.autopilots.create(
|
|
1144
|
+
name: 'Weekly Roundup',
|
|
1145
|
+
openrouter_api_key: ENV['OPENROUTER_API_KEY'],
|
|
1146
|
+
ai_model: 'openai/gpt-4o',
|
|
1147
|
+
schedule_frequency: 'weekly',
|
|
1148
|
+
schedule_day_of_week: 1,
|
|
1149
|
+
schedule_time: '09:00',
|
|
1150
|
+
schedule_timezone: 'America/New_York',
|
|
1151
|
+
copies_to_generate: 3,
|
|
1152
|
+
tone_description: 'Direct and technical. No hype.',
|
|
1153
|
+
content_instructions: 'Lead with the most consequential change.',
|
|
1154
|
+
segment_ids: [ 12 ]
|
|
1155
|
+
)
|
|
1156
|
+
|
|
1157
|
+
client.autopilots.update(autopilot['id'], copies_to_generate: 5)
|
|
1158
|
+
client.autopilots.delete(autopilot['id'])
|
|
1159
|
+
```
|
|
1160
|
+
|
|
1161
|
+
### Lifecycle
|
|
1162
|
+
|
|
1163
|
+
```ruby
|
|
1164
|
+
client.autopilots.activate(id) # start running on schedule
|
|
1165
|
+
client.autopilots.pause(id) # keep config, stop generating
|
|
1166
|
+
client.autopilots.deactivate(id)
|
|
1167
|
+
```
|
|
1168
|
+
|
|
1169
|
+
`activate` requires **at least one active source, an API key, and a model**. If
|
|
1170
|
+
any is missing it raises `Broadcast::ValidationError` naming the prerequisites:
|
|
1171
|
+
|
|
1172
|
+
```ruby
|
|
1173
|
+
begin
|
|
1174
|
+
client.autopilots.activate(id)
|
|
1175
|
+
rescue Broadcast::ValidationError => e
|
|
1176
|
+
# All missing prerequisites, comma-joined:
|
|
1177
|
+
e.message # => "At least one active source is required, AI model is required"
|
|
1178
|
+
end
|
|
1179
|
+
```
|
|
1180
|
+
|
|
1181
|
+
Sources and tone samples have **no API endpoints yet** — they are configured in
|
|
1182
|
+
the web UI. Since `activate` needs an active source, a brand-new autopilot
|
|
1183
|
+
created over the API cannot be activated until a source is added there.
|
|
1184
|
+
|
|
1185
|
+
### Runs
|
|
1186
|
+
|
|
1187
|
+
`trigger_run` queues generation immediately and returns `202` — the work is
|
|
1188
|
+
asynchronous, so poll rather than expecting finished copy back:
|
|
1189
|
+
|
|
1190
|
+
```ruby
|
|
1191
|
+
run = client.autopilots.trigger_run(id)
|
|
1192
|
+
run['status'] # => "pending"
|
|
1193
|
+
|
|
1194
|
+
client.autopilots.runs(id, limit: 10) # most recent first
|
|
1195
|
+
```
|
|
1196
|
+
|
|
1197
|
+
### API key handling
|
|
1198
|
+
|
|
1199
|
+
`openrouter_api_key` is write-only. It is encrypted at rest and never returned —
|
|
1200
|
+
reads expose only `api_key_configured`. The API renders a configured key
|
|
1201
|
+
bullet-masked, and writing that mask back would replace a working credential
|
|
1202
|
+
with bullets, so `update` strips it and warns:
|
|
1203
|
+
|
|
1204
|
+
```ruby
|
|
1205
|
+
current = client.autopilots.get_autopilot(id)
|
|
1206
|
+
current['api_key_configured'] # => true
|
|
1207
|
+
current['openrouter_api_key'] # => nil — never returned
|
|
1208
|
+
|
|
1209
|
+
# Safe: the masked value is dropped, the stored key survives
|
|
1210
|
+
client.autopilots.update(id, openrouter_api_key: '••••••••', ai_model: 'openai/gpt-4o')
|
|
1211
|
+
```
|
|
1212
|
+
|
|
1213
|
+
Pass the real key to rotate it, or omit the field entirely. This is the same
|
|
1214
|
+
guard as [Email Servers](#credential-redaction).
|
|
1215
|
+
|
|
1216
|
+
---
|
|
1217
|
+
|
|
1082
1218
|
## Discovery
|
|
1083
1219
|
|
|
1084
1220
|
Ask the instance what this token can do and whether the channel is ready to
|
|
@@ -1114,7 +1250,7 @@ raise 'channel not ready to send' unless client.status.dig('readiness', 'broadca
|
|
|
1114
1250
|
|
|
1115
1251
|
---
|
|
1116
1252
|
|
|
1117
|
-
## Export & Migration
|
|
1253
|
+
## Export & Migration
|
|
1118
1254
|
|
|
1119
1255
|
Read-only endpoints under `/api/migration/v1` for backups and moving a channel
|
|
1120
1256
|
between instances. Two constraints differ from the rest of the API:
|
|
@@ -1179,7 +1315,7 @@ File.binwrite('logo.png', bytes)
|
|
|
1179
1315
|
|
|
1180
1316
|
---
|
|
1181
1317
|
|
|
1182
|
-
##
|
|
1318
|
+
## Errors
|
|
1183
1319
|
|
|
1184
1320
|
All API errors inherit from `Broadcast::Error`. Put specific errors before general ones:
|
|
1185
1321
|
|
|
@@ -1225,6 +1361,8 @@ Each token can be scoped to specific resources. The ActionMailer delivery method
|
|
|
1225
1361
|
| Opt-In Forms | `opt_in_forms_read` -- list, get, analytics | `opt_in_forms_write` -- create, update, delete, create_variant, duplicate |
|
|
1226
1362
|
| Email Servers | `email_servers_read` -- list, get | `email_servers_write` -- create, update, delete, test_connection, copy_to_channel (admin) |
|
|
1227
1363
|
| Webhook Endpoints | `webhook_endpoints_read` -- list, get, deliveries | `webhook_endpoints_write` -- create, update, delete, test |
|
|
1364
|
+
| Autopilot | `autopilot_read` -- list, get, runs | `autopilot_write` -- create, update, delete, activate, pause, deactivate, trigger_run |
|
|
1365
|
+
| Suppressions | `suppressions_read` -- list, check | `suppressions_write` -- add, remove, bulk add/remove |
|
|
1228
1366
|
|
|
1229
1367
|
---
|
|
1230
1368
|
|
|
@@ -1268,6 +1406,25 @@ Each token can be scoped to specific resources. The ActionMailer delivery method
|
|
|
1268
1406
|
- **Check credentials:** Run `bin/rails credentials:show` and verify `broadcast.api_token` is set.
|
|
1269
1407
|
- **Check logs:** Set `debug: true` in `broadcast_settings` to see request/response details.
|
|
1270
1408
|
|
|
1409
|
+
## Documentation
|
|
1410
|
+
|
|
1411
|
+
- **[Ruby SDK guide](https://sendbroadcast.net/docs/ruby-sdk)** — the same material as this README, on the docs site
|
|
1412
|
+
- **[API reference](https://sendbroadcast.net/docs/api-authentication)** — endpoints, parameters, and permissions
|
|
1413
|
+
- **[API response warnings](https://sendbroadcast.net/docs/api-response-warnings)** — why a 2xx can still tell you something went wrong
|
|
1414
|
+
- **[Webhook endpoints](https://sendbroadcast.net/docs/api-webhook-endpoints)** — signature format and event types
|
|
1415
|
+
- **[Agents CLI](https://sendbroadcast.net/docs/agents-cli)** — the same credentials, from a terminal
|
|
1416
|
+
|
|
1417
|
+
### Other SDKs
|
|
1418
|
+
|
|
1419
|
+
| Language | Package | Repository |
|
|
1420
|
+
|---|---|---|
|
|
1421
|
+
| Ruby | [broadcast-ruby](https://rubygems.org/gems/broadcast-ruby) | this repository |
|
|
1422
|
+
| PHP | broadcast/broadcast-php | [broadcast-php](https://github.com/send-broadcast/broadcast-php) |
|
|
1423
|
+
| Node / TypeScript | @send-broadcast/sdk | [broadcast-node](https://github.com/send-broadcast/broadcast-node) |
|
|
1424
|
+
| Python | broadcast-python | [broadcast-python](https://github.com/send-broadcast/broadcast-python) |
|
|
1425
|
+
|
|
1426
|
+
All four cover the same 115 operations and behave the same way on the wire — the transport contract (warnings, idempotency, rate-limit handling, redirect safety, credential redaction) is identical across languages.
|
|
1427
|
+
|
|
1271
1428
|
## License
|
|
1272
1429
|
|
|
1273
1430
|
MIT License. See [LICENSE.txt](LICENSE.txt).
|
data/SDK-COVERAGE.md
CHANGED
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
What this gem covers, what it deliberately doesn't, and what the Broadcast
|
|
4
4
|
application can do that no client can reach.
|
|
5
5
|
|
|
6
|
+
> **This file describes the Ruby gem specifically.** Since 2026-07-27 there are
|
|
7
|
+
> three sibling SDKs — `broadcast-node`, `broadcast-python`, `broadcast-php` —
|
|
8
|
+
> all at 104/104 against the same spec. The endpoint map below applies to all
|
|
9
|
+
> of them (the paths are identical); only the method names differ. Per-language
|
|
10
|
+
> status, including the caveat that PHP's suite has never been run, is in
|
|
11
|
+
> [SDK-TODO.md](SDK-TODO.md#status--2026-07-27).
|
|
12
|
+
|
|
6
13
|
**Headline: 104/104 API operations (100%).**
|
|
7
14
|
|
|
8
15
|
That number is narrower than it sounds, and [Limits of the 100%](#limits-of-the-100)
|
|
@@ -10,7 +17,7 @@ explains exactly what it does not measure. Read that section before quoting the
|
|
|
10
17
|
figure.
|
|
11
18
|
|
|
12
19
|
- Gem version: **0.3.0**
|
|
13
|
-
- API version: **2.
|
|
20
|
+
- API version: **2.20.0** (75 paths, 104 operations)
|
|
14
21
|
- Generated by: `bin/rails "openapi:coverage[../broadcast-ruby]"` in the
|
|
15
22
|
`broadcast` repo
|
|
16
23
|
- Verified: self-hosted and SaaS mode (the `/api` surface is identical in both)
|
|
@@ -24,7 +31,8 @@ figure.
|
|
|
24
31
|
3. [Transport coverage](#transport-coverage) — the protocol, not the endpoints
|
|
25
32
|
4. [Webhook coverage](#webhook-coverage) — inbound
|
|
26
33
|
5. [Not in the API at all](#not-in-the-api-at-all) — app features no client can reach
|
|
27
|
-
6. [
|
|
34
|
+
6. [Demo instances answer differently](#demo-instances-answer-differently)
|
|
35
|
+
7. [Limits of the 100%](#limits-of-the-100)
|
|
28
36
|
|
|
29
37
|
---
|
|
30
38
|
|
|
@@ -339,7 +347,6 @@ this is an API gap, not a gem gap, and nothing here is actionable in this repo.
|
|
|
339
347
|
|
|
340
348
|
| Feature | Where it lives | Notes |
|
|
341
349
|
|---|---|---|
|
|
342
|
-
| **Autopilot** (AI newsletter generation) | Web UI | Sources, tone samples, runs, scheduling. See below — this one is different. |
|
|
343
350
|
| **Ask AI** | Web UI | Conversations, messages, tool-call approval |
|
|
344
351
|
| **Subscriber imports** | Web UI | CSV/TSV upload, mapping, processing |
|
|
345
352
|
| **CSV exports** | Web UI | Subscribers, broadcasts, activity. `GET /subscribers/export` is a web route |
|
|
@@ -353,17 +360,44 @@ this is an API gap, not a gem gap, and nothing here is actionable in this repo.
|
|
|
353
360
|
| **Admin / platform** | Web UI | Admin API tokens, SaaS platform admin |
|
|
354
361
|
| **Confirmation templates & pages** | Partly API | Configurable as template attributes; no dedicated endpoints |
|
|
355
362
|
|
|
356
|
-
### Autopilot
|
|
363
|
+
### Autopilot: this section used to be wrong, and how
|
|
364
|
+
|
|
365
|
+
Autopilot was listed here as web-UI-only. The reasoning was that `Token`
|
|
366
|
+
defined `permission_to_autopilot_read` / `_write` while no `api/v1/autopilots`
|
|
367
|
+
controller existed — inert flags. That was true when written, and the entry
|
|
368
|
+
predicted it would be the first thing to go stale. It was.
|
|
369
|
+
|
|
370
|
+
Autopilot **is** in the API as of app v2.20.0 and is covered
|
|
371
|
+
[above](#autopilot--1010). The build also closed the permission gap the flags
|
|
372
|
+
had exposed: `user_broadcast_channel_permissions` had no autopilot columns, so
|
|
373
|
+
the feature was silently sudo-only in the UI and its policy scopes raised
|
|
374
|
+
`NoMethodError` for any user holding a channel-permission record. Migration
|
|
375
|
+
`20260726180000` added them; `UserManagementPolicy` now derives assignable
|
|
376
|
+
attributes from the schema rather than a hand-written list.
|
|
377
|
+
|
|
378
|
+
Sub-resources are still web-only: autopilot **sources** and **tone samples**
|
|
379
|
+
have no v1 endpoints. `create`/`update` set the generator itself, and
|
|
380
|
+
`activate` requires an active source — so a source must exist before the API
|
|
381
|
+
can activate anything. That is the next gap in this resource.
|
|
382
|
+
|
|
383
|
+
---
|
|
384
|
+
|
|
385
|
+
## Demo instances answer differently
|
|
357
386
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
controller and zero autopilot routes under `/api`. The permission flags exist
|
|
361
|
-
with nothing behind them.
|
|
387
|
+
Nothing here is a gem concern, but a client pointed at a demo instance
|
|
388
|
+
(`DEMO_MODE` set) sees behaviour no error class distinguishes:
|
|
362
389
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
390
|
+
- **The migration API is off entirely.** Every `/api/migration/v1` route
|
|
391
|
+
returns `403` with `{"error": "The migration API is disabled in demo mode."}`,
|
|
392
|
+
raised *before* token verification — so a valid and an invalid admin token
|
|
393
|
+
get identical answers, deliberately, to stop the demo being used as a token
|
|
394
|
+
oracle. In the gem this surfaces as `Broadcast::AuthorizationError`.
|
|
395
|
+
- **Transactional sends are accepted but never delivered.** The request
|
|
396
|
+
succeeds and a record is created; the mailer returns early. `2xx` does not
|
|
397
|
+
mean an email left the building.
|
|
398
|
+
- **`/api/v1` itself is unguarded.** It honours any valid token — but tokens
|
|
399
|
+
cannot be minted in demo mode, so in practice the only usable credential is
|
|
400
|
+
the "Default transactional email" token each channel auto-provisions.
|
|
367
401
|
|
|
368
402
|
---
|
|
369
403
|
|
|
@@ -417,6 +451,19 @@ bin/rails openapi:generate
|
|
|
417
451
|
bin/rails "openapi:coverage[../broadcast-ruby]"
|
|
418
452
|
```
|
|
419
453
|
|
|
420
|
-
|
|
454
|
+
`openapi:check` now runs in CI, so a changed API surface fails the build rather
|
|
455
|
+
than silently ageing this file. Note what that guard does **not** cover: it
|
|
456
|
+
compares the spec to the router, not this document to the spec. Both stale
|
|
457
|
+
entries found in the 2.20.0 pass — the version header, and Autopilot appearing
|
|
458
|
+
as both covered and absent — would have survived a green CI run indefinitely.
|
|
459
|
+
|
|
460
|
+
So on every API bump, re-check the three hand-maintained claims by hand:
|
|
461
|
+
|
|
462
|
+
1. **The version header** at the top — it does not come from anywhere.
|
|
463
|
+
2. **[Not in the API at all](#not-in-the-api-at-all)** — a new endpoint means a
|
|
464
|
+
row leaves that table. This is where the contradiction hid.
|
|
465
|
+
3. **Prose in the endpoint map** — counts regenerate, the sentences under them
|
|
466
|
+
do not.
|
|
467
|
+
|
|
421
468
|
Cross-language plans live in [SDK-TODO.md](SDK-TODO.md); the v0.3.0 work log is
|
|
422
469
|
in [TODO.md](TODO.md).
|
data/lib/broadcast/client.rb
CHANGED
|
@@ -36,8 +36,23 @@ module Broadcast
|
|
|
36
36
|
# Thin convenience wrapper around `transactionals.create`. Use
|
|
37
37
|
# `client.transactionals.create` directly for template_id, double_opt_in,
|
|
38
38
|
# preheader, idempotency_key, and other advanced options.
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
# `html_body` tells Broadcast the body is already HTML. Without it the send
|
|
40
|
+
# is recorded as plain text and the payload is wrapped in Broadcast's own
|
|
41
|
+
# <html><body> shell, so an HTML mail arrives as two nested documents.
|
|
42
|
+
#
|
|
43
|
+
# `include_unsubscribe_link` lets the caller suppress the unsubscribe
|
|
44
|
+
# footer and List-Unsubscribe header. Transactional mail wants that off: a
|
|
45
|
+
# one-click unsubscribe on a password reset marks the person unsubscribed
|
|
46
|
+
# and silently drops them from every sequence and broadcast.
|
|
47
|
+
# rubocop:disable Metrics/ParameterLists -- mirrors the API's flat param surface
|
|
48
|
+
def send_email(to:, subject: nil, body: nil, reply_to: nil,
|
|
49
|
+
html_body: nil, include_unsubscribe_link: nil)
|
|
50
|
+
# rubocop:enable Metrics/ParameterLists
|
|
51
|
+
opts = { to: to, subject: subject, body: body, reply_to: reply_to }
|
|
52
|
+
opts[:html_body] = html_body unless html_body.nil?
|
|
53
|
+
opts[:include_unsubscribe_link] = include_unsubscribe_link unless include_unsubscribe_link.nil?
|
|
54
|
+
|
|
55
|
+
transactionals.create(**opts)
|
|
41
56
|
end
|
|
42
57
|
|
|
43
58
|
def get_email(id)
|
|
@@ -108,6 +123,18 @@ module Broadcast
|
|
|
108
123
|
@discovery ||= Resources::Discovery.new(self)
|
|
109
124
|
end
|
|
110
125
|
|
|
126
|
+
# The current channel's suppression list (plus `check`, which reads the
|
|
127
|
+
# global list too).
|
|
128
|
+
def suppressions
|
|
129
|
+
@suppressions ||= Resources::Suppressions.new(self)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The installation-wide suppression list. Requires an admin (system) API
|
|
133
|
+
# token.
|
|
134
|
+
def global_suppressions
|
|
135
|
+
@global_suppressions ||= Resources::GlobalSuppressions.new(self)
|
|
136
|
+
end
|
|
137
|
+
|
|
111
138
|
# Read-only export endpoints under /api/migration/v1. Requires an admin
|
|
112
139
|
# (system) API token.
|
|
113
140
|
def migration
|
|
@@ -2,8 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
module Broadcast
|
|
4
4
|
class DeliveryMethod
|
|
5
|
+
# ActionMailer delivers transactional mail, so the unsubscribe link is off
|
|
6
|
+
# unless the host app opts back in via broadcast_settings. The option is
|
|
7
|
+
# consumed here rather than forwarded: Configuration would reject it.
|
|
8
|
+
DEFAULT_INCLUDE_UNSUBSCRIBE_LINK = false
|
|
9
|
+
|
|
5
10
|
def initialize(settings = {})
|
|
6
|
-
|
|
11
|
+
opts = settings.to_h.dup
|
|
12
|
+
@include_unsubscribe_link =
|
|
13
|
+
if opts.key?(:include_unsubscribe_link)
|
|
14
|
+
opts.delete(:include_unsubscribe_link)
|
|
15
|
+
else
|
|
16
|
+
DEFAULT_INCLUDE_UNSUBSCRIBE_LINK
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
@client = Client.new(**opts)
|
|
7
20
|
end
|
|
8
21
|
|
|
9
22
|
def deliver!(mail)
|
|
@@ -11,7 +24,9 @@ module Broadcast
|
|
|
11
24
|
to: mail.to&.first,
|
|
12
25
|
subject: mail.subject,
|
|
13
26
|
body: extract_body(mail),
|
|
14
|
-
reply_to: mail.reply_to&.first
|
|
27
|
+
reply_to: mail.reply_to&.first,
|
|
28
|
+
html_body: (true if mail.html_part),
|
|
29
|
+
include_unsubscribe_link: @include_unsubscribe_link
|
|
15
30
|
)
|
|
16
31
|
rescue Broadcast::WarningError
|
|
17
32
|
# The send succeeded — warnings_mode: :raise is about surfacing ignored
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Broadcast
|
|
4
|
+
module Resources
|
|
5
|
+
# The installation-wide suppression list. Addresses on it never receive
|
|
6
|
+
# mail from any channel. All operations require an admin (system) API
|
|
7
|
+
# token — a channel token gets a 401.
|
|
8
|
+
#
|
|
9
|
+
# There is deliberately no `check` here: checking is a per-channel
|
|
10
|
+
# question (it reads the channel list too), so it lives on Suppressions.
|
|
11
|
+
class GlobalSuppressions < Base
|
|
12
|
+
# List global suppressions (250 per page, with `pagination` metadata;
|
|
13
|
+
# pass `page:`). Optional `email:` filters by partial match.
|
|
14
|
+
def list(**params)
|
|
15
|
+
get('/api/v1/global_suppressions.json', params)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Add an address to the global list. Already-suppressed is a success
|
|
19
|
+
# (200 instead of 201).
|
|
20
|
+
def add(email)
|
|
21
|
+
post('/api/v1/global_suppressions.json', { email: email })
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Remove an address from the global list only. Channels that suppressed
|
|
25
|
+
# the same address on their own account keep their block.
|
|
26
|
+
def remove(email)
|
|
27
|
+
@client.request(:delete, '/api/v1/global_suppressions.json', { email: email })
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Add up to 10,000 addresses at once. Idempotent. Returns `added`,
|
|
31
|
+
# `already_suppressed`, and `invalid` counts.
|
|
32
|
+
def bulk_add(emails)
|
|
33
|
+
post('/api/v1/global_suppressions/bulk.json', { emails: emails })
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Remove up to 10,000 addresses at once. Returns `removed` and
|
|
37
|
+
# `not_found` counts.
|
|
38
|
+
def bulk_remove(emails)
|
|
39
|
+
@client.request(:delete, '/api/v1/global_suppressions/bulk.json', { emails: emails })
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Broadcast
|
|
4
|
+
module Resources
|
|
5
|
+
# The current channel's suppression list. Addresses on it never receive
|
|
6
|
+
# broadcasts, sequences, or transactionals from this channel.
|
|
7
|
+
#
|
|
8
|
+
# The installation-wide list is a separate resource — see
|
|
9
|
+
# GlobalSuppressions — but `check` reads across both on purpose: it
|
|
10
|
+
# answers the question an integration actually asks, "will this address
|
|
11
|
+
# receive mail?".
|
|
12
|
+
class Suppressions < Base
|
|
13
|
+
# List the channel's suppressions (250 per page, with `pagination`
|
|
14
|
+
# metadata; pass `page:`). Optional `email:` filters by partial,
|
|
15
|
+
# case-insensitive match.
|
|
16
|
+
def list(**params)
|
|
17
|
+
get('/api/v1/suppressions.json', params)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Add an address to the channel's suppression list.
|
|
21
|
+
#
|
|
22
|
+
# Adding an address that is already suppressed is a success (the server
|
|
23
|
+
# answers 200 instead of 201), so callers do not have to check first.
|
|
24
|
+
def add(email)
|
|
25
|
+
post('/api/v1/suppressions.json', { email: email })
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Remove an address from the channel's suppression list. Returns
|
|
29
|
+
# `removed: false` (not an error) when the address was not on it.
|
|
30
|
+
# Does not touch the global list.
|
|
31
|
+
def remove(email)
|
|
32
|
+
@client.request(:delete, '/api/v1/suppressions.json', { email: email })
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Add up to 10,000 addresses at once. Idempotent: a retried batch cannot
|
|
36
|
+
# duplicate. Returns `added`, `already_suppressed`, and `invalid` counts.
|
|
37
|
+
def bulk_add(emails)
|
|
38
|
+
post('/api/v1/suppressions/bulk.json', { emails: emails })
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Remove up to 10,000 addresses at once. Returns `removed` and
|
|
42
|
+
# `not_found` counts.
|
|
43
|
+
def bulk_remove(emails)
|
|
44
|
+
@client.request(:delete, '/api/v1/suppressions/bulk.json', { emails: emails })
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Will this address receive mail? Reads across both the global and the
|
|
48
|
+
# channel list — a globally blocked address reports `suppressed: true`
|
|
49
|
+
# here even though it is absent from the channel's own list. The
|
|
50
|
+
# response's `scope` says which list matched.
|
|
51
|
+
def check(email)
|
|
52
|
+
get('/api/v1/suppressions/check.json', { email: email })
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
data/lib/broadcast/version.rb
CHANGED
data/lib/broadcast.rb
CHANGED
|
@@ -20,6 +20,8 @@ require_relative 'broadcast/resources/opt_in_forms'
|
|
|
20
20
|
require_relative 'broadcast/resources/email_servers'
|
|
21
21
|
require_relative 'broadcast/resources/autopilots'
|
|
22
22
|
require_relative 'broadcast/resources/discovery'
|
|
23
|
+
require_relative 'broadcast/resources/suppressions'
|
|
24
|
+
require_relative 'broadcast/resources/global_suppressions'
|
|
23
25
|
require_relative 'broadcast/resources/migration'
|
|
24
26
|
|
|
25
27
|
# ActionMailer integration — only loaded when Rails is present
|
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.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Simon Chiu
|
|
@@ -52,11 +52,13 @@ files:
|
|
|
52
52
|
- lib/broadcast/resources/broadcasts.rb
|
|
53
53
|
- lib/broadcast/resources/discovery.rb
|
|
54
54
|
- lib/broadcast/resources/email_servers.rb
|
|
55
|
+
- lib/broadcast/resources/global_suppressions.rb
|
|
55
56
|
- lib/broadcast/resources/migration.rb
|
|
56
57
|
- lib/broadcast/resources/opt_in_forms.rb
|
|
57
58
|
- lib/broadcast/resources/segments.rb
|
|
58
59
|
- lib/broadcast/resources/sequences.rb
|
|
59
60
|
- lib/broadcast/resources/subscribers.rb
|
|
61
|
+
- lib/broadcast/resources/suppressions.rb
|
|
60
62
|
- lib/broadcast/resources/templates.rb
|
|
61
63
|
- lib/broadcast/resources/transactionals.rb
|
|
62
64
|
- lib/broadcast/resources/webhook_endpoints.rb
|
|
@@ -86,7 +88,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
86
88
|
- !ruby/object:Gem::Version
|
|
87
89
|
version: '0'
|
|
88
90
|
requirements: []
|
|
89
|
-
rubygems_version: 4.0.
|
|
91
|
+
rubygems_version: 4.0.17
|
|
90
92
|
specification_version: 4
|
|
91
93
|
summary: Ruby client for the Broadcast email platform
|
|
92
94
|
test_files: []
|