sendara 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 36b63c37d1614f2443d015ee439a65a2504238aa6f53608909b4d38ef686dd3c
4
- data.tar.gz: 31e7a59ebfd8b679dc2e27e518d1220d75e4dd60f68c1478244c663233493294
3
+ metadata.gz: 93eb9f0dc2bd668914efca68d65e72f614c23ea0d194f376a77aac5ed7327520
4
+ data.tar.gz: 826da708e6ea0519d6f48827315465261c39382d613008a31f5797f9d6e7d2c4
5
5
  SHA512:
6
- metadata.gz: e6189e38a294454656bb302f5009e1383fec9919e451c26f4b90ccdb59087aa3e1fd35369b68dd1eb505327c632a3f029b7f2c29bbbd2c86114fbcfae3f94013
7
- data.tar.gz: 99ea454fab47746eb0b36780a43c8b02ee9ffac82447b812b60681b092b8cd4bf4d72476d581535ee34dfaed0579b0082a280379b4259175d74b8cb94421a69b
6
+ metadata.gz: c117085ea0d5d9d82f4da39ca466080e04253b1c1ac2c25584b5841fa61951129c5f4d104f95586443b05592a26e3fe53e05cb040f5993a0559042b635342623
7
+ data.tar.gz: f8c4394b30342443ba05adf6505e03e8b42014561795319bf156f6e7243c3252ac59d34a4ef63ec384ed9ed775a49b932f6c1fe0b8df8dfd6d753b1f02d7ef4b
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Sendara Ruby
2
2
 
