broadcast-ruby 0.1.4 → 0.3.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 +113 -0
- data/Gemfile.lock +3 -2
- data/README.md +582 -17
- data/SDK-COVERAGE.md +422 -0
- data/lib/broadcast/client.rb +90 -119
- data/lib/broadcast/configuration.rb +56 -5
- data/lib/broadcast/connection.rb +278 -0
- data/lib/broadcast/debug_logger.rb +64 -0
- data/lib/broadcast/delivery_method.rb +5 -0
- data/lib/broadcast/errors.rb +29 -1
- data/lib/broadcast/resources/autopilots.rb +100 -0
- data/lib/broadcast/resources/discovery.rb +36 -0
- data/lib/broadcast/resources/email_servers.rb +88 -0
- data/lib/broadcast/resources/migration.rb +75 -0
- data/lib/broadcast/resources/opt_in_forms.rb +83 -0
- data/lib/broadcast/resources/subscribers.rb +43 -1
- data/lib/broadcast/resources/templates.rb +17 -0
- data/lib/broadcast/resources/transactionals.rb +86 -0
- 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 +9 -0
- metadata +12 -3
- data/.rubocop.yml +0 -48
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Broadcast
|
|
4
|
+
module Resources
|
|
5
|
+
class EmailServers < Base
|
|
6
|
+
# Fields the API returns redacted (bullet-masked). When updating, never
|
|
7
|
+
# round-trip these values back from a fetch — the gem strips them out
|
|
8
|
+
# of the payload (with a logger warning) to prevent corrupting credentials.
|
|
9
|
+
REDACTED_FIELDS = %i[
|
|
10
|
+
smtp_password
|
|
11
|
+
aws_access_key_id
|
|
12
|
+
aws_secret_access_key
|
|
13
|
+
outbound_aws_access_key_id
|
|
14
|
+
outbound_aws_secret_access_key
|
|
15
|
+
postmark_api_token
|
|
16
|
+
inboxroad_api_token
|
|
17
|
+
smtp_com_api_key
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
# Matches the API's redaction shape: 8 bullets, OR 4-char prefix + bullets + 4-char suffix.
|
|
21
|
+
REDACTED_PATTERN = /\A(?:•{8}|.{0,4}•+.{0,4})\z/
|
|
22
|
+
|
|
23
|
+
def list(limit: nil, offset: nil)
|
|
24
|
+
params = {}
|
|
25
|
+
params[:limit] = limit unless limit.nil?
|
|
26
|
+
params[:offset] = offset unless offset.nil?
|
|
27
|
+
get('/api/v1/email_servers', params)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def get_email_server(id)
|
|
31
|
+
get("/api/v1/email_servers/#{id}")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def create(**attrs)
|
|
35
|
+
post('/api/v1/email_servers', { email_server: attrs })
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Update an email server. Attrs are wrapped under `email_server:` on the wire.
|
|
39
|
+
#
|
|
40
|
+
# CAUTION: API responses redact credential fields (e.g. `smtp_password`)
|
|
41
|
+
# with bullet characters. Never echo a fetched response back into update —
|
|
42
|
+
# this method scrubs values that match the redaction pattern, but you
|
|
43
|
+
# should pass only the fields you actually want to change.
|
|
44
|
+
def update(id, **attrs)
|
|
45
|
+
scrubbed = scrub_redacted(attrs)
|
|
46
|
+
patch("/api/v1/email_servers/#{id}", { email_server: scrubbed })
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def delete(id)
|
|
50
|
+
@client.request(:delete, "/api/v1/email_servers/#{id}")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def test_connection(id)
|
|
54
|
+
post("/api/v1/email_servers/#{id}/test_connection")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Copy an email server to another channel. Requires an admin/system token.
|
|
58
|
+
# In SaaS mode, target_channel_id is scoped to the token creator's account.
|
|
59
|
+
def copy_to_channel(id, target_channel_id:)
|
|
60
|
+
post("/api/v1/email_servers/#{id}/copy_to_channel", { target_channel_id: target_channel_id })
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def scrub_redacted(attrs)
|
|
66
|
+
scrubbed = {}
|
|
67
|
+
attrs.each do |key, value|
|
|
68
|
+
if REDACTED_FIELDS.include?(key.to_sym) && value.is_a?(String) && value.match?(REDACTED_PATTERN)
|
|
69
|
+
warn_redacted(key)
|
|
70
|
+
next
|
|
71
|
+
end
|
|
72
|
+
scrubbed[key] = value
|
|
73
|
+
end
|
|
74
|
+
scrubbed
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def warn_redacted(field)
|
|
78
|
+
msg = "[broadcast-ruby] Dropped redacted #{field} from update payload — " \
|
|
79
|
+
'pass the real credential or omit the field'
|
|
80
|
+
if @client.config.logger
|
|
81
|
+
@client.config.logger.warn(msg)
|
|
82
|
+
else
|
|
83
|
+
Kernel.warn(msg)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
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
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
|
|
5
|
+
module Broadcast
|
|
6
|
+
module Resources
|
|
7
|
+
class OptInForms < Base
|
|
8
|
+
# List opt-in forms.
|
|
9
|
+
#
|
|
10
|
+
# NOTE: returns up to 250 results per page along with `pagination`
|
|
11
|
+
# metadata. Variants are excluded (only main forms are returned).
|
|
12
|
+
# Pass `page:` to advance.
|
|
13
|
+
#
|
|
14
|
+
# Optional filters: filter: (label substring), widget_type:, enabled: 'true'
|
|
15
|
+
def list(**params)
|
|
16
|
+
get('/api/v1/opt_in_forms', params)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def get_opt_in_form(id)
|
|
20
|
+
get("/api/v1/opt_in_forms/#{id}")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Create an opt-in form. Attrs are wrapped under `opt_in_form:` on the wire.
|
|
24
|
+
# Nested settings hashes (theme_settings, automation_settings, security_settings,
|
|
25
|
+
# trigger_settings, widget_settings) and arrays (opt_in_form_blocks_attributes,
|
|
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.
|
|
39
|
+
def create(**attrs)
|
|
40
|
+
post('/api/v1/opt_in_forms', { opt_in_form: attrs })
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def update(id, **attrs)
|
|
44
|
+
patch("/api/v1/opt_in_forms/#{id}", { opt_in_form: attrs })
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def delete(id)
|
|
48
|
+
@client.request(:delete, "/api/v1/opt_in_forms/#{id}")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Performance analytics for the form. start_date/end_date accept Date,
|
|
52
|
+
# Time, or ISO-8601 strings (default: last 30 days).
|
|
53
|
+
def analytics(id, start_date: nil, end_date: nil)
|
|
54
|
+
params = {}
|
|
55
|
+
params[:start_date] = coerce_date(start_date) if start_date
|
|
56
|
+
params[:end_date] = coerce_date(end_date) if end_date
|
|
57
|
+
get("/api/v1/opt_in_forms/#{id}/analytics", params)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def create_variant(id, name: nil, weight: nil)
|
|
61
|
+
body = {}
|
|
62
|
+
body[:name] = name unless name.nil?
|
|
63
|
+
body[:weight] = weight unless weight.nil?
|
|
64
|
+
post("/api/v1/opt_in_forms/#{id}/variants", body)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def duplicate(id, label: nil)
|
|
68
|
+
body = {}
|
|
69
|
+
body[:label] = label unless label.nil?
|
|
70
|
+
post("/api/v1/opt_in_forms/#{id}/duplicate", body)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def coerce_date(value)
|
|
76
|
+
case value
|
|
77
|
+
when Date, Time, DateTime then value.iso8601
|
|
78
|
+
else value.to_s
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
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
|
|
@@ -11,8 +27,34 @@ module Broadcast
|
|
|
11
27
|
get('/api/v1/subscribers/find.json', { email: email })
|
|
12
28
|
end
|
|
13
29
|
|
|
30
|
+
# Create or upsert a subscriber.
|
|
31
|
+
#
|
|
32
|
+
# Subscriber attributes (wrapped under `subscriber:` on the wire):
|
|
33
|
+
# email:, first_name:, last_name:, is_active:, source:,
|
|
34
|
+
# subscribed_at:, ip_address:, tags: [...], custom_data: {...}
|
|
35
|
+
#
|
|
36
|
+
# Top-level options (NOT wrapped under `subscriber:`):
|
|
37
|
+
# double_opt_in: true | { reply_to:, confirmation_template_id:, include_unsubscribe_link: }
|
|
38
|
+
# When set, the subscriber is created in unconfirmed state
|
|
39
|
+
# and a confirmation email is queued.
|
|
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)`.
|
|
14
49
|
def create(**attrs)
|
|
15
|
-
|
|
50
|
+
double_opt_in = attrs.delete(:double_opt_in)
|
|
51
|
+
confirmation_template_id = attrs.delete(:confirmation_template_id)
|
|
52
|
+
|
|
53
|
+
payload = { subscriber: attrs }
|
|
54
|
+
payload[:double_opt_in] = double_opt_in unless double_opt_in.nil?
|
|
55
|
+
payload[:confirmation_template_id] = confirmation_template_id unless confirmation_template_id.nil?
|
|
56
|
+
|
|
57
|
+
post('/api/v1/subscribers.json', payload)
|
|
16
58
|
end
|
|
17
59
|
|
|
18
60
|
def update(email, **attrs)
|
|
@@ -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
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Broadcast
|
|
4
|
+
module Resources
|
|
5
|
+
class Transactionals < Base
|
|
6
|
+
MAX_IDEMPOTENCY_KEY_LENGTH = 255
|
|
7
|
+
|
|
8
|
+
# Send a transactional email.
|
|
9
|
+
#
|
|
10
|
+
# Required:
|
|
11
|
+
# to: recipient email address
|
|
12
|
+
#
|
|
13
|
+
# One of subject/body or template_id is required (template_id resolves
|
|
14
|
+
# subject and body server-side; subject/body override the template).
|
|
15
|
+
#
|
|
16
|
+
# Optional:
|
|
17
|
+
# subject:, body:, preheader:
|
|
18
|
+
# reply_to:
|
|
19
|
+
# template_id: resolve subject/body/preheader from a Template
|
|
20
|
+
# include_unsubscribe_link: boolean
|
|
21
|
+
# double_opt_in: true | { reply_to:, confirmation_template_id:, include_unsubscribe_link: }
|
|
22
|
+
# Holds the email until the recipient confirms.
|
|
23
|
+
# confirmation_template_id: custom confirmation template (used with double_opt_in: true)
|
|
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.
|
|
43
|
+
# rubocop:disable Metrics/ParameterLists -- mirrors the API's flat param surface
|
|
44
|
+
def create(to:, subject: nil, body: nil, reply_to: nil, preheader: nil,
|
|
45
|
+
template_id: nil, include_unsubscribe_link: nil,
|
|
46
|
+
double_opt_in: nil, confirmation_template_id: nil,
|
|
47
|
+
subscriber: nil, idempotency_key: nil, **extra)
|
|
48
|
+
# rubocop:enable Metrics/ParameterLists
|
|
49
|
+
payload = { to: to }
|
|
50
|
+
payload[:subject] = subject unless subject.nil?
|
|
51
|
+
payload[:body] = body unless body.nil?
|
|
52
|
+
payload[:preheader] = preheader unless preheader.nil?
|
|
53
|
+
payload[:reply_to] = reply_to unless reply_to.nil?
|
|
54
|
+
payload[:template_id] = template_id unless template_id.nil?
|
|
55
|
+
payload[:include_unsubscribe_link] = include_unsubscribe_link unless include_unsubscribe_link.nil?
|
|
56
|
+
payload[:double_opt_in] = double_opt_in unless double_opt_in.nil?
|
|
57
|
+
payload[:confirmation_template_id] = confirmation_template_id unless confirmation_template_id.nil?
|
|
58
|
+
payload[:subscriber] = subscriber unless subscriber.nil?
|
|
59
|
+
payload.merge!(extra) unless extra.empty?
|
|
60
|
+
|
|
61
|
+
@client.request(:post, '/api/v1/transactionals.json', payload,
|
|
62
|
+
headers: idempotency_headers(idempotency_key))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def get_transactional(id)
|
|
66
|
+
get("/api/v1/transactionals/#{id}.json")
|
|
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
|
|
84
|
+
end
|
|
85
|
+
end
|
|
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
data/lib/broadcast/webhook.rb
CHANGED
|
@@ -7,6 +7,40 @@ module Broadcast
|
|
|
7
7
|
module Webhook
|
|
8
8
|
TIMESTAMP_TOLERANCE = 300 # 5 minutes
|
|
9
9
|
|
|
10
|
+
# Every event type a webhook endpoint can subscribe to, mirroring
|
|
11
|
+
# WebhookEndpoint::AVAILABLE_EVENT_TYPES server-side. Use these when
|
|
12
|
+
# creating an endpoint — an unknown event type is dropped silently.
|
|
13
|
+
EMAIL_EVENTS = %w[
|
|
14
|
+
email.sent email.delivered email.delivery_delayed email.complained
|
|
15
|
+
email.bounced email.opened email.clicked email.failed
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
SUBSCRIBER_EVENTS = %w[
|
|
19
|
+
subscriber.created subscriber.updated subscriber.deleted
|
|
20
|
+
subscriber.subscribed subscriber.unsubscribed subscriber.bounced
|
|
21
|
+
subscriber.complained
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
BROADCAST_EVENTS = %w[
|
|
25
|
+
broadcast.scheduled broadcast.queueing broadcast.sending broadcast.sent
|
|
26
|
+
broadcast.failed broadcast.partial_failure broadcast.aborted
|
|
27
|
+
broadcast.paused
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
SEQUENCE_EVENTS = %w[
|
|
31
|
+
sequence.subscriber_added sequence.subscriber_completed
|
|
32
|
+
sequence.subscriber_moved sequence.subscriber_removed
|
|
33
|
+
sequence.subscriber_paused sequence.subscriber_resumed
|
|
34
|
+
sequence.subscriber_error
|
|
35
|
+
].freeze
|
|
36
|
+
|
|
37
|
+
# Delivery-machinery events, not content events.
|
|
38
|
+
SYSTEM_EVENTS = %w[message.attempt.exhausted test.webhook].freeze
|
|
39
|
+
|
|
40
|
+
EVENT_TYPES = (
|
|
41
|
+
EMAIL_EVENTS + SUBSCRIBER_EVENTS + BROADCAST_EVENTS + SEQUENCE_EVENTS + SYSTEM_EVENTS
|
|
42
|
+
).freeze
|
|
43
|
+
|
|
10
44
|
module_function
|
|
11
45
|
|
|
12
46
|
def verify(payload, signature_header, timestamp_header, secret:, now: nil)
|
data/lib/broadcast.rb
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative 'broadcast/version'
|
|
4
4
|
require_relative 'broadcast/errors'
|
|
5
|
+
require_relative 'broadcast/response'
|
|
5
6
|
require_relative 'broadcast/configuration'
|
|
7
|
+
require_relative 'broadcast/debug_logger'
|
|
8
|
+
require_relative 'broadcast/connection'
|
|
6
9
|
require_relative 'broadcast/client'
|
|
7
10
|
require_relative 'broadcast/webhook'
|
|
8
11
|
require_relative 'broadcast/resources/base'
|
|
@@ -12,6 +15,12 @@ require_relative 'broadcast/resources/broadcasts'
|
|
|
12
15
|
require_relative 'broadcast/resources/segments'
|
|
13
16
|
require_relative 'broadcast/resources/templates'
|
|
14
17
|
require_relative 'broadcast/resources/webhook_endpoints'
|
|
18
|
+
require_relative 'broadcast/resources/transactionals'
|
|
19
|
+
require_relative 'broadcast/resources/opt_in_forms'
|
|
20
|
+
require_relative 'broadcast/resources/email_servers'
|
|
21
|
+
require_relative 'broadcast/resources/autopilots'
|
|
22
|
+
require_relative 'broadcast/resources/discovery'
|
|
23
|
+
require_relative 'broadcast/resources/migration'
|
|
15
24
|
|
|
16
25
|
# ActionMailer integration — only loaded when Rails is present
|
|
17
26
|
if defined?(Rails::Railtie)
|
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.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Simon Chiu
|
|
@@ -31,27 +31,36 @@ executables: []
|
|
|
31
31
|
extensions: []
|
|
32
32
|
extra_rdoc_files: []
|
|
33
33
|
files:
|
|
34
|
-
- ".rubocop.yml"
|
|
35
34
|
- CHANGELOG.md
|
|
36
35
|
- Gemfile
|
|
37
36
|
- Gemfile.lock
|
|
38
37
|
- LICENSE.txt
|
|
39
38
|
- README.md
|
|
40
39
|
- Rakefile
|
|
40
|
+
- SDK-COVERAGE.md
|
|
41
41
|
- lib/broadcast-ruby.rb
|
|
42
42
|
- lib/broadcast.rb
|
|
43
43
|
- lib/broadcast/client.rb
|
|
44
44
|
- lib/broadcast/configuration.rb
|
|
45
|
+
- lib/broadcast/connection.rb
|
|
46
|
+
- lib/broadcast/debug_logger.rb
|
|
45
47
|
- lib/broadcast/delivery_method.rb
|
|
46
48
|
- lib/broadcast/errors.rb
|
|
47
49
|
- lib/broadcast/railtie.rb
|
|
50
|
+
- lib/broadcast/resources/autopilots.rb
|
|
48
51
|
- lib/broadcast/resources/base.rb
|
|
49
52
|
- lib/broadcast/resources/broadcasts.rb
|
|
53
|
+
- lib/broadcast/resources/discovery.rb
|
|
54
|
+
- lib/broadcast/resources/email_servers.rb
|
|
55
|
+
- lib/broadcast/resources/migration.rb
|
|
56
|
+
- lib/broadcast/resources/opt_in_forms.rb
|
|
50
57
|
- lib/broadcast/resources/segments.rb
|
|
51
58
|
- lib/broadcast/resources/sequences.rb
|
|
52
59
|
- lib/broadcast/resources/subscribers.rb
|
|
53
60
|
- lib/broadcast/resources/templates.rb
|
|
61
|
+
- lib/broadcast/resources/transactionals.rb
|
|
54
62
|
- lib/broadcast/resources/webhook_endpoints.rb
|
|
63
|
+
- lib/broadcast/response.rb
|
|
55
64
|
- lib/broadcast/version.rb
|
|
56
65
|
- lib/broadcast/webhook.rb
|
|
57
66
|
homepage: https://github.com/send-broadcast/broadcast-ruby
|
|
@@ -77,7 +86,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
77
86
|
- !ruby/object:Gem::Version
|
|
78
87
|
version: '0'
|
|
79
88
|
requirements: []
|
|
80
|
-
rubygems_version:
|
|
89
|
+
rubygems_version: 4.0.16
|
|
81
90
|
specification_version: 4
|
|
82
91
|
summary: Ruby client for the Broadcast email platform
|
|
83
92
|
test_files: []
|