broadcast-ruby 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +146 -0
- data/Gemfile.lock +3 -2
- data/README.md +434 -22
- data/SDK-COVERAGE.md +469 -0
- data/lib/broadcast/client.rb +65 -138
- data/lib/broadcast/configuration.rb +53 -4
- data/lib/broadcast/connection.rb +278 -0
- data/lib/broadcast/debug_logger.rb +64 -0
- data/lib/broadcast/delivery_method.rb +22 -2
- data/lib/broadcast/errors.rb +27 -1
- data/lib/broadcast/resources/autopilots.rb +100 -0
- data/lib/broadcast/resources/discovery.rb +36 -0
- data/lib/broadcast/resources/global_suppressions.rb +43 -0
- data/lib/broadcast/resources/migration.rb +75 -0
- data/lib/broadcast/resources/opt_in_forms.rb +12 -0
- data/lib/broadcast/resources/subscribers.rb +24 -0
- data/lib/broadcast/resources/suppressions.rb +56 -0
- data/lib/broadcast/resources/templates.rb +17 -0
- data/lib/broadcast/resources/transactionals.rb +39 -2
- data/lib/broadcast/response.rb +104 -0
- data/lib/broadcast/version.rb +1 -1
- data/lib/broadcast/webhook.rb +34 -0
- data/lib/broadcast.rb +8 -0
- metadata +11 -3
- data/.rubocop.yml +0 -50
|
@@ -2,8 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
module Broadcast
|
|
4
4
|
class DeliveryMethod
|
|
5
|
+
# ActionMailer delivers transactional mail, so the unsubscribe link is off
|
|
6
|
+
# unless the host app opts back in via broadcast_settings. The option is
|
|
7
|
+
# consumed here rather than forwarded: Configuration would reject it.
|
|
8
|
+
DEFAULT_INCLUDE_UNSUBSCRIBE_LINK = false
|
|
9
|
+
|
|
5
10
|
def initialize(settings = {})
|
|
6
|
-
|
|
11
|
+
opts = settings.to_h.dup
|
|
12
|
+
@include_unsubscribe_link =
|
|
13
|
+
if opts.key?(:include_unsubscribe_link)
|
|
14
|
+
opts.delete(:include_unsubscribe_link)
|
|
15
|
+
else
|
|
16
|
+
DEFAULT_INCLUDE_UNSUBSCRIBE_LINK
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
@client = Client.new(**opts)
|
|
7
20
|
end
|
|
8
21
|
|
|
9
22
|
def deliver!(mail)
|
|
@@ -11,8 +24,15 @@ 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
|
)
|
|
31
|
+
rescue Broadcast::WarningError
|
|
32
|
+
# The send succeeded — warnings_mode: :raise is about surfacing ignored
|
|
33
|
+
# parameters, not reporting a delivery failure. Wrapping it in
|
|
34
|
+
# DeliveryError would tell ActionMailer the mail didn't go out.
|
|
35
|
+
raise
|
|
16
36
|
rescue Broadcast::Error => e
|
|
17
37
|
raise DeliveryError, "Failed to deliver email: #{e.message}"
|
|
18
38
|
end
|
data/lib/broadcast/errors.rb
CHANGED
|
@@ -13,11 +13,37 @@ module Broadcast
|
|
|
13
13
|
|
|
14
14
|
class NotFoundError < APIError; end
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
# 409 — an in-flight request is already using this Idempotency-Key. The
|
|
17
|
+
# original request is still processing; retrying after a short pause will
|
|
18
|
+
# either replay its stored response or run fresh if it failed.
|
|
19
|
+
class ConflictError < APIError; end
|
|
20
|
+
|
|
21
|
+
class RateLimitError < APIError
|
|
22
|
+
# Seconds the server asked us to wait, parsed from the Retry-After header.
|
|
23
|
+
attr_reader :retry_after
|
|
24
|
+
|
|
25
|
+
def initialize(message = nil, retry_after: nil)
|
|
26
|
+
super(message)
|
|
27
|
+
@retry_after = retry_after
|
|
28
|
+
end
|
|
29
|
+
end
|
|
17
30
|
|
|
18
31
|
class ValidationError < Error; end
|
|
19
32
|
|
|
20
33
|
class TimeoutError < Error; end
|
|
21
34
|
|
|
22
35
|
class DeliveryError < Error; end
|
|
36
|
+
|
|
37
|
+
# Raised instead of returning when config.warnings_mode is :raise and a 2xx
|
|
38
|
+
# response carried warnings. The request DID succeed — the write happened.
|
|
39
|
+
# Callers rescuing this must not assume anything was rolled back.
|
|
40
|
+
class WarningError < Error
|
|
41
|
+
attr_reader :warnings, :response
|
|
42
|
+
|
|
43
|
+
def initialize(warnings, response = nil)
|
|
44
|
+
@warnings = warnings
|
|
45
|
+
@response = response
|
|
46
|
+
super("API returned #{warnings.size} warning(s): #{warnings.join('; ')}")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
23
49
|
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Broadcast
|
|
4
|
+
module Resources
|
|
5
|
+
# Autopilot — AI-generated newsletters.
|
|
6
|
+
#
|
|
7
|
+
# Requires the `autopilot_read` / `autopilot_write` token permissions.
|
|
8
|
+
class Autopilots < Base
|
|
9
|
+
# The API renders a configured key bullet-masked and never returns the
|
|
10
|
+
# real value. Writing a masked value back would replace a working
|
|
11
|
+
# credential with bullets, so it is stripped — same guard as
|
|
12
|
+
# EmailServers#update.
|
|
13
|
+
REDACTED_KEY_PATTERN = /\A•+\z/
|
|
14
|
+
|
|
15
|
+
def list(**params)
|
|
16
|
+
get('/api/v1/autopilots', params)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def get_autopilot(id)
|
|
20
|
+
get("/api/v1/autopilots/#{id}")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Create an autopilot. Attrs are wrapped under `autopilot:` on the wire.
|
|
24
|
+
#
|
|
25
|
+
# name: required, unique per channel
|
|
26
|
+
# openrouter_api_key: OpenRouter credential (write-only)
|
|
27
|
+
# ai_model: e.g. 'openai/gpt-4o'
|
|
28
|
+
# schedule_frequency: 'daily' | 'weekly' | 'biweekly' | 'monthly'
|
|
29
|
+
# schedule_day_of_week:, schedule_day_of_month:, schedule_time:, schedule_timezone:
|
|
30
|
+
# copies_to_generate: how many drafts each run produces
|
|
31
|
+
# tone_description:, content_instructions:, newsletter_structure:
|
|
32
|
+
# segment_ids: array — restrict the newsletter's audience
|
|
33
|
+
def create(**attrs)
|
|
34
|
+
post('/api/v1/autopilots', { autopilot: attrs })
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Update an autopilot. A bullet-masked openrouter_api_key is dropped
|
|
38
|
+
# before sending; pass the real key to rotate it, or omit the field.
|
|
39
|
+
def update(id, **attrs)
|
|
40
|
+
patch("/api/v1/autopilots/#{id}", { autopilot: scrub_redacted_key(attrs) })
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def delete(id)
|
|
44
|
+
@client.request(:delete, "/api/v1/autopilots/#{id}")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# --- Lifecycle ---
|
|
48
|
+
|
|
49
|
+
# Activate the autopilot so it runs on its schedule.
|
|
50
|
+
#
|
|
51
|
+
# Requires at least one active source, an API key, and a model. Raises
|
|
52
|
+
# Broadcast::ValidationError naming the missing prerequisites otherwise.
|
|
53
|
+
def activate(id)
|
|
54
|
+
post("/api/v1/autopilots/#{id}/activate")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def pause(id)
|
|
58
|
+
post("/api/v1/autopilots/#{id}/pause")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def deactivate(id)
|
|
62
|
+
post("/api/v1/autopilots/#{id}/deactivate")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Queue a generation run now. Returns 202 with the created run — the work
|
|
66
|
+
# is asynchronous, so poll `runs` for progress rather than expecting a
|
|
67
|
+
# finished newsletter here.
|
|
68
|
+
def trigger_run(id)
|
|
69
|
+
post("/api/v1/autopilots/#{id}/trigger_run")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# --- Runs ---
|
|
73
|
+
|
|
74
|
+
# Generation runs, most recent first. Supports limit: and offset:.
|
|
75
|
+
def runs(id, **params)
|
|
76
|
+
get("/api/v1/autopilots/#{id}/runs", params)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def scrub_redacted_key(attrs)
|
|
82
|
+
key = attrs[:openrouter_api_key] || attrs['openrouter_api_key']
|
|
83
|
+
return attrs unless key.is_a?(String) && key.match?(REDACTED_KEY_PATTERN)
|
|
84
|
+
|
|
85
|
+
warn_redacted
|
|
86
|
+
attrs.reject { |name, _| name.to_sym == :openrouter_api_key }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def warn_redacted
|
|
90
|
+
msg = '[broadcast-ruby] Dropped redacted openrouter_api_key from update payload — ' \
|
|
91
|
+
'pass the real key or omit the field'
|
|
92
|
+
if @client.config.logger
|
|
93
|
+
@client.config.logger.warn(msg)
|
|
94
|
+
else
|
|
95
|
+
Kernel.warn(msg)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Broadcast
|
|
4
|
+
module Resources
|
|
5
|
+
# Introspection endpoints. Primarily built for AI agents and CLIs that need
|
|
6
|
+
# to discover what a token can do before acting, but equally useful for
|
|
7
|
+
# health checks and for failing fast on a misconfigured deploy.
|
|
8
|
+
class Discovery < Base
|
|
9
|
+
# Identity of the current token: label, type (channel_scoped or
|
|
10
|
+
# admin_cross_channel), per-resource permissions, and the resolved channel.
|
|
11
|
+
def whoami
|
|
12
|
+
get('/api/v1/whoami')
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Channel sender config, subscriber counts, and per-feature transmission
|
|
16
|
+
# readiness. Worth calling before a send — `readiness.broadcasts == false`
|
|
17
|
+
# means the channel has no usable email server or sender identity.
|
|
18
|
+
def status
|
|
19
|
+
get('/api/v1/status')
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Full capability manifest: platform version, token permissions, channel
|
|
23
|
+
# status, the endpoint list the token can reach, rate limit, and usage tips.
|
|
24
|
+
def prime
|
|
25
|
+
get('/api/v1/prime')
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Plain-text agent skill manifest (Markdown with YAML front matter),
|
|
29
|
+
# including the safety rules agents are expected to follow. Returns a
|
|
30
|
+
# String, not a Hash — this endpoint serves text/plain.
|
|
31
|
+
def skill
|
|
32
|
+
@client.request(:get, '/api/v1/skill', nil, raw: true)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -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,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Broadcast
|
|
4
|
+
module Resources
|
|
5
|
+
# Read-only export endpoints under /api/migration/v1 — the surface behind
|
|
6
|
+
# backups and instance-to-instance migration.
|
|
7
|
+
#
|
|
8
|
+
# Two things differ from the v1 API:
|
|
9
|
+
#
|
|
10
|
+
# 1. **Admin tokens only.** Channel-scoped tokens are rejected outright;
|
|
11
|
+
# the controller looks up AdminApiToken and nothing else.
|
|
12
|
+
# 2. **broadcast_channel_id is required on every call.** Set it once via
|
|
13
|
+
# `Client.new(broadcast_channel_id:)` or `client.with_channel(id)` and
|
|
14
|
+
# the gem attaches it automatically; otherwise pass it per call.
|
|
15
|
+
#
|
|
16
|
+
# Every list endpoint pages with `limit:` (1..250, default 250) and
|
|
17
|
+
# `offset:`, and returns `{"data" => [...], "pagination" => {...}}`.
|
|
18
|
+
class Migration < Base
|
|
19
|
+
# Endpoints that are a plain paginated list of the channel's records.
|
|
20
|
+
COLLECTIONS = %i[
|
|
21
|
+
channels subscribers templates segments sequences email_servers
|
|
22
|
+
opt_in_forms broadcasts outbound_receipts webhook_endpoints tokens
|
|
23
|
+
suppressions tags users link_redirects link_clicks subscriber_histories
|
|
24
|
+
file_assets
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
COLLECTIONS.each do |collection|
|
|
28
|
+
define_method(collection) do |**params|
|
|
29
|
+
get("/api/migration/v1/#{collection}", params)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Export summary: format version, channel identity, per-resource counts,
|
|
34
|
+
# and recent-history totals. Call this first to size an export.
|
|
35
|
+
#
|
|
36
|
+
# @param days_history [Integer] window for the time-bounded counts
|
|
37
|
+
# (broadcasts, receipts, histories). Server clamps to 1..365, default 90.
|
|
38
|
+
def manifest(**params)
|
|
39
|
+
get('/api/migration/v1/manifest', params)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Binary contents of a stored file asset. Returns a String of bytes, not
|
|
43
|
+
# JSON — write it straight to disk.
|
|
44
|
+
def download_file_asset(id, **params)
|
|
45
|
+
@client.request(:get, "/api/migration/v1/file_assets/#{id}/download", params, raw: true)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Pages through a collection, yielding each record.
|
|
49
|
+
#
|
|
50
|
+
# client.migration.each_record(:subscribers) { |sub| csv << sub }
|
|
51
|
+
#
|
|
52
|
+
# Stops when the server reports `has_more: false`, so it stays correct if
|
|
53
|
+
# the page size is clamped server-side.
|
|
54
|
+
def each_record(collection, limit: 250, **params, &block)
|
|
55
|
+
return enum_for(:each_record, collection, limit: limit, **params) unless block_given?
|
|
56
|
+
|
|
57
|
+
offset = 0
|
|
58
|
+
loop do
|
|
59
|
+
page = public_send(collection, limit: limit, offset: offset, **params)
|
|
60
|
+
records = Array(page['data'])
|
|
61
|
+
records.each(&block)
|
|
62
|
+
|
|
63
|
+
pagination = page['pagination'] || {}
|
|
64
|
+
break unless pagination['has_more']
|
|
65
|
+
|
|
66
|
+
# Advance by what the server actually returned — it clamps `limit`.
|
|
67
|
+
advanced = pagination['limit'] || records.size
|
|
68
|
+
break if advanced.to_i.zero?
|
|
69
|
+
|
|
70
|
+
offset += advanced.to_i
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -24,6 +24,18 @@ module Broadcast
|
|
|
24
24
|
# Nested settings hashes (theme_settings, automation_settings, security_settings,
|
|
25
25
|
# trigger_settings, widget_settings) and arrays (opt_in_form_blocks_attributes,
|
|
26
26
|
# opt_in_post_submission_blocks_attributes) are passed through verbatim.
|
|
27
|
+
#
|
|
28
|
+
# Core:
|
|
29
|
+
# label:, form_type:, widget_type:, enabled:, allowed_embedding_domains:
|
|
30
|
+
#
|
|
31
|
+
# Confirmation / welcome flow:
|
|
32
|
+
# confirmation_email_template_id: Template sent to confirm the address
|
|
33
|
+
# welcome_email_template_id: Template sent after confirmation
|
|
34
|
+
# confirmation_redirect_url: where to send the subscriber post-confirm
|
|
35
|
+
# include_unsubscribe_link_in_confirmation: boolean
|
|
36
|
+
#
|
|
37
|
+
# Custom fields declared in opt_in_form_blocks_attributes support a
|
|
38
|
+
# multi-line variant, which renders as a textarea on the hosted form.
|
|
27
39
|
def create(**attrs)
|
|
28
40
|
post('/api/v1/opt_in_forms', { opt_in_form: attrs })
|
|
29
41
|
end
|
|
@@ -3,6 +3,22 @@
|
|
|
3
3
|
module Broadcast
|
|
4
4
|
module Resources
|
|
5
5
|
class Subscribers < Base
|
|
6
|
+
# List subscribers (250 per page, with `pagination` metadata; pass `page:`).
|
|
7
|
+
#
|
|
8
|
+
# Filters, all optional and combinable:
|
|
9
|
+
# is_active: true | false
|
|
10
|
+
# source: exact match on the source string
|
|
11
|
+
# created_after: ISO-8601 timestamp
|
|
12
|
+
# created_before: ISO-8601 timestamp
|
|
13
|
+
# tags: array — AND logic, subscriber must have all of them
|
|
14
|
+
# email: partial (case-insensitive) match, not exact
|
|
15
|
+
# confirmation_status: 'confirmed' | 'unconfirmed'
|
|
16
|
+
# custom_data: hash — JSONB containment, e.g. { plan: 'pro' }
|
|
17
|
+
#
|
|
18
|
+
# An unparseable created_after/created_before is *ignored* by the server
|
|
19
|
+
# rather than rejected; it comes back as a `parameter_ignored` warning on
|
|
20
|
+
# the response, so a bad timestamp silently widens the result set unless
|
|
21
|
+
# you check `result.warnings`.
|
|
6
22
|
def list(**params)
|
|
7
23
|
get('/api/v1/subscribers.json', params)
|
|
8
24
|
end
|
|
@@ -22,6 +38,14 @@ module Broadcast
|
|
|
22
38
|
# When set, the subscriber is created in unconfirmed state
|
|
23
39
|
# and a confirmation email is queued.
|
|
24
40
|
# confirmation_template_id: custom confirmation template (used with double_opt_in: true)
|
|
41
|
+
#
|
|
42
|
+
# Admin tokens only:
|
|
43
|
+
# confirmed_at: backdate the confirmation timestamp on create.
|
|
44
|
+
# Intended for migrating an already-confirmed list off
|
|
45
|
+
# another provider. Ignored (with a warning) on update,
|
|
46
|
+
# and ignored entirely for channel-scoped tokens.
|
|
47
|
+
#
|
|
48
|
+
# Note `unsubscribed_at` is never settable here — use `unsubscribe(email)`.
|
|
25
49
|
def create(**attrs)
|
|
26
50
|
double_opt_in = attrs.delete(:double_opt_in)
|
|
27
51
|
confirmation_template_id = attrs.delete(:confirmation_template_id)
|
|
@@ -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
|
|
@@ -11,6 +11,23 @@ module Broadcast
|
|
|
11
11
|
get("/api/v1/templates/#{id}")
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
+
# Create a template. Attrs are wrapped under `template:` on the wire.
|
|
15
|
+
#
|
|
16
|
+
# Content:
|
|
17
|
+
# label:, subject:, preheader:, body:, html_body:
|
|
18
|
+
#
|
|
19
|
+
# Confirmation templates (double opt-in):
|
|
20
|
+
# template_purpose: marks the template's role, e.g. 'confirmation'
|
|
21
|
+
# confirmation_text: copy shown in the confirmation email
|
|
22
|
+
# default_confirmation: make this the channel's default confirmation template
|
|
23
|
+
# confirmation_page_settings: per-state page copy, keyed by state, each
|
|
24
|
+
# taking { heading:, body: } — e.g.
|
|
25
|
+
# { 'confirmed' => { heading: 'You're in',
|
|
26
|
+
# body: 'Thanks for confirming.' },
|
|
27
|
+
# 'expired' => { heading: 'Link expired', body: '...' } }
|
|
28
|
+
#
|
|
29
|
+
# Anything the server doesn't recognize comes back as an
|
|
30
|
+
# `unrecognized_parameter` warning on the response rather than an error.
|
|
14
31
|
def create(**attrs)
|
|
15
32
|
post('/api/v1/templates', { template: attrs })
|
|
16
33
|
end
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
module Broadcast
|
|
4
4
|
module Resources
|
|
5
5
|
class Transactionals < Base
|
|
6
|
+
MAX_IDEMPOTENCY_KEY_LENGTH = 255
|
|
7
|
+
|
|
6
8
|
# Send a transactional email.
|
|
7
9
|
#
|
|
8
10
|
# Required:
|
|
@@ -20,11 +22,29 @@ module Broadcast
|
|
|
20
22
|
# Holds the email until the recipient confirms.
|
|
21
23
|
# confirmation_template_id: custom confirmation template (used with double_opt_in: true)
|
|
22
24
|
# subscriber: { first_name:, last_name: } — populates Subscriber on first send
|
|
25
|
+
# idempotency_key: see below
|
|
26
|
+
#
|
|
27
|
+
# Idempotency
|
|
28
|
+
# -----------
|
|
29
|
+
# Pass `idempotency_key:` to make a retry safe. The server stores the
|
|
30
|
+
# response for 24 hours keyed on (token, key) and replays it rather than
|
|
31
|
+
# sending a second email. Check `result.idempotent_replay?` to tell a
|
|
32
|
+
# replay from a fresh send.
|
|
33
|
+
#
|
|
34
|
+
# client.transactionals.create(to: 'a@b.com', subject: 'Receipt',
|
|
35
|
+
# body: '<p>Thanks</p>',
|
|
36
|
+
# idempotency_key: "receipt-#{order.id}")
|
|
37
|
+
#
|
|
38
|
+
# The key is part of a fingerprint over method + full path + body:
|
|
39
|
+
# - same key, same payload, still running -> Broadcast::ConflictError (409)
|
|
40
|
+
# - same key, *different* payload -> Broadcast::ValidationError (422)
|
|
41
|
+
# That 422 means "this key was already used for something else", not that
|
|
42
|
+
# the email was invalid — don't retry it with the same key.
|
|
23
43
|
# rubocop:disable Metrics/ParameterLists -- mirrors the API's flat param surface
|
|
24
44
|
def create(to:, subject: nil, body: nil, reply_to: nil, preheader: nil,
|
|
25
45
|
template_id: nil, include_unsubscribe_link: nil,
|
|
26
46
|
double_opt_in: nil, confirmation_template_id: nil,
|
|
27
|
-
subscriber: nil, **extra)
|
|
47
|
+
subscriber: nil, idempotency_key: nil, **extra)
|
|
28
48
|
# rubocop:enable Metrics/ParameterLists
|
|
29
49
|
payload = { to: to }
|
|
30
50
|
payload[:subject] = subject unless subject.nil?
|
|
@@ -38,12 +58,29 @@ module Broadcast
|
|
|
38
58
|
payload[:subscriber] = subscriber unless subscriber.nil?
|
|
39
59
|
payload.merge!(extra) unless extra.empty?
|
|
40
60
|
|
|
41
|
-
post
|
|
61
|
+
@client.request(:post, '/api/v1/transactionals.json', payload,
|
|
62
|
+
headers: idempotency_headers(idempotency_key))
|
|
42
63
|
end
|
|
43
64
|
|
|
44
65
|
def get_transactional(id)
|
|
45
66
|
get("/api/v1/transactionals/#{id}.json")
|
|
46
67
|
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def idempotency_headers(key)
|
|
72
|
+
return {} if key.nil?
|
|
73
|
+
|
|
74
|
+
key = key.to_s.strip
|
|
75
|
+
return {} if key.empty?
|
|
76
|
+
|
|
77
|
+
if key.length > MAX_IDEMPOTENCY_KEY_LENGTH
|
|
78
|
+
raise ArgumentError,
|
|
79
|
+
"idempotency_key must be #{MAX_IDEMPOTENCY_KEY_LENGTH} characters or fewer (got #{key.length})"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
{ 'Idempotency-Key' => key }
|
|
83
|
+
end
|
|
47
84
|
end
|
|
48
85
|
end
|
|
49
86
|
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module Broadcast
|
|
6
|
+
# A single entry from the API's `warnings` array (docs: api-response-warnings).
|
|
7
|
+
# The API raises these on successful 2xx responses when it accepted the request
|
|
8
|
+
# but ignored part of it — an unrecognized parameter, a parameter that only
|
|
9
|
+
# applies in another mode, a value the server overrode.
|
|
10
|
+
#
|
|
11
|
+
# `param` is a dot-path to the offending parameter (e.g. "subscriber.foo").
|
|
12
|
+
# The API never includes submitted values, so a warning is safe to log.
|
|
13
|
+
Warning = Struct.new(:code, :param, :message) do
|
|
14
|
+
def to_s
|
|
15
|
+
param ? "[#{code}] #{param}: #{message}" : "[#{code}] #{message}"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Parsed X-RateLimit-* response headers. `reset` is the time the current
|
|
20
|
+
# window rolls over, not a duration.
|
|
21
|
+
RateLimit = Struct.new(:limit, :remaining, :reset)
|
|
22
|
+
|
|
23
|
+
# The value returned by every JSON API call.
|
|
24
|
+
#
|
|
25
|
+
# Response subclasses Hash rather than wrapping it, so everything that worked
|
|
26
|
+
# against the raw parsed body still works — `result['id']`, `result.is_a?(Hash)`,
|
|
27
|
+
# `result.dig(...)`, equality against a plain Hash. The response metadata the
|
|
28
|
+
# API sends alongside the body is exposed as extra readers.
|
|
29
|
+
#
|
|
30
|
+
# result = client.subscribers.create(email: 'user@example.com', foo: 'bar')
|
|
31
|
+
# result['id'] # => 42 (unchanged from v0.2)
|
|
32
|
+
# result.warnings # => [#<Warning code="unrecognized_parameter" ...>]
|
|
33
|
+
# result.rate_limit.remaining # => 118
|
|
34
|
+
# result.status # => 201
|
|
35
|
+
#
|
|
36
|
+
# Non-Hash JSON bodies (a bare array) are returned as-is and carry no metadata.
|
|
37
|
+
class Response < Hash
|
|
38
|
+
attr_reader :status, :headers
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
# Wraps a parsed JSON body when it is a Hash; passes anything else through.
|
|
42
|
+
def build(parsed, status:, headers: {})
|
|
43
|
+
return parsed unless parsed.is_a?(::Hash)
|
|
44
|
+
|
|
45
|
+
response = new
|
|
46
|
+
response.replace(parsed)
|
|
47
|
+
response.attach_metadata(status: status, headers: headers)
|
|
48
|
+
response
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @api private — set once by .build immediately after construction
|
|
53
|
+
def attach_metadata(status:, headers:)
|
|
54
|
+
@status = status
|
|
55
|
+
@headers = headers
|
|
56
|
+
self
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def warnings
|
|
60
|
+
@warnings ||= Array(self['warnings']).filter_map do |entry|
|
|
61
|
+
next unless entry.is_a?(::Hash)
|
|
62
|
+
|
|
63
|
+
Warning.new(code: entry['code'], param: entry['param'], message: entry['message'])
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def warnings?
|
|
68
|
+
!warnings.empty?
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def rate_limit
|
|
72
|
+
return @rate_limit if defined?(@rate_limit)
|
|
73
|
+
|
|
74
|
+
limit = header('x-ratelimit-limit')
|
|
75
|
+
@rate_limit = if limit.nil?
|
|
76
|
+
nil
|
|
77
|
+
else
|
|
78
|
+
RateLimit.new(
|
|
79
|
+
limit: limit.to_i,
|
|
80
|
+
remaining: header('x-ratelimit-remaining')&.to_i,
|
|
81
|
+
reset: parse_time(header('x-ratelimit-reset'))
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# True when the API replayed a stored response for a repeated
|
|
87
|
+
# Idempotency-Key rather than performing the write again.
|
|
88
|
+
def idempotent_replay?
|
|
89
|
+
header('idempotency-replayed') == 'true'
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def header(name)
|
|
95
|
+
(@headers || {})[name]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def parse_time(value)
|
|
99
|
+
value && Time.iso8601(value)
|
|
100
|
+
rescue ArgumentError
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
data/lib/broadcast/version.rb
CHANGED