3
- Email-first Ruby client for the [Sendara](https://sendara.dev) API: transactional email, broadcasts, contacts, templates, domains, and signed webhooks. Pure stdlib HTTP, zero runtime dependencies.
3
+ Email-first Ruby client for the [Sendara](https://sendara.dev) API: transactional email, broadcasts, contacts, templates, domains, and signed webhooks. It uses Ruby's standard HTTP stack and declares the `base64` default gem for Ruby 3.4+ compatibility.
4
4
 
5
5
  Requires Ruby >= 3.0.
6
6
 
@@ -55,7 +55,7 @@ The client automatically attaches an `Idempotency-Key` to every write and retrie
55
55
 
56
56
  ### Sending email
57
57
 
58
- `emails.send` is keyword-based. `from`, `to`, and `subject` are the essentials; provide `html`, `text`, or both:
58
+ `emails.send` is keyword-based. `to` is always required; `from` is required after domain verification, and inline sends need a subject plus `html`, `text`, or both. Stored templates can provide their own content:
59
59
 
60
60
  ```ruby
61
61
  client.emails.send(
@@ -65,7 +65,9 @@ client.emails.send(
65
65
  html: "<p>Thanks for your order.</p>",
66
66
  text: "Thanks for your order.",
67
67
  message_type: "transactional",
68
- metadata: { "order_id" => "ord_123" }
68
+ metadata: { "order_id" => "ord_123" },
69
+ scheduled_at: "2027-01-15T09:00:00Z",
70
+ validate_recipient: true
69
71
  )
70
72
  ```
71
73
 
@@ -122,6 +124,19 @@ broadcast = client.broadcasts.create(
122
124
  client.broadcasts.send(broadcast["id"])
123
125
  ```
124
126
 
127
+ Pass `list_ids:` to send to several contact lists at once, and `exclude_list_ids:`
128
+ to subtract a list from that audience:
129
+
130
+ ```ruby
131
+ client.broadcasts.create(
132
+ from_email: "you@yourdomain.com",
133
+ subject: "What's new in June",
134
+ body_html: "<h1>June</h1>",
135
+ list_ids: %w[list_123 list_456],
136
+ exclude_list_ids: %w[list_789]
137
+ )
138
+ ```
139
+
125
140
  Schedule for later, or send immediately on create with `send_now: true`:
126
141
 
127
142
  ```ruby
@@ -130,8 +145,8 @@ client.broadcasts.create(
130
145
  name: "Launch",
131
146
  subject: "We're live",
132
147
  body_html: "<p>We launched.</p>",
133
- recipients: ["a@example.com", "b@example.com"],
134
- scheduled_at: "2026-07-01T09:00:00Z"
148
+ recipients: [{ "email" => "a@example.com" }, { "email" => "b@example.com" }],
149
+ scheduled_at: "2027-01-15T09:00:00Z"
135
150
  )
136
151
  ```
137
152
 
@@ -144,15 +159,16 @@ client.broadcasts.cancel("bcast_123")
144
159
  client.broadcasts.delete("bcast_123")
145
160
  ```
146
161
 
147
- To create-and-send an audience in a single request, use `bulk_send` (same arguments as `create`):
162
+ To create and immediately send an audience in one request, use `bulk_send`.
163
+ It accepts the same content and audience arguments as `create`, but not
164
+ `scheduled_at` or `send_now` because this endpoint always fans out immediately:
148
165
 
149
166
  ```ruby
150
167
  client.broadcasts.bulk_send(
151
168
  from_email: "you@yourdomain.com",
152
169
  subject: "Flash sale",
153
170
  body_html: "<p>24 hours only.</p>",
154
- audience_list_id: "list_123",
155
- send_now: true
171
+ audience_list_id: "list_123"
156
172
  )
157
173
  ```
158
174
 
@@ -189,6 +205,22 @@ client.messages.get("msg_123")
189
205
  client.messages.get(idempotency_key: "order-42")
190
206
  ```
191
207
 
208
+ ## Billing checkout
209
+
210
+ Checkout accepts `starter`, `pro`, `growth`, or `scale`, with a `month` or
211
+ `year` billing period. It returns a `Sendara::CheckoutResult` for a new hosted
212
+ checkout or an in-place subscription change:
213
+
214
+ ```ruby
215
+ checkout = client.billing.checkout(plan: :growth, period: :year)
216
+
217
+ if checkout.updated?
218
+ puts "Subscription changed to #{checkout.plan}"
219
+ else
220
+ redirect_to checkout.url
221
+ end
222
+ ```
223
+
192
224
  ## Webhook verification
193
225
 
194
226
  Verify the signature on incoming webhooks with `Sendara::Webhooks.verify`. Pass the **raw request body** (not parsed), the request headers, and your signing secret. On success it returns the parsed event payload; on failure it raises `Sendara::WebhookVerificationError`.
@@ -202,7 +234,7 @@ event = Sendara::Webhooks.verify(
202
234
  ENV.fetch("SENDARA_WEBHOOK_SECRET")
203
235
  )
204
236
 
205
- event["type"] # => "email.delivered"
237
+ event["event_type"] # => "delivered"
206
238
  ```
207
239
 
208
240
  `verify` checks the `Sendara-Signature` HMAC and rejects requests whose `Sendara-Timestamp` is outside the tolerance window (default 300 seconds). Adjust it if needed:
@@ -297,9 +329,9 @@ class SendaraWebhooksController < ActionController::API
297
329
  Rails.application.credentials.sendara_webhook_secret
298
330
  )
299
331
 
300
- case event["type"]
301
- when "email.delivered" then handle_delivered(event)
302
- when "email.bounced" then handle_bounced(event)
332
+ case event["event_type"]
333
+ when "delivered" then handle_delivered(event)
334
+ when "bounced" then handle_bounced(event)
303
335
  end
304
336
 
305
337
  head :ok
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sendara
4
+ # Result of POST /v1/billing/checkout. A new subscription has a URL to
5
+ # redirect to; an existing subscription reports the plan changed in place.
6
+ class CheckoutResult
7
+ PLANS = %w[starter pro growth scale].freeze
8
+
9
+ attr_reader :url, :plan
10
+
11
+ def self.from_response(response)
12
+ url = response["url"]
13
+ return new(url: url, updated: false) if url.is_a?(String) && !url.empty?
14
+
15
+ plan = response["plan"]
16
+ if response["updated"] == true && PLANS.include?(plan)
17
+ return new(updated: true, plan: plan)
18
+ end
19
+
20
+ raise Error, "Billing checkout returned neither a checkout URL nor an updated plan"
21
+ end
22
+
23
+ def initialize(url: nil, updated:, plan: nil)
24
+ @url = url
25
+ @updated = updated
26
+ @plan = plan
27
+ freeze
28
+ end
29
+
30
+ def updated?
31
+ @updated
32
+ end
33
+
34
+ def redirect?
35
+ !@url.nil?
36
+ end
37
+ end
38
+ end
@@ -36,7 +36,8 @@ module Sendara
36
36
 
37
37
  headers = {
38
38
  "Authorization" => "Bearer #{@api_key}",
39
- "Accept" => "application/json"
39
+ "Accept" => "application/json",
40
+ "User-Agent" => "sendara-ruby/#{VERSION}"
40
41
  }
41
42
 
42
43
  raw_body = nil
@@ -3,14 +3,22 @@
3
3
  module Sendara
4
4
  module Resources
5
5
  class Billing < Resource
6
+ PLANS = %w[starter pro growth scale].freeze
7
+ PERIODS = %w[month year].freeze
8
+
6
9
  def get
7
10
  request(:get, "/v1/billing") || {}
8
11
  end
9
12
 
10
13
  def checkout(plan: nil, period: nil)
14
+ plan = plan.to_s unless plan.nil?
15
+ period = period.to_s unless period.nil?
16
+ raise ArgumentError, "plan must be starter, pro, growth, or scale" if plan && !PLANS.include?(plan)
17
+ raise ArgumentError, "period must be month or year" if period && !PERIODS.include?(period)
18
+
11
19
  body = compact_params("plan" => plan, "period" => period)
12
20
  response = request(:post, "/v1/billing/checkout", body: body) || {}
13
- response["url"].to_s
21
+ CheckoutResult.from_response(response)
14
22
  end
15
23
 
16
24
  def portal
@@ -16,11 +16,13 @@ module Sendara
16
16
 
17
17
  def create(from_email:, name: nil, subject: nil, body_html: nil, body_text: nil,
18
18
  template_id: nil, message_type: nil, audience_list_id: nil,
19
+ list_ids: nil, exclude_list_ids: nil,
19
20
  recipients: nil, scheduled_at: nil, send_now: nil)
20
21
  request(:post, "/v1/broadcasts", body: broadcast_body(
21
22
  from_email: from_email, name: name, subject: subject, body_html: body_html,
22
23
  body_text: body_text, template_id: template_id, message_type: message_type,
23
- audience_list_id: audience_list_id, recipients: recipients,
24
+ audience_list_id: audience_list_id, list_ids: list_ids,
25
+ exclude_list_ids: exclude_list_ids, recipients: recipients,
24
26
  scheduled_at: scheduled_at, send_now: send_now
25
27
  )) || {}
26
28
  end
@@ -40,19 +42,21 @@ module Sendara
40
42
 
41
43
  def bulk_send(from_email:, name: nil, subject: nil, body_html: nil, body_text: nil,
42
44
  template_id: nil, message_type: nil, audience_list_id: nil,
43
- recipients: nil, scheduled_at: nil, send_now: nil)
45
+ list_ids: nil, exclude_list_ids: nil, recipients: nil)
44
46
  request(:post, "/v1/send/bulk", body: broadcast_body(
45
47
  from_email: from_email, name: name, subject: subject, body_html: body_html,
46
48
  body_text: body_text, template_id: template_id, message_type: message_type,
47
- audience_list_id: audience_list_id, recipients: recipients,
48
- scheduled_at: scheduled_at, send_now: send_now
49
+ audience_list_id: audience_list_id, list_ids: list_ids,
50
+ exclude_list_ids: exclude_list_ids, recipients: recipients,
51
+ scheduled_at: nil, send_now: nil
49
52
  )) || {}
50
53
  end
51
54
 
52
55
  private
53
56
 
54
57
  def broadcast_body(from_email:, name:, subject:, body_html:, body_text:, template_id:,
55
- message_type:, audience_list_id:, recipients:, scheduled_at:, send_now:)
58
+ message_type:, audience_list_id:, list_ids:, exclude_list_ids:,
59
+ recipients:, scheduled_at:, send_now:)
56
60
  compact_params(
57
61
  "name" => name,
58
62
  "from_email" => from_email,
@@ -62,6 +66,8 @@ module Sendara
62
66
  "template_id" => template_id,
63
67
  "message_type" => message_type,
64
68
  "audience_list_id" => audience_list_id,
69
+ "list_ids" => list_ids,
70
+ "exclude_list_ids" => exclude_list_ids,
65
71
  "recipients" => recipients,
66
72
  "scheduled_at" => scheduled_at,
67
73
  "send_now" => send_now
@@ -5,27 +5,37 @@ module Sendara
5
5
  class Emails < Resource
6
6
  def send(to:, from: nil, subject: nil, html: nil, text: nil, message_type: nil,
7
7
  template_id: nil, template_vars: nil, metadata: nil, store_payload: nil,
8
- test_send: nil, idempotency_key: nil)
8
+ test_send: nil, scheduled_at: nil, validate_recipient: nil, idempotency_key: nil)
9
+ raise ArgumentError, "to is required" if to.nil? || to.to_s.empty?
10
+
9
11
  meta = metadata.nil? ? {} : metadata.dup
