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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fec10ff364f7b63e728e1da143f2e0ff4445e2466f8a9e1a307370142d4c8666
4
- data.tar.gz: efce695852b7774e6e40f1f91a93ee2487e8d635a579ab0fe20505a9fe4a9efe
3
+ metadata.gz: 7866007085ed5d19620669069bdfeb9e7babaad5d08b2198d76e19620ce68bde
4
+ data.tar.gz: '082d0663d0a5a2501f445f0f7152e6b6a56467d1e0a61d0ae96e45b3137380d0'
5
5
  SHA512:
6
- metadata.gz: b72667f6f90ecb99ec60d0d9c905f529fbf0f78e30386e78ca93aeb1ac783e2a923750b76a41b73f2da771be67b3bfca012749066614a2ddb99c6cebb093924c
7
- data.tar.gz: ff0b6ba2de7606b7dbc1fab2c552f6947158cd69ad28ae328d3b23f1d52b8878700802a08d108841e7453b5495398258c70e2e287bbc5ac76e1a908baedab1c1
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.3.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.3.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 the [Broadcast](https://sendbroadcast.net) email platform.
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
- ### Options
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 [API Warnings](#api-warnings) |
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
- ## API Warnings
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
- ## Channel Scoping (Admin/System Tokens)
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
- ## Webhook Endpoints
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 (admin tokens only)
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
- ## Error Handling
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.19.0** (75 paths, 104 operations)
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. [Limits of the 100%](#limits-of-the-100)
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 is worth a separate note
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
- The `Token` model already defines **`permission_to_autopilot_read`** and
359
- **`permission_to_autopilot_write`** but there is no `api/v1/autopilots`
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
- So an operator can grant an API token "Autopilot" access today and that grant
364
- does nothing. That is worth either building the endpoints or hiding the
365
- permission and it is the most likely next addition to the API surface, which
366
- makes it the first thing to check when this document goes stale.
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
- If `openapi:check` fails in CI, the API changed and this document is stale.
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).
@@ -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
- def send_email(to:, subject: nil, body: nil, reply_to: nil)
40
- transactionals.create(to: to, subject: subject, body: body, reply_to: reply_to)
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
- @client = Client.new(**settings)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Broadcast
4
- VERSION = '0.3.0'
4
+ VERSION = '0.4.0'
5
5
  end
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.3.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.16
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: []