broadcast-ruby 0.2.0 → 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.
@@ -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)
@@ -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('/api/v1/transactionals.json', payload)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Broadcast
4
- VERSION = '0.2.0'
4
+ VERSION = '0.3.0'
5
5
  end
@@ -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'
@@ -15,6 +18,9 @@ require_relative 'broadcast/resources/webhook_endpoints'
15
18
  require_relative 'broadcast/resources/transactionals'
16
19
  require_relative 'broadcast/resources/opt_in_forms'
17
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'
18
24
 
19
25
  # ActionMailer integration — only loaded when Rails is present
20
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.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Simon Chiu
@@ -31,23 +31,28 @@ 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
50
54
  - lib/broadcast/resources/email_servers.rb
55
+ - lib/broadcast/resources/migration.rb
51
56
  - lib/broadcast/resources/opt_in_forms.rb
52
57
  - lib/broadcast/resources/segments.rb
53
58
  - lib/broadcast/resources/sequences.rb
@@ -55,6 +60,7 @@ files:
55
60
  - lib/broadcast/resources/templates.rb
56
61
  - lib/broadcast/resources/transactionals.rb
57
62
  - lib/broadcast/resources/webhook_endpoints.rb
63
+ - lib/broadcast/response.rb
58
64
  - lib/broadcast/version.rb
59
65
  - lib/broadcast/webhook.rb
60
66
  homepage: https://github.com/send-broadcast/broadcast-ruby
@@ -80,7 +86,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
80
86
  - !ruby/object:Gem::Version
81
87
  version: '0'
82
88
  requirements: []
83
- rubygems_version: 3.7.2
89
+ rubygems_version: 4.0.16
84
90
  specification_version: 4
85
91
  summary: Ruby client for the Broadcast email platform
86
92
  test_files: []
data/.rubocop.yml DELETED
@@ -1,50 +0,0 @@
1
- AllCops:
2
- TargetRubyVersion: 3.2
3
- NewCops: enable
4
- SuggestExtensions: false
5
-
6
- Style/StringLiterals:
7
- EnforcedStyle: single_quotes
8
-
9
- Style/StringLiteralsInInterpolation:
10
- EnforcedStyle: single_quotes
11
-
12
- Style/Documentation:
13
- Enabled: false
14
-
15
- Metrics/MethodLength:
16
- Max: 25
17
- Exclude:
18
- - test/**/*
19
-
20
- Metrics/AbcSize:
21
- Max: 35
22
- Exclude:
23
- - test/**/*
24
-
25
- Metrics/ClassLength:
26
- Max: 200
27
- Exclude:
28
- - test/**/*
29
-
30
- Metrics/CyclomaticComplexity:
31
- Max: 15
32
-
33
- Metrics/PerceivedComplexity:
34
- Max: 15
35
-
36
- Layout/LineLength:
37
- Max: 120
38
-
39
- Naming/FileName:
40
- Exclude:
41
- - lib/broadcast-ruby.rb
42
-
43
- Naming/MethodParameterName:
44
- AllowedNames:
45
- - a
46
- - b
47
- - id
48
- - k
49
- - v
50
- - to