10
12
  meta["from_email"] = from unless from.nil?
11
13
 
12
14
  body = {
13
15
  "channel" => "email",
14
16
  "idempotency_key" => idempotency_key || Client.generate_idempotency_key,
15
- "destination" => { "email" => to },
16
- "payload" => {
17
- "subject" => subject,
18
- "body_html" => html,
19
- "body_text" => text
20
- },
21
- "metadata" => meta
17
+ "destination" => { "email" => to }
22
18
  }
19
+ body["metadata"] = meta unless meta.empty?
20
+
21
+ # Only the fields the caller supplied go on the wire, and "payload" is
22
+ # dropped outright when none were: a template-only send would otherwise
23
+ # ship nulls the server discards when it renders the template over them.
24
+ # Shared by all five SDKs.
25
+ payload = compact_params(
26
+ "subject" => subject,
27
+ "body_html" => html,
28
+ "body_text" => text
29
+ ).reject { |_key, value| value.to_s.empty? }
30
+ body["payload"] = payload unless payload.empty?
23
31
 
24
32
  body["message_type"] = message_type unless message_type.nil?
25
33
  body["template_id"] = template_id unless template_id.nil?
26
34
  body["template_vars"] = template_vars unless template_vars.nil?
27
35
  body["store_payload"] = store_payload unless store_payload.nil?
28
36
  body["test_send"] = test_send unless test_send.nil?
37
+ body["scheduled_at"] = scheduled_at unless scheduled_at.nil?
38
+ body["validate_recipient"] = validate_recipient unless validate_recipient.nil?
29
39
 
30
40
  send_raw(body)
31
41
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sendara
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/sendara.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative "sendara/version"
4
4
  require_relative "sendara/errors"
5
5
  require_relative "sendara/message_page"
6
+ require_relative "sendara/checkout_result"
6
7
  require_relative "sendara/resource"
7
8
  require_relative "sendara/resources/emails"
8
9
  require_relative "sendara/resources/broadcasts"
data/sendara.gemspec CHANGED
@@ -11,7 +11,7 @@ Gem::Specification.new do |spec|
11
11
  spec.summary = "Sendara — email & marketing API client for Ruby."
12
12
  spec.description = "Email-first Ruby client for the Sendara API: transactional email, " \
13
13
  "broadcasts, contacts, templates, domains, and signed webhooks. " \
14
- "Pure stdlib HTTP, zero runtime dependencies."
14
+ "Pure stdlib HTTP with the Ruby base64 default gem."
15
15
  spec.homepage = "https://sendara.dev"
16
16
  spec.license = "MIT"
17
17
  spec.required_ruby_version = ">= 3.0"
@@ -26,5 +26,6 @@ Gem::Specification.new do |spec|
26
26
  spec.files = Dir.glob("lib/**/*.rb") + %w[README.md LICENSE sendara.gemspec].select { |f| File.exist?(f) }
27
27
  spec.require_paths = ["lib"]
28
28
 
29
+ spec.add_dependency "base64", ">= 0.1"
29
30
  spec.add_development_dependency "rspec", "~> 3"
30
31
  end
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sendara
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
  - Sendara
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-28 00:00:00.000000000 Z
11
+ date: 2026-07-22 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: base64
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0.1'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: rspec
15
29
  requirement: !ruby/object:Gem::Requirement
@@ -25,8 +39,8 @@ dependencies:
25
39
  - !ruby/object:Gem::Version
26
40
  version: '3'
27
41
  description: 'Email-first Ruby client for the Sendara API: transactional email, broadcasts,
28
- contacts, templates, domains, and signed webhooks. Pure stdlib HTTP, zero runtime
29
- dependencies.'
42
+ contacts, templates, domains, and signed webhooks. Pure stdlib HTTP with the Ruby
43
+ base64 default gem.'
30
44
  email:
31
45
  - support@sendara.dev
32
46
  executables: []
@@ -35,6 +49,7 @@ extra_rdoc_files: []
35
49
  files:
36
50
  - README.md
37
51
  - lib/sendara.rb
52
+ - lib/sendara/checkout_result.rb
38
53
  - lib/sendara/client.rb
39
54
  - lib/sendara/errors.rb
40
55
  - lib/sendara/generators/install_generator.rb