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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6691d4bb7e0fb74c2f3fe326d1ef2ae6797ec957f730be3d62287c07b13b2b65
4
- data.tar.gz: 6117bc0c528ddb576ec51eafe1c56071b05fc354d06cbe66b067effbe2f8e560
3
+ metadata.gz: 46cb94477a871cda3ac8f5a69d2d947b2ec87b5dd4454a1ec8d179cec7f25b9a
4
+ data.tar.gz: 38f72f3aecd7376ed6f072e831049614ae5f329c8e92cb0e1aeb3d26648f8829
5
5
  SHA512:
6
- metadata.gz: 86f1f4579ff54f8677f1848efa21d097bea66ab5e2102fcc05763021a4f7ff05b34818b93e506631f2cd241bbe87c21d587bc0f6ff60f72adadad4f3acefc009
7
- data.tar.gz: b0a6d3889b396bce312a174f3da12e87a3f20ac729c91c26891a03f1b6f7f1f696f02d1c5681773b1f3fdc649034426d52a637891fe3153b8e0d0e4b044fc6a1
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.7.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.7.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 → Design) so your own templates can
837
- match it. Read-only, fully resolved (defaults filled in), and always the
838
- token's own channel, so it takes no arguments. Needs `templates_read`.
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 35
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` — 35 values |
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).
@@ -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
- def request(method, path, body_or_params = nil, headers: {}, raw: false)
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
@@ -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
- def request(method, path, payload = nil, headers: {}, raw: false)
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
- req.body = payload.to_json if method != :get && payload_present?(payload)
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 -> Design, fully resolved
10
- # (defaults filled in), read-only. Requires the `templates_read`
11
- # permission.
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
- # Note `unsubscribed_at` is never settable here — use `unsubscribe(email)`.
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Broadcast
4
- VERSION = '0.7.0'
4
+ VERSION = '0.9.0'
5
5
  end
@@ -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.complained
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.7.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