broadcast-ruby 0.7.0 → 0.9.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 +48 -0
- data/Gemfile.lock +2 -2
- data/README.md +105 -6
- data/SDK-COVERAGE.md +1 -1
- data/lib/broadcast/client.rb +9 -2
- data/lib/broadcast/connection.rb +11 -3
- data/lib/broadcast/debug_logger.rb +9 -0
- data/lib/broadcast/multipart.rb +51 -0
- data/lib/broadcast/resources/autopilots.rb +1 -0
- data/lib/broadcast/resources/channel.rb +39 -3
- data/lib/broadcast/resources/file_assets.rb +107 -0
- data/lib/broadcast/resources/opt_in_forms.rb +10 -0
- data/lib/broadcast/resources/subscribers.rb +7 -1
- data/lib/broadcast/version.rb +1 -1
- data/lib/broadcast/webhook.rb +6 -3
- data/lib/broadcast.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 46cb94477a871cda3ac8f5a69d2d947b2ec87b5dd4454a1ec8d179cec7f25b9a
|
|
4
|
+
data.tar.gz: 38f72f3aecd7376ed6f072e831049614ae5f329c8e92cb0e1aeb3d26648f8829
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e46bf11d5142f63a5bde2fc5d6d0c34c5d352fc05e428e1c3bc05931e346c9cff163f664d611f2ea88c1d5af819ed9d09b2fde518d5fe4fd6629ab650626bb4f
|
|
7
|
+
data.tar.gz: ca25863674f4560506aca95d62739ca8998a0671cfc52561b99b1dac26f6c519d6ff19c1781b121a4f3b72d6902b5e00360f30418d954aac3a34ad3fbbe83515
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,54 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.9.0] - 2026-10-16
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `file_assets` for the File Assets API (Broadcast 2.48.0): `list(filter:,
|
|
12
|
+
limit:, offset:)`, `get_file_asset(id)`, `upload(path_or_io, filename:,
|
|
13
|
+
content_type:, channel_restricted:, idempotency_key:)` (multipart),
|
|
14
|
+
`upload_base64(filename:, data:, ...)` (JSON) and `delete(id)`. The returned
|
|
15
|
+
`url` is what `channel.update_design(brand: { logo_url: })` takes. Needs the
|
|
16
|
+
new `file_assets` permissions.
|
|
17
|
+
- Multipart request support in the HTTP layer (`Broadcast::FilePart`). The
|
|
18
|
+
debug log shows a file part as its name and size, and cuts strings over
|
|
19
|
+
1,000 characters short, so an upload never writes the file into the log.
|
|
20
|
+
- `channel.settings` and `channel.update_settings(**groups)` for
|
|
21
|
+
`GET`/`PATCH /api/v1/channel/settings` (Broadcast 2.46.0): general, sender,
|
|
22
|
+
unsubscribe page (with the privacy policy and legal notice links),
|
|
23
|
+
confirmation page and opt-in forms (`opt_in_forms.match_brand_kit`) settings. Needs the new `channel_settings` permissions.
|
|
24
|
+
- `channel.update_design(**kit)` for `PATCH /api/v1/channel/design`
|
|
25
|
+
(Broadcast 2.46.0): change the brand kit. Needs `channel_settings_write`.
|
|
26
|
+
The logo is the `logo_url` of an image in the channel's file library.
|
|
27
|
+
|
|
28
|
+
## [0.8.0] - 2026-10-10
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- `email.send_delayed` and `broadcast.batch_completed` in
|
|
33
|
+
`Broadcast::Webhook::EVENT_TYPES` (and in `EMAIL_EVENTS` /
|
|
34
|
+
`BROADCAST_EVENTS`), matching the server (Broadcast 2.43.0).
|
|
35
|
+
`email.send_delayed` fires when a transactional email waits for room under
|
|
36
|
+
its server's hourly limit.
|
|
37
|
+
- `excluded_segment_ids` on broadcasts, sequences and autopilots (create,
|
|
38
|
+
update, and in the returned record): segments a send never reaches. The
|
|
39
|
+
server refuses a segment that is both sent to and excluded with 422.
|
|
40
|
+
- `subscribers.create(unsubscribed_at: ...)`: creates a subscriber who has
|
|
41
|
+
already unsubscribed, in one request, with any token. Before, the server
|
|
42
|
+
ignored the field and a migration had to create, then unsubscribe.
|
|
43
|
+
|
|
44
|
+
- `Broadcast::Resources::OptInForms::TRIGGER_FREQUENCIES`: the words
|
|
45
|
+
`trigger_settings[:frequency]` accepts (`always`, `every_visit`,
|
|
46
|
+
`once_per_session`, `once_per_day`, `once_per_week`, `once`).
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
|
|
50
|
+
- Opt-in forms: the server now refuses an unknown `trigger_settings`
|
|
51
|
+
frequency word with 422 (`Broadcast::ValidationError`). Before, it saved
|
|
52
|
+
any word, and an unknown one showed the popup on every visit. The client
|
|
53
|
+
still sends what it is given.
|
|
54
|
+
|
|
7
55
|
## [0.7.0] - 2026-10-06
|
|
8
56
|
|
|
9
57
|
### Added
|
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.9.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.9.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
|
@@ -734,6 +734,12 @@ client.topics.delete(topic['id']) # 422 while a broadcast or sequence uses it
|
|
|
734
734
|
# Send to a topic
|
|
735
735
|
client.broadcasts.create(subject: 'Webinar', body: '...', segment_ids: [2], topic_id: topic['id'])
|
|
736
736
|
|
|
737
|
+
# Send to everyone except a segment (also on sequences and autopilots)
|
|
738
|
+
client.broadcasts.create(subject: 'Offer', body: '...', excluded_segment_ids: [competitors['id']])
|
|
739
|
+
|
|
740
|
+
# Migrate someone who already unsubscribed, in one request
|
|
741
|
+
client.subscribers.create(email: 'opted-out@example.com', unsubscribed_at: '2025-03-14T09:30:00Z')
|
|
742
|
+
|
|
737
743
|
# Change only the keys you send (nil deletes a key); the default replaces custom_data
|
|
738
744
|
client.subscribers.update('jane@example.com', custom_data: { sub_webinars: false }, custom_data_mode: 'merge')
|
|
739
745
|
```
|
|
@@ -833,9 +839,10 @@ client.templates.delete(1)
|
|
|
833
839
|
|
|
834
840
|
### Brand Kit
|
|
835
841
|
|
|
836
|
-
Read the channel's brand kit (Settings →
|
|
837
|
-
match it.
|
|
838
|
-
|
|
842
|
+
Read the channel's brand kit (Settings → Brand Kit) so your own templates can
|
|
843
|
+
match it. Fully resolved (defaults filled in), and always the token's own
|
|
844
|
+
channel, so it takes no arguments. Needs `templates_read` (or, from Broadcast
|
|
845
|
+
2.46.0, `channel_settings_read`).
|
|
839
846
|
|
|
840
847
|
```ruby
|
|
841
848
|
design = client.channel.design
|
|
@@ -848,8 +855,93 @@ design['brand']['website_url'] # => 'https://acme.example'
|
|
|
848
855
|
design['brand']['social_links'] # => [{ 'network' => 'x', 'url' => 'https://x.com/acme' }]
|
|
849
856
|
```
|
|
850
857
|
|
|
858
|
+
Change it with `update_design` (Broadcast 2.46.0 or later, needs
|
|
859
|
+
`channel_settings_write`). Send only what changes, in the same shape. An
|
|
860
|
+
empty string or `nil` puts a value back to its default, and `social_links`
|
|
861
|
+
replaces the list. `font_stack` and `width` are read-only. The logo is the
|
|
862
|
+
`logo_url` of an image in the channel's file library; the server never
|
|
863
|
+
fetches a URL.
|
|
864
|
+
|
|
865
|
+
```ruby
|
|
866
|
+
kit = client.channel.update_design(
|
|
867
|
+
colors: { accent: '#ff5500' },
|
|
868
|
+
typography: { font: 'georgia' },
|
|
869
|
+
brand: { logo_url: 'https://your-broadcast.example/files/eyJ...-logo.png', logo_width: 200 }
|
|
870
|
+
)
|
|
871
|
+
kit['colors']['accent'] # => '#ff5500' (the whole kit comes back)
|
|
872
|
+
kit.warnings # => e.g. an unrecognized key you sent
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
An unknown font, icon style or social network, a logo from outside the file
|
|
876
|
+
library, or a value the Brand Kit page would refuse raises
|
|
877
|
+
`Broadcast::ValidationError` (422), and nothing is saved.
|
|
878
|
+
|
|
879
|
+
### Channel Settings
|
|
880
|
+
|
|
881
|
+
Read and change the channel's settings (Broadcast 2.46.0 or later), grouped
|
|
882
|
+
like the Settings pages: `general`, `sender`, `unsubscribe_page` (including
|
|
883
|
+
the privacy policy and legal notice links), `confirmation_page` and
|
|
884
|
+
`opt_in_forms` (`match_brand_kit`: whether new opt-in forms start from the
|
|
885
|
+
brand kit). Needs `channel_settings_read` to read and `channel_settings_write`
|
|
886
|
+
to change; both are off on every token until you tick them.
|
|
887
|
+
|
|
888
|
+
The `match_brand_kit` switches (`unsubscribe_page`, `confirmation_page`
|
|
889
|
+
and `opt_in_forms`) decide where the brand kit is used. They are set
|
|
890
|
+
here, not with the brand kit update.
|
|
891
|
+
|
|
892
|
+
```ruby
|
|
893
|
+
settings = client.channel.settings
|
|
894
|
+
settings['unsubscribe_page']['legal_notice_url'] # => 'https://example.com/impressum'
|
|
895
|
+
|
|
896
|
+
result = client.channel.update_settings(
|
|
897
|
+
unsubscribe_page: {
|
|
898
|
+
privacy_policy_url: 'https://example.com/privacy',
|
|
899
|
+
legal_notice_url: 'https://example.com/impressum'
|
|
900
|
+
}
|
|
901
|
+
)
|
|
902
|
+
result.warnings # => e.g. [#<Warning code="unrecognized_parameter" param="channel_settings.general.domain" ...>]
|
|
903
|
+
```
|
|
904
|
+
|
|
905
|
+
Updates are partial: nested text and colours merge, an empty string or `nil`
|
|
906
|
+
removes a value, and `languages` replaces the list. A value outside a fixed
|
|
907
|
+
set, or one that fails validation, raises `Broadcast::ValidationError` and
|
|
908
|
+
saves nothing.
|
|
909
|
+
|
|
851
910
|
---
|
|
852
911
|
|
|
912
|
+
## File Assets
|
|
913
|
+
|
|
914
|
+
The channel's file library (Broadcast 2.48.0 or later): PNG, JPEG and GIF
|
|
915
|
+
images under 10 MB, each with a public URL that never expires. Use the URL in
|
|
916
|
+
emails, or as the brand kit logo. Needs `file_assets_read` to list and get,
|
|
917
|
+
and `file_assets_write` to upload and delete.
|
|
918
|
+
|
|
919
|
+
```ruby
|
|
920
|
+
# Upload from a path (multipart). The content type follows the extension.
|
|
921
|
+
file = client.file_assets.upload('logo.png')
|
|
922
|
+
file['url'] # => 'https://your-broadcast.example/files/eyJ...-logo.png'
|
|
923
|
+
|
|
924
|
+
# From an IO, with a filename; link it to this channel only; safe to retry
|
|
925
|
+
client.file_assets.upload(io, filename: 'banner.gif', channel_restricted: true,
|
|
926
|
+
idempotency_key: "banner-#{campaign.id}")
|
|
927
|
+
|
|
928
|
+
# Already have base64? Send it as JSON.
|
|
929
|
+
client.file_assets.upload_base64(filename: 'logo.png', content_type: 'image/png',
|
|
930
|
+
data: Base64.strict_encode64(bytes))
|
|
931
|
+
|
|
932
|
+
# Use it as the brand kit logo
|
|
933
|
+
client.channel.update_design(brand: { logo_url: file['url'] })
|
|
934
|
+
|
|
935
|
+
client.file_assets.list(filter: 'logo', limit: 20) # => { 'data' => [...], 'total' => n }
|
|
936
|
+
client.file_assets.get_file_asset(file['id'])
|
|
937
|
+
client.file_assets.delete(file['id'])
|
|
938
|
+
```
|
|
939
|
+
|
|
940
|
+
A file that is not a PNG, JPEG or GIF, a file of 10 MB or more, or invalid
|
|
941
|
+
base64 raises `Broadcast::ValidationError` (422). So does deleting a file
|
|
942
|
+
that is a channel's brand kit logo: set another logo first. The server never
|
|
943
|
+
fetches a URL; upload the bytes.
|
|
944
|
+
|
|
853
945
|
## Opt-In Forms
|
|
854
946
|
|
|
855
947
|
Embeddable subscription forms with theming, A/B variants, and analytics.
|
|
@@ -889,6 +981,11 @@ result = client.opt_in_forms.create(
|
|
|
889
981
|
# Update (deeply nested settings hashes pass through verbatim)
|
|
890
982
|
client.opt_in_forms.update(1, enabled: false)
|
|
891
983
|
|
|
984
|
+
# How often the same visitor sees a popup. Any word outside
|
|
985
|
+
# Broadcast::Resources::OptInForms::TRIGGER_FREQUENCIES raises
|
|
986
|
+
# Broadcast::ValidationError (422).
|
|
987
|
+
client.opt_in_forms.update(1, trigger_settings: { frequency: 'once_per_day' })
|
|
988
|
+
|
|
892
989
|
# Delete
|
|
893
990
|
client.opt_in_forms.delete(1)
|
|
894
991
|
```
|
|
@@ -1163,7 +1260,7 @@ secret = result['secret']
|
|
|
1163
1260
|
|
|
1164
1261
|
# Every valid event type is available as a constant. An unknown event type is
|
|
1165
1262
|
# dropped server-side rather than rejected, so subscribe from these.
|
|
1166
|
-
Broadcast::Webhook::EVENT_TYPES # all
|
|
1263
|
+
Broadcast::Webhook::EVENT_TYPES # all 37
|
|
1167
1264
|
Broadcast::Webhook::EMAIL_EVENTS # email.sent, email.delivered, ...
|
|
1168
1265
|
Broadcast::Webhook::SUBSCRIBER_EVENTS # subscriber.created, ..., subscribers.purged
|
|
1169
1266
|
Broadcast::Webhook::BROADCAST_EVENTS # broadcast.sending, broadcast.sent, ...
|
|
@@ -1194,9 +1291,9 @@ result['data'] # => [{'id' => 1, 'event_type' => 'email.sent', 'response_status
|
|
|
1194
1291
|
|
|
1195
1292
|
| Category | Events |
|
|
1196
1293
|
|----------|--------|
|
|
1197
|
-
| Email | `email.sent`, `email.delivered`, `email.delivery_delayed`, `email.opened`, `email.clicked`, `email.bounced`, `email.complained`, `email.failed` |
|
|
1294
|
+
| Email | `email.sent`, `email.delivered`, `email.delivery_delayed`, `email.send_delayed`, `email.opened`, `email.clicked`, `email.bounced`, `email.complained`, `email.failed` |
|
|
1198
1295
|
| Subscriber | `subscriber.created`, `subscriber.updated`, `subscriber.deleted`, `subscriber.subscribed`, `subscriber.unsubscribed`, `subscriber.bounced`, `subscriber.complained`, `subscribers.purged`, `subscribers.purge_failed` |
|
|
1199
|
-
| Broadcast | `broadcast.scheduled`, `broadcast.queueing`, `broadcast.sending`, `broadcast.sent`, `broadcast.failed`, `broadcast.partial_failure`, `broadcast.aborted`, `broadcast.paused` |
|
|
1296
|
+
| Broadcast | `broadcast.scheduled`, `broadcast.queueing`, `broadcast.sending`, `broadcast.sent`, `broadcast.failed`, `broadcast.partial_failure`, `broadcast.aborted`, `broadcast.paused`, `broadcast.batch_completed` |
|
|
1200
1297
|
| Sequence | `sequence.subscriber_added`, `sequence.subscriber_completed`, `sequence.subscriber_moved`, `sequence.subscriber_removed`, `sequence.subscriber_paused`, `sequence.subscriber_resumed`, `sequence.subscriber_error` |
|
|
1201
1298
|
| System | `message.attempt.exhausted`, `test.webhook` |
|
|
1202
1299
|
|
|
@@ -1477,7 +1574,9 @@ Each token can be scoped to specific resources. The ActionMailer delivery method
|
|
|
1477
1574
|
| Users (admin token only) | `users_read` -- list, get, channel_permissions, system_permissions | `users_write` -- create, update, deactivate, activate, delete, permission writes |
|
|
1478
1575
|
| Webhook Endpoints | `webhook_endpoints_read` -- list, get, deliveries | `webhook_endpoints_write` -- create, update, delete, test |
|
|
1479
1576
|
| Autopilot | `autopilot_read` -- list, get, runs | `autopilot_write` -- create, update, delete, activate, pause, deactivate, trigger_run |
|
|
1577
|
+
| Channel Settings | `channel_settings_read` -- `channel.settings`, `channel.design` | `channel_settings_write` -- `channel.update_settings`, `channel.update_design` |
|
|
1480
1578
|
| Suppressions | `suppressions_read` -- list, check | `suppressions_write` -- add, remove, bulk add/remove |
|
|
1579
|
+
| File Assets | `file_assets_read` -- list, get | `file_assets_write` -- upload, upload_base64, delete |
|
|
1481
1580
|
|
|
1482
1581
|
---
|
|
1483
1582
|
|
data/SDK-COVERAGE.md
CHANGED
|
@@ -331,7 +331,7 @@ Inbound webhooks — the app calling you.
|
|
|
331
331
|
| HMAC-SHA256 verification (`v1,<base64>`) | ✅ `Webhook.verify` |
|
|
332
332
|
| 5-minute timestamp tolerance | ✅ Replay protection |
|
|
333
333
|
| Constant-time comparison | ✅ `OpenSSL.fixed_length_secure_compare` |
|
|
334
|
-
| Event type constants | ✅ `Webhook::EVENT_TYPES` —
|
|
334
|
+
| Event type constants | ✅ `Webhook::EVENT_TYPES` — 37 values |
|
|
335
335
|
|
|
336
336
|
Categories: `EMAIL_EVENTS` (8), `SUBSCRIBER_EVENTS` (7), `BROADCAST_EVENTS` (8),
|
|
337
337
|
`SEQUENCE_EVENTS` (7), `SYSTEM_EVENTS` (2).
|
data/lib/broadcast/client.rb
CHANGED
|
@@ -153,6 +153,11 @@ module Broadcast
|
|
|
153
153
|
@channel ||= Resources::Channel.new(self)
|
|
154
154
|
end
|
|
155
155
|
|
|
156
|
+
# The channel's file library: upload images and get their public URLs.
|
|
157
|
+
def file_assets
|
|
158
|
+
@file_assets ||= Resources::FileAssets.new(self)
|
|
159
|
+
end
|
|
160
|
+
|
|
156
161
|
# Read-only export endpoints under /api/migration/v1. Requires an admin
|
|
157
162
|
# (system) API token.
|
|
158
163
|
def migration
|
|
@@ -160,9 +165,11 @@ module Broadcast
|
|
|
160
165
|
end
|
|
161
166
|
|
|
162
167
|
# @api private
|
|
163
|
-
|
|
168
|
+
# rubocop:disable Metrics/ParameterLists -- transport options passed straight through to Connection
|
|
169
|
+
def request(method, path, body_or_params = nil, headers: {}, raw: false, multipart: false)
|
|
170
|
+
# rubocop:enable Metrics/ParameterLists
|
|
164
171
|
payload = inject_channel_scope(body_or_params)
|
|
165
|
-
@connection.request(method, path, payload, headers: headers, raw: raw)
|
|
172
|
+
@connection.request(method, path, payload, headers: headers, raw: raw, multipart: multipart)
|
|
166
173
|
end
|
|
167
174
|
|
|
168
175
|
private
|
data/lib/broadcast/connection.rb
CHANGED
|
@@ -28,9 +28,13 @@ module Broadcast
|
|
|
28
28
|
|
|
29
29
|
# @param raw [Boolean] return the response body as a String instead of
|
|
30
30
|
# parsing it as JSON — for text/plain endpoints such as /api/v1/skill.
|
|
31
|
-
|
|
31
|
+
# @param multipart [Boolean] send the payload as multipart/form-data
|
|
32
|
+
# (Broadcast::FilePart values become file parts) instead of JSON.
|
|
33
|
+
# rubocop:disable Metrics/ParameterLists -- one keyword per transport option
|
|
34
|
+
def request(method, path, payload = nil, headers: {}, raw: false, multipart: false)
|
|
35
|
+
# rubocop:enable Metrics/ParameterLists
|
|
32
36
|
uri = build_uri(path, method, payload)
|
|
33
|
-
context = { headers: headers, raw: raw, redirects: 0 }
|
|
37
|
+
context = { headers: headers, raw: raw, redirects: 0, multipart: multipart }
|
|
34
38
|
|
|
35
39
|
retry_with_backoff { execute(method, uri, payload, context) }
|
|
36
40
|
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
@@ -52,7 +56,11 @@ module Broadcast
|
|
|
52
56
|
http.read_timeout = @config.timeout
|
|
53
57
|
|
|
54
58
|
req = build_request(method, uri, context[:headers])
|
|
55
|
-
|
|
59
|
+
if context[:multipart]
|
|
60
|
+
req.body, req['Content-Type'] = Multipart.build(payload || {})
|
|
61
|
+
elsif method != :get && payload_present?(payload)
|
|
62
|
+
req.body = payload.to_json
|
|
63
|
+
end
|
|
56
64
|
|
|
57
65
|
@debug_logger.request(req, method == :get ? nil : payload)
|
|
58
66
|
response = http.request(req)
|
|
@@ -17,6 +17,9 @@ module Broadcast
|
|
|
17
17
|
api_key api_token password secret
|
|
18
18
|
].freeze
|
|
19
19
|
|
|
20
|
+
# Longer strings are cut short in the log.
|
|
21
|
+
MAX_LOGGED_STRING = 1000
|
|
22
|
+
|
|
20
23
|
def initialize(config)
|
|
21
24
|
@config = config
|
|
22
25
|
end
|
|
@@ -56,6 +59,12 @@ module Broadcast
|
|
|
56
59
|
end
|
|
57
60
|
when Array
|
|
58
61
|
value.map { |item| redact(item) }
|
|
62
|
+
when FilePart
|
|
63
|
+
value.inspect
|
|
64
|
+
when String
|
|
65
|
+
# A base64 file upload (file_assets.upload_base64) would otherwise
|
|
66
|
+
# write megabytes into the log.
|
|
67
|
+
value.length > MAX_LOGGED_STRING ? "#{value[0, 40]}... (#{value.length} characters)" : value
|
|
59
68
|
else
|
|
60
69
|
value
|
|
61
70
|
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Broadcast
|
|
6
|
+
# A file in a multipart/form-data request: the bytes, and the filename and
|
|
7
|
+
# content type sent with them. Holding the bytes (not an IO) keeps a retry
|
|
8
|
+
# safe: every attempt builds the body afresh.
|
|
9
|
+
FilePart = Struct.new(:bytes, :filename, :content_type) do
|
|
10
|
+
def inspect
|
|
11
|
+
"#<Broadcast::FilePart #{filename} (#{content_type}, #{bytes.bytesize} bytes)>"
|
|
12
|
+
end
|
|
13
|
+
alias_method :to_s, :inspect
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Builds a multipart/form-data body by hand. Net::HTTP's set_form streams
|
|
17
|
+
# the body at send time, which the debug logger and test doubles cannot see.
|
|
18
|
+
module Multipart
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# @param fields [Hash] values are FileParts or anything with #to_s; nil is left out
|
|
22
|
+
# @return [Array(String, String)] the body (binary) and the Content-Type header
|
|
23
|
+
def build(fields)
|
|
24
|
+
boundary = "BroadcastRuby#{SecureRandom.hex(16)}"
|
|
25
|
+
body = String.new(encoding: Encoding::BINARY)
|
|
26
|
+
|
|
27
|
+
fields.each do |name, value|
|
|
28
|
+
next if value.nil?
|
|
29
|
+
|
|
30
|
+
body << "--#{boundary}\r\n"
|
|
31
|
+
if value.is_a?(FilePart)
|
|
32
|
+
body << "Content-Disposition: form-data; name=\"#{quote(name)}\"; filename=\"#{quote(value.filename)}\"\r\n"
|
|
33
|
+
body << "Content-Type: #{value.content_type}\r\n\r\n"
|
|
34
|
+
body << value.bytes.b
|
|
35
|
+
else
|
|
36
|
+
body << "Content-Disposition: form-data; name=\"#{quote(name)}\"\r\n\r\n"
|
|
37
|
+
body << value.to_s.b
|
|
38
|
+
end
|
|
39
|
+
body << "\r\n"
|
|
40
|
+
end
|
|
41
|
+
body << "--#{boundary}--\r\n"
|
|
42
|
+
|
|
43
|
+
[body, "multipart/form-data; boundary=#{boundary}"]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A quote or a line break in a name would end the header early.
|
|
47
|
+
def quote(value)
|
|
48
|
+
value.to_s.gsub(/[\r\n]/, ' ').gsub('"', '%22').b
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -30,6 +30,7 @@ module Broadcast
|
|
|
30
30
|
# copies_to_generate: how many drafts each run produces
|
|
31
31
|
# tone_description:, content_instructions:, newsletter_structure:
|
|
32
32
|
# segment_ids: array — restrict the newsletter's audience
|
|
33
|
+
# excluded_segment_ids: array — segments its drafts never reach
|
|
33
34
|
def create(**attrs)
|
|
34
35
|
post('/api/v1/autopilots', { autopilot: attrs })
|
|
35
36
|
end
|
|
@@ -6,9 +6,9 @@ module Broadcast
|
|
|
6
6
|
# (or, for an admin token, the channel it is scoped to); there is no
|
|
7
7
|
# channel id argument.
|
|
8
8
|
class Channel < Base
|
|
9
|
-
# The channel's brand kit from Settings ->
|
|
10
|
-
# (defaults filled in)
|
|
11
|
-
#
|
|
9
|
+
# The channel's brand kit from Settings -> Brand Kit, fully resolved
|
|
10
|
+
# (defaults filled in). Requires the `templates_read` permission (or,
|
|
11
|
+
# from Broadcast 2.46.0, `channel_settings_read`).
|
|
12
12
|
#
|
|
13
13
|
# Returns a Hash with `colors`, `typography` (`font` key and email-safe
|
|
14
14
|
# `font_stack`), `layout` (`width`, `radius`), and `brand` (`logo_url` as
|
|
@@ -17,6 +17,42 @@ module Broadcast
|
|
|
17
17
|
def design
|
|
18
18
|
get('/api/v1/channel/design')
|
|
19
19
|
end
|
|
20
|
+
|
|
21
|
+
# Changes the brand kit (Broadcast 2.46.0 or later). Requires the
|
|
22
|
+
# `channel_settings_write` permission.
|
|
23
|
+
#
|
|
24
|
+
# Partial: send only what changes, in the shape #design returns. An empty
|
|
25
|
+
# string or nil puts a value back to its default; `social_links`
|
|
26
|
+
# replaces the list. Set the logo with the `logo_url` of an image in the
|
|
27
|
+
# channel's file library. Returns the whole kit after the change, with
|
|
28
|
+
# any `warnings`.
|
|
29
|
+
#
|
|
30
|
+
# client.channel.update_design(colors: { accent: '#ff5500' }, brand: { logo_url: nil })
|
|
31
|
+
def update_design(**design)
|
|
32
|
+
patch('/api/v1/channel/design', { channel_design: design })
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The channel's settings (Broadcast 2.46.0 or later), grouped like the
|
|
36
|
+
# Settings pages: `general`, `sender`, `unsubscribe_page` (with the legal
|
|
37
|
+
# links), `confirmation_page` and `opt_in_forms` (`match_brand_kit`, whether
|
|
38
|
+
# new opt-in forms start from the brand kit). The brand kit switches are
|
|
39
|
+
# set here, not with #update_design. Requires `channel_settings_read`.
|
|
40
|
+
def settings
|
|
41
|
+
get('/api/v1/channel/settings')
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Changes channel settings (Broadcast 2.46.0 or later). Requires
|
|
45
|
+
# `channel_settings_write`.
|
|
46
|
+
#
|
|
47
|
+
# Partial: send only the groups and keys that change. Nested text and
|
|
48
|
+
# colours merge, an empty string or nil removes a value, and
|
|
49
|
+
# `languages` replaces the list. Returns every setting after the
|
|
50
|
+
# change, with any `warnings`.
|
|
51
|
+
#
|
|
52
|
+
# client.channel.update_settings(unsubscribe_page: { legal_notice_url: 'https://example.com/impressum' })
|
|
53
|
+
def update_settings(**settings)
|
|
54
|
+
patch('/api/v1/channel/settings', { channel_settings: settings })
|
|
55
|
+
end
|
|
20
56
|
end
|
|
21
57
|
end
|
|
22
58
|
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Broadcast
|
|
4
|
+
module Resources
|
|
5
|
+
# The channel's file library (Broadcast 2.48.0 or later): images (PNG,
|
|
6
|
+
# JPEG, GIF, under 10 MB) with public URLs that never expire. Use a file's
|
|
7
|
+
# `url` in emails, or as the brand kit logo:
|
|
8
|
+
#
|
|
9
|
+
# file = client.file_assets.upload('logo.png')
|
|
10
|
+
# client.channel.update_design(brand: { logo_url: file['url'] })
|
|
11
|
+
#
|
|
12
|
+
# Requires the `file_assets_read` permission to read and
|
|
13
|
+
# `file_assets_write` to upload and delete.
|
|
14
|
+
class FileAssets < Base
|
|
15
|
+
MAX_IDEMPOTENCY_KEY_LENGTH = 255
|
|
16
|
+
|
|
17
|
+
CONTENT_TYPES = {
|
|
18
|
+
'.png' => 'image/png',
|
|
19
|
+
'.jpg' => 'image/jpeg',
|
|
20
|
+
'.jpeg' => 'image/jpeg',
|
|
21
|
+
'.gif' => 'image/gif'
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
# Files the channel can see, newest first.
|
|
25
|
+
#
|
|
26
|
+
# filter: find files by file name (closest matches first)
|
|
27
|
+
# limit:, offset:
|
|
28
|
+
#
|
|
29
|
+
# Returns `{ 'data' => [...], 'total' => n }`.
|
|
30
|
+
def list(**params)
|
|
31
|
+
get('/api/v1/file_assets', params)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def get_file_asset(id)
|
|
35
|
+
get("/api/v1/file_assets/#{id}")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Uploads a file as multipart/form-data.
|
|
39
|
+
#
|
|
40
|
+
# `file` is a path, or an IO (then `filename:` is required). The content
|
|
41
|
+
# type follows the file extension unless `content_type:` is given.
|
|
42
|
+
#
|
|
43
|
+
# channel_restricted: true links the file to this channel only
|
|
44
|
+
# (default: every channel, as in the web app)
|
|
45
|
+
# idempotency_key: makes a retry safe; see Transactionals#create
|
|
46
|
+
def upload(file, filename: nil, content_type: nil, channel_restricted: nil, idempotency_key: nil)
|
|
47
|
+
bytes, name = read_file(file, filename)
|
|
48
|
+
part = FilePart.new(bytes: bytes, filename: name, content_type: content_type || content_type_for(name))
|
|
49
|
+
|
|
50
|
+
fields = { file: part }
|
|
51
|
+
fields[:channel_restricted] = channel_restricted unless channel_restricted.nil?
|
|
52
|
+
|
|
53
|
+
@client.request(:post, '/api/v1/file_assets', fields,
|
|
54
|
+
headers: idempotency_headers(idempotency_key), multipart: true)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Uploads base64-encoded bytes in a JSON body, for callers that already
|
|
58
|
+
# hold base64 (or cannot send multipart).
|
|
59
|
+
def upload_base64(filename:, data:, content_type: nil, channel_restricted: nil, idempotency_key: nil)
|
|
60
|
+
payload = { filename: filename, data: data }
|
|
61
|
+
payload[:content_type] = content_type unless content_type.nil?
|
|
62
|
+
payload[:channel_restricted] = channel_restricted unless channel_restricted.nil?
|
|
63
|
+
|
|
64
|
+
@client.request(:post, '/api/v1/file_assets', payload, headers: idempotency_headers(idempotency_key))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Deletes a file. A file that is a channel's brand kit logo is refused
|
|
68
|
+
# with Broadcast::ValidationError (422): choose another logo first.
|
|
69
|
+
def delete(id)
|
|
70
|
+
@client.request(:delete, "/api/v1/file_assets/#{id}")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def read_file(file, filename)
|
|
76
|
+
if file.respond_to?(:read)
|
|
77
|
+
if filename.nil? && !file.respond_to?(:path)
|
|
78
|
+
raise ArgumentError, 'filename: is required when uploading from an IO'
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
[file.read, filename || File.basename(file.path)]
|
|
82
|
+
else
|
|
83
|
+
path = file.to_s
|
|
84
|
+
[File.binread(path), filename || File.basename(path)]
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def content_type_for(name)
|
|
89
|
+
CONTENT_TYPES.fetch(File.extname(name.to_s).downcase, 'application/octet-stream')
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def idempotency_headers(key)
|
|
93
|
+
return {} if key.nil?
|
|
94
|
+
|
|
95
|
+
key = key.to_s.strip
|
|
96
|
+
return {} if key.empty?
|
|
97
|
+
|
|
98
|
+
if key.length > MAX_IDEMPOTENCY_KEY_LENGTH
|
|
99
|
+
raise ArgumentError,
|
|
100
|
+
"idempotency_key must be #{MAX_IDEMPOTENCY_KEY_LENGTH} characters or fewer (got #{key.length})"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
{ 'Idempotency-Key' => key }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -5,6 +5,12 @@ require 'date'
|
|
|
5
5
|
module Broadcast
|
|
6
6
|
module Resources
|
|
7
7
|
class OptInForms < Base
|
|
8
|
+
# The words trigger_settings[:frequency] accepts: how often the same visitor
|
|
9
|
+
# sees a popup. always and every_visit mean the same. The server refuses any
|
|
10
|
+
# other word with 422 (Broadcast::ValidationError); the client does not
|
|
11
|
+
# check, so an older server is never refused a word it would take.
|
|
12
|
+
TRIGGER_FREQUENCIES = %w[always every_visit once_per_session once_per_day once_per_week once].freeze
|
|
13
|
+
|
|
8
14
|
# List opt-in forms.
|
|
9
15
|
#
|
|
10
16
|
# NOTE: returns up to 250 results per page along with `pagination`
|
|
@@ -36,6 +42,10 @@ module Broadcast
|
|
|
36
42
|
#
|
|
37
43
|
# Custom fields declared in opt_in_form_blocks_attributes support a
|
|
38
44
|
# multi-line variant, which renders as a textarea on the hosted form.
|
|
45
|
+
#
|
|
46
|
+
# trigger_settings: { frequency: } must be one of TRIGGER_FREQUENCIES;
|
|
47
|
+
# any other word raises Broadcast::ValidationError (422). Keys sent in a
|
|
48
|
+
# settings hash are merged into the stored settings.
|
|
39
49
|
def create(**attrs)
|
|
40
50
|
post('/api/v1/opt_in_forms', { opt_in_form: attrs })
|
|
41
51
|
end
|
|
@@ -45,7 +45,13 @@ module Broadcast
|
|
|
45
45
|
# another provider. Ignored (with a warning) on update,
|
|
46
46
|
# and ignored entirely for channel-scoped tokens.
|
|
47
47
|
#
|
|
48
|
-
#
|
|
48
|
+
# Any token, create only:
|
|
49
|
+
# unsubscribed_at: ISO 8601. Stores the subscriber as unsubscribed in the
|
|
50
|
+
# same request (inactive, on the unsubscribe list), for
|
|
51
|
+
# migrating a list with its opt-outs intact. No
|
|
52
|
+
# confirmation email; 422 if it is in the future or sent
|
|
53
|
+
# with is_active: true. To unsubscribe an existing
|
|
54
|
+
# subscriber, use `unsubscribe(email)`.
|
|
49
55
|
def create(**attrs)
|
|
50
56
|
double_opt_in = attrs.delete(:double_opt_in)
|
|
51
57
|
confirmation_template_id = attrs.delete(:confirmation_template_id)
|
data/lib/broadcast/version.rb
CHANGED
data/lib/broadcast/webhook.rb
CHANGED
|
@@ -10,9 +10,12 @@ module Broadcast
|
|
|
10
10
|
# Every event type a webhook endpoint can subscribe to, mirroring
|
|
11
11
|
# WebhookEndpoint::AVAILABLE_EVENT_TYPES server-side. Use these when
|
|
12
12
|
# creating an endpoint — an unknown event type is dropped silently.
|
|
13
|
+
# email.send_delayed: a transactional email is waiting for room under its
|
|
14
|
+
# server's hourly limit (not yet sent). email.delivery_delayed: the
|
|
15
|
+
# provider accepted it and the recipient's server is delaying it.
|
|
13
16
|
EMAIL_EVENTS = %w[
|
|
14
|
-
email.sent email.delivered email.delivery_delayed email.
|
|
15
|
-
email.bounced email.opened email.clicked email.failed
|
|
17
|
+
email.sent email.delivered email.delivery_delayed email.send_delayed
|
|
18
|
+
email.complained email.bounced email.opened email.clicked email.failed
|
|
16
19
|
].freeze
|
|
17
20
|
|
|
18
21
|
# subscribers.* are sent once for a whole-list purge, in place of a
|
|
@@ -26,7 +29,7 @@ module Broadcast
|
|
|
26
29
|
BROADCAST_EVENTS = %w[
|
|
27
30
|
broadcast.scheduled broadcast.queueing broadcast.sending broadcast.sent
|
|
28
31
|
broadcast.failed broadcast.partial_failure broadcast.aborted
|
|
29
|
-
broadcast.paused
|
|
32
|
+
broadcast.paused broadcast.batch_completed
|
|
30
33
|
].freeze
|
|
31
34
|
|
|
32
35
|
SEQUENCE_EVENTS = %w[
|
data/lib/broadcast.rb
CHANGED
|
@@ -5,6 +5,7 @@ require_relative 'broadcast/errors'
|
|
|
5
5
|
require_relative 'broadcast/response'
|
|
6
6
|
require_relative 'broadcast/configuration'
|
|
7
7
|
require_relative 'broadcast/debug_logger'
|
|
8
|
+
require_relative 'broadcast/multipart'
|
|
8
9
|
require_relative 'broadcast/connection'
|
|
9
10
|
require_relative 'broadcast/client'
|
|
10
11
|
require_relative 'broadcast/webhook'
|
|
@@ -25,6 +26,7 @@ require_relative 'broadcast/resources/discovery'
|
|
|
25
26
|
require_relative 'broadcast/resources/suppressions'
|
|
26
27
|
require_relative 'broadcast/resources/global_suppressions'
|
|
27
28
|
require_relative 'broadcast/resources/channel'
|
|
29
|
+
require_relative 'broadcast/resources/file_assets'
|
|
28
30
|
require_relative 'broadcast/resources/migration'
|
|
29
31
|
|
|
30
32
|
# 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.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Simon Chiu
|
|
@@ -46,6 +46,7 @@ files:
|
|
|
46
46
|
- lib/broadcast/debug_logger.rb
|
|
47
47
|
- lib/broadcast/delivery_method.rb
|
|
48
48
|
- lib/broadcast/errors.rb
|
|
49
|
+
- lib/broadcast/multipart.rb
|
|
49
50
|
- lib/broadcast/railtie.rb
|
|
50
51
|
- lib/broadcast/resources/autopilots.rb
|
|
51
52
|
- lib/broadcast/resources/base.rb
|
|
@@ -53,6 +54,7 @@ files:
|
|
|
53
54
|
- lib/broadcast/resources/channel.rb
|
|
54
55
|
- lib/broadcast/resources/discovery.rb
|
|
55
56
|
- lib/broadcast/resources/email_servers.rb
|
|
57
|
+
- lib/broadcast/resources/file_assets.rb
|
|
56
58
|
- lib/broadcast/resources/global_suppressions.rb
|
|
57
59
|
- lib/broadcast/resources/migration.rb
|
|
58
60
|
- lib/broadcast/resources/opt_in_forms.rb
|