sendara 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d4dc1a93353c4f74c9858a5c0456819cf09d8822bfc2d85e52380310800ab361
4
+ data.tar.gz: 01b26945825759c360b80e0fa82be866b76daeb75632a2aae4800b79eb7024a4
5
+ SHA512:
6
+ metadata.gz: d2ff26bc76b493d4f12dabfeba8aecf5bee14192bc2bae40fc31ba7e85f6c4116b89b1d5293912869f8278588c3729e7ceecbc6e431bb7b5ef4793a835059b93
7
+ data.tar.gz: 1792963bb32043679409cc010483e1fb7a0cb9fa9af8019656cce503370ec1b8e6b8130dc47ed7d27f99c1b3c91d1d45026d6281fa9bb630a1742f3c93759f55
data/README.md ADDED
@@ -0,0 +1,313 @@
1
+ # Sendara Ruby
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.
4
+
5
+ Requires Ruby >= 3.0.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ gem install sendara
11
+ ```
12
+
13
+ Or with Bundler, add to your `Gemfile`:
14
+
15
+ ```ruby
16
+ gem "sendara"
17
+ ```
18
+
19
+ Then run:
20
+
21
+ ```bash
22
+ bundle install
23
+ ```
24
+
25
+ ## Quickstart
26
+
27
+ ```ruby
28
+ require "sendara"
29
+
30
+ client = Sendara.new(ENV.fetch("SENDARA_API_KEY"))
31
+
32
+ result = client.emails.send(
33
+ from: "you@yourdomain.com",
34
+ to: "customer@example.com",
35
+ subject: "Welcome to Acme",
36
+ html: "<h1>Hello</h1><p>Thanks for signing up.</p>",
37
+ text: "Hello — thanks for signing up."
38
+ )
39
+
40
+ puts result["id"]
41
+ ```
42
+
43
+ `Sendara.new` accepts the API key as the first argument plus optional keywords:
44
+
45
+ ```ruby
46
+ client = Sendara.new(
47
+ ENV.fetch("SENDARA_API_KEY"),
48
+ base_url: "https://api.sendara.dev",
49
+ timeout: 30,
50
+ max_retries: 2
51
+ )
52
+ ```
53
+
54
+ The client automatically attaches an `Idempotency-Key` to every write and retries idempotent requests with exponential backoff (honoring `Retry-After`).
55
+
56
+ ### Sending email
57
+
58
+ `emails.send` is keyword-based. `from`, `to`, and `subject` are the essentials; provide `html`, `text`, or both:
59
+
60
+ ```ruby
61
+ client.emails.send(
62
+ from: "you@yourdomain.com",
63
+ to: "customer@example.com",
64
+ subject: "Your receipt",
65
+ html: "<p>Thanks for your order.</p>",
66
+ text: "Thanks for your order.",
67
+ message_type: "transactional",
68
+ metadata: { "order_id" => "ord_123" }
69
+ )
70
+ ```
71
+
72
+ Send with a stored template instead of inline content:
73
+
74
+ ```ruby
75
+ client.emails.send(
76
+ from: "you@yourdomain.com",
77
+ to: "customer@example.com",
78
+ subject: "Your receipt",
79
+ template_id: "tmpl_abc",
80
+ template_vars: { "name" => "Ada", "total" => "$42.00" }
81
+ )
82
+ ```
83
+
84
+ Pass your own `idempotency_key` to make retries safe across process restarts:
85
+
86
+ ```ruby
87
+ client.emails.send(
88
+ from: "you@yourdomain.com",
89
+ to: "customer@example.com",
90
+ subject: "Your receipt",
91
+ html: "<p>Thanks!</p>",
92
+ idempotency_key: "receipt-ord_123"
93
+ )
94
+ ```
95
+
96
+ For full control over the request envelope, use `send_raw`, or `send_batch` for many messages in one call:
97
+
98
+ ```ruby
99
+ client.emails.send_raw({
100
+ "channel" => "email",
101
+ "destination" => { "email" => "customer@example.com" },
102
+ "payload" => { "subject" => "Hi", "body_html" => "<p>Hi</p>" }
103
+ })
104
+
105
+ client.emails.send_batch([request_a, request_b, request_c])
106
+ ```
107
+
108
+ ## Broadcasts
109
+
110
+ Broadcasts send one email to an audience — a saved list or an inline set of recipients.
111
+
112
+ ```ruby
113
+ broadcast = client.broadcasts.create(
114
+ from_email: "you@yourdomain.com",
115
+ name: "June newsletter",
116
+ subject: "What's new in June",
117
+ body_html: "<h1>June</h1><p>Updates inside.</p>",
118
+ body_text: "June updates inside.",
119
+ audience_list_id: "list_123"
120
+ )
121
+
122
+ client.broadcasts.send(broadcast["id"])
123
+ ```
124
+
125
+ Schedule for later, or send immediately on create with `send_now: true`:
126
+
127
+ ```ruby
128
+ client.broadcasts.create(
129
+ from_email: "you@yourdomain.com",
130
+ name: "Launch",
131
+ subject: "We're live",
132
+ body_html: "<p>We launched.</p>",
133
+ recipients: ["a@example.com", "b@example.com"],
134
+ scheduled_at: "2026-07-01T09:00:00Z"
135
+ )
136
+ ```
137
+
138
+ List, fetch, cancel, and delete:
139
+
140
+ ```ruby
141
+ client.broadcasts.list(limit: 20, offset: 0)
142
+ client.broadcasts.get("bcast_123")
143
+ client.broadcasts.cancel("bcast_123")
144
+ client.broadcasts.delete("bcast_123")
145
+ ```
146
+
147
+ To create-and-send an audience in a single request, use `bulk_send` (same arguments as `create`):
148
+
149
+ ```ruby
150
+ client.broadcasts.bulk_send(
151
+ from_email: "you@yourdomain.com",
152
+ subject: "Flash sale",
153
+ body_html: "<p>24 hours only.</p>",
154
+ audience_list_id: "list_123",
155
+ send_now: true
156
+ )
157
+ ```
158
+
159
+ ## Messages & pagination
160
+
161
+ Fetch a single page with `messages.page`, which returns a `Sendara::MessagePage`:
162
+
163
+ ```ruby
164
+ page = client.messages.page(status: "delivered", limit: 50)
165
+
166
+ page.messages.each { |message| puts message["id"] }
167
+ page.has_more? # => true / false
168
+ page.next_cursor # => cursor string or nil
169
+ ```
170
+
171
+ `messages.each` iterates across **all** pages transparently, following the cursor for you:
172
+
173
+ ```ruby
174
+ client.messages.each(status: "bounced") do |message|
175
+ puts "#{message['id']} → #{message['to']}"
176
+ end
177
+ ```
178
+
179
+ Called without a block it returns an `Enumerator`, so the full `Enumerable` API is available:
180
+
181
+ ```ruby
182
+ recent = client.messages.each(channel: "email", limit: 100).first(10)
183
+ ```
184
+
185
+ Filters: `channel`, `status`, `from`, `to`, `limit`, `cursor`. Fetch one message by id:
186
+
187
+ ```ruby
188
+ client.messages.get("msg_123")
189
+ ```
190
+
191
+ ## Webhook verification
192
+
193
+ 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`.
194
+
195
+ ```ruby
196
+ require "sendara"
197
+
198
+ event = Sendara::Webhooks.verify(
199
+ raw_body,
200
+ request.headers,
201
+ ENV.fetch("SENDARA_WEBHOOK_SECRET")
202
+ )
203
+
204
+ event["type"] # => "email.delivered"
205
+ ```
206
+
207
+ `verify` checks the `Sendara-Signature` HMAC and rejects requests whose `Sendara-Timestamp` is outside the tolerance window (default 300 seconds). Adjust it if needed:
208
+
209
+ ```ruby
210
+ Sendara::Webhooks.verify(raw_body, headers, secret, tolerance: 600)
211
+ ```
212
+
213
+ ## Error handling
214
+
215
+ API responses outside the 2xx range raise `Sendara::ApiError`, which exposes the HTTP `status`, the machine-readable `code`, the `request_id`, and `retry_after` (seconds, when the server sent it):
216
+
217
+ ```ruby
218
+ begin
219
+ client.emails.send(
220
+ from: "you@yourdomain.com",
221
+ to: "customer@example.com",
222
+ subject: "Hi",
223
+ html: "<p>Hi</p>"
224
+ )
225
+ rescue Sendara::ApiError => e
226
+ warn "#{e.code} (HTTP #{e.status}): #{e.message}"
227
+ warn "request_id=#{e.request_id}"
228
+ raise unless e.code == "rate_limited"
229
+ sleep(e.retry_after || 1)
230
+ retry
231
+ end
232
+ ```
233
+
234
+ The exception hierarchy, all under `Sendara::Error`:
235
+
236
+ | Class | Raised when |
237
+ | --- | --- |
238
+ | `Sendara::ApiError` | The API returned a non-2xx response. Has `status`, `code`, `request_id`, `retry_after`. |
239
+ | `Sendara::ConnectionError` | The request could not reach the API (DNS, TLS, socket). |
240
+ | `Sendara::TimeoutError` | The request exceeded the configured timeout. Subclass of `ConnectionError`. |
241
+ | `Sendara::WebhookVerificationError` | A webhook signature, timestamp, or body failed verification. |
242
+
243
+ Rescue `Sendara::Error` to catch everything from the gem:
244
+
245
+ ```ruby
246
+ rescue Sendara::Error => e
247
+ # any Sendara failure
248
+ end
249
+ ```
250
+
251
+ ## Rails usage
252
+
253
+ Build one client and share it. An initializer works well:
254
+
255
+ ```ruby
256
+ # config/initializers/sendara.rb
257
+ require "sendara"
258
+
259
+ SENDARA = Sendara.new(Rails.application.credentials.sendara_api_key)
260
+ ```
261
+
262
+ Send from anywhere, ideally off the request cycle in a background job:
263
+
264
+ ```ruby
265
+ # app/jobs/welcome_email_job.rb
266
+ class WelcomeEmailJob < ApplicationJob
267
+ queue_as :default
268
+
269
+ def perform(user)
270
+ SENDARA.emails.send(
271
+ from: "hello@yourdomain.com",
272
+ to: user.email,
273
+ subject: "Welcome to Acme",
274
+ html: WelcomeMailer.render(user),
275
+ idempotency_key: "welcome-#{user.id}"
276
+ )
277
+ rescue Sendara::ApiError => e
278
+ Rails.logger.error("sendara send failed: #{e.code} #{e.message} (#{e.request_id})")
279
+ raise
280
+ end
281
+ end
282
+ ```
283
+
284
+ Receiving webhooks — verify against the **raw** body, which Rails exposes via `request.raw_post`:
285
+
286
+ ```ruby
287
+ # config/routes.rb
288
+ post "/webhooks/sendara", to: "sendara_webhooks#receive"
289
+
290
+ # app/controllers/sendara_webhooks_controller.rb
291
+ class SendaraWebhooksController < ActionController::API
292
+ def receive
293
+ event = Sendara::Webhooks.verify(
294
+ request.raw_post,
295
+ request.headers,
296
+ Rails.application.credentials.sendara_webhook_secret
297
+ )
298
+
299
+ case event["type"]
300
+ when "email.delivered" then handle_delivered(event)
301
+ when "email.bounced" then handle_bounced(event)
302
+ end
303
+
304
+ head :ok
305
+ rescue Sendara::WebhookVerificationError
306
+ head :bad_request
307
+ end
308
+ end
309
+ ```
310
+
311
+ ## License
312
+
313
+ MIT
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "securerandom"
6
+ require "uri"
7
+
8
+ module Sendara
9
+ class Client
10
+ DEFAULT_BASE_URL = "https://api.sendara.dev"
11
+ DEFAULT_TIMEOUT = 30
12
+ DEFAULT_MAX_RETRIES = 2
13
+
14
+ RETRY_BASE_DELAY = 0.5
15
+ RETRY_MAX_DELAY = 8.0
16
+ WRITE_METHODS = %w[POST PUT PATCH].freeze
17
+ RETRIABLE_METHODS = %w[GET HEAD PUT DELETE].freeze
18
+
19
+ attr_reader :base_url, :timeout, :max_retries
20
+
21
+ def initialize(api_key, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT,
22
+ max_retries: DEFAULT_MAX_RETRIES, transport: nil)
23
+ raise Error, "An API key is required" if api_key.nil? || api_key.to_s.empty?
24
+
25
+ @api_key = api_key.to_s
26
+ @base_url = base_url.to_s.sub(%r{/+\z}, "")
27
+ @timeout = Integer(timeout)
28
+ @max_retries = [0, Integer(max_retries)].max
29
+ @transport = transport || method(:net_http_transport)
30
+ @resources = {}
31
+ end
32
+
33
+ def request(method, path, body: nil, query: {})
34
+ method = method.to_s.upcase
35
+ url = @base_url + path + build_query(query)
36
+
37
+ headers = {
38
+ "Authorization" => "Bearer #{@api_key}",
39
+ "Accept" => "application/json"
40
+ }
41
+
42
+ raw_body = nil
43
+ unless body.nil?
44
+ headers["Content-Type"] = "application/json"
45
+ raw_body = JSON.generate(body)
46
+ end
47
+
48
+ headers["Idempotency-Key"] = SecureRandom.uuid if WRITE_METHODS.include?(method)
49
+
50
+ idempotent = RETRIABLE_METHODS.include?(method)
51
+ max_attempts = idempotent ? @max_retries + 1 : 1
52
+
53
+ last_error = nil
54
+ attempt = 0
55
+
56
+ while attempt < max_attempts
57
+ begin
58
+ response = @transport.call(method, url, headers, raw_body, @timeout)
59
+ rescue ConnectionError => e
60
+ last_error = e
61
+ if attempt < max_attempts - 1
62
+ sleep_for(backoff_delay(attempt, nil))
63
+ attempt += 1
64
+ next
65
+ end
66
+ raise
67
+ end
68
+
69
+ status = response[:status].to_i
70
+ response_headers = response[:headers] || {}
71
+ response_body = response[:body].to_s
72
+
73
+ if status >= 200 && status < 300
74
+ return nil if status == 204 || response_body.empty?
75
+
76
+ return parse_json(response_body)
77
+ end
78
+
79
+ error = error_from_response(status, response_body, response_headers)
80
+ if idempotent && attempt < max_attempts - 1 && retriable_status?(status)
81
+ last_error = error
82
+ sleep_for(backoff_delay(attempt, retry_after_seconds(response_headers)))
83
+ attempt += 1
84
+ next
85
+ end
86
+
87
+ raise error
88
+ end
89
+
90
+ raise last_error || ConnectionError.new("Request failed after retries")
91
+ end
92
+
93
+ def emails = resource(:emails, Resources::Emails)
94
+ def broadcasts = resource(:broadcasts, Resources::Broadcasts)
95
+ def messages = resource(:messages, Resources::Messages)
96
+ def contacts = resource(:contacts, Resources::Contacts)
97
+ def lists = resource(:lists, Resources::Lists)
98
+ def domains = resource(:domains, Resources::Domains)
99
+ def templates = resource(:templates, Resources::Templates)
100
+ def suppressions = resource(:suppressions, Resources::Suppressions)
101
+ def usage = resource(:usage, Resources::Usage)
102
+ def api_keys = resource(:api_keys, Resources::ApiKeys)
103
+ def billing = resource(:billing, Resources::Billing)
104
+
105
+ def self.generate_idempotency_key
106
+ SecureRandom.uuid
107
+ end
108
+
109
+ private
110
+
111
+ def resource(name, klass)
112
+ @resources[name] ||= klass.new(self)
113
+ end
114
+
115
+ def parse_json(text)
116
+ JSON.parse(text)
117
+ rescue JSON::ParserError
118
+ nil
119
+ end
120
+
121
+ def build_query(query)
122
+ return "" if query.nil? || query.empty?
123
+
124
+ pairs = query.each_with_object([]) do |(key, value), acc|
125
+ next if value.nil? || value == ""
126
+
127
+ value = value ? "true" : "false" if value == true || value == false
128
+ acc << [key.to_s, value.to_s]
129
+ end
130
+ return "" if pairs.empty?
131
+
132
+ "?" + URI.encode_www_form(pairs)
133
+ end
134
+
135
+ def retriable_status?(status)
136
+ status == 429 || status >= 500
137
+ end
138
+
139
+ def retry_after_seconds(headers)
140
+ value = header_value(headers, "retry-after")
141
+ return nil if value.nil?
142
+
143
+ return value.to_i if value.match?(/\A\d+\z/)
144
+
145
+ timestamp = (Time.httpdate(value).to_i rescue nil)
146
+ return nil if timestamp.nil?
147
+
148
+ [0, timestamp - Time.now.to_i].max
149
+ end
150
+
151
+ def backoff_delay(attempt, retry_after)
152
+ return retry_after.to_f if !retry_after.nil? && retry_after >= 0
153
+
154
+ exp = [RETRY_MAX_DELAY, RETRY_BASE_DELAY * (2**attempt)].min
155
+ half = exp / 2.0
156
+ half + (rand * half)
157
+ end
158
+
159
+ def sleep_for(seconds)
160
+ sleep(seconds) if seconds.positive?
161
+ end
162
+
163
+ def error_from_response(status, body, headers)
164
+ code = "error"
165
+ message = body.empty? ? "HTTP #{status}" : body
166
+
167
+ decoded = body.empty? ? nil : (JSON.parse(body) rescue nil)
168
+ if decoded.is_a?(Hash)
169
+ envelope = decoded["error"]
170
+ if envelope.is_a?(Hash)
171
+ code = (envelope["code"] || code).to_s
172
+ message = (envelope["message"] || message).to_s
173
+ elsif decoded.key?("message")
174
+ code = (decoded["code"] || code).to_s
175
+ message = decoded["message"].to_s
176
+ end
177
+ end
178
+
179
+ request_id = header_value(headers, "x-request-id")
180
+ retry_after_header = header_value(headers, "retry-after")
181
+ retry_after = retry_after_header&.match?(/\A\d+\z/) ? retry_after_header.to_i : nil
182
+
183
+ ApiError.new(message, status: status, code: code, request_id: request_id, retry_after: retry_after)
184
+ end
185
+
186
+ def header_value(headers, name)
187
+ lower = name.downcase
188
+ headers.each do |key, value|
189
+ next unless key.to_s.downcase == lower
190
+
191
+ value = value.first if value.is_a?(Array)
192
+ return value.nil? ? nil : value.to_s
193
+ end
194
+ nil
195
+ end
196
+
197
+ def net_http_transport(method, url, headers, body, timeout)
198
+ uri = URI.parse(url)
199
+ http = Net::HTTP.new(uri.host, uri.port)
200
+ http.use_ssl = uri.scheme == "https"
201
+ http.open_timeout = timeout
202
+ http.read_timeout = timeout
203
+ http.write_timeout = timeout if http.respond_to?(:write_timeout=)
204
+
205
+ request_class = net_http_request_class(method)
206
+ request = request_class.new(uri.request_uri)
207
+ headers.each { |name, value| request[name] = value }
208
+ request.body = body unless body.nil?
209
+
210
+ response = http.request(request)
211
+
212
+ {
213
+ status: response.code.to_i,
214
+ headers: flatten_headers(response),
215
+ body: response.body.to_s
216
+ }
217
+ rescue Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout => e
218
+ raise TimeoutError, "Request timed out: #{e.message}"
219
+ rescue SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError => e
220
+ raise ConnectionError, "Network request failed: #{e.message}"
221
+ end
222
+
223
+ def net_http_request_class(method)
224
+ {
225
+ "GET" => Net::HTTP::Get,
226
+ "HEAD" => Net::HTTP::Head,
227
+ "POST" => Net::HTTP::Post,
228
+ "PUT" => Net::HTTP::Put,
229
+ "PATCH" => Net::HTTP::Patch,
230
+ "DELETE" => Net::HTTP::Delete
231
+ }.fetch(method) { raise Error, "Unsupported HTTP method: #{method}" }
232
+ end
233
+
234
+ def flatten_headers(response)
235
+ headers = {}
236
+ response.each_header { |name, value| headers[name] = value }
237
+ headers
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sendara
4
+ class Error < StandardError; end
5
+
6
+ class ApiError < Error
7
+ attr_reader :status, :code, :request_id, :retry_after
8
+
9
+ def initialize(message, status:, code: "error", request_id: nil, retry_after: nil)
10
+ super(message)
11
+ @status = status
12
+ @code = code
13
+ @request_id = request_id
14
+ @retry_after = retry_after
15
+ end
16
+ end
17
+
18
+ class ConnectionError < Error; end
19
+
20
+ class TimeoutError < ConnectionError; end
21
+
22
+ class WebhookVerificationError < Error; end
23
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module Sendara
6
+ module Generators
7
+ class InstallGenerator < ::Rails::Generators::Base
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ desc "Creates a Sendara initializer at config/initializers/sendara.rb"
11
+
12
+ def copy_initializer
13
+ template "sendara.rb", "config/initializers/sendara.rb"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sendara.configure do |config|
4
+ config.api_key = ENV["SENDARA_API_KEY"]
5
+ # config.base_url = "https://api.sendara.dev"
6
+ # config.timeout = 30
7
+ # config.max_retries = 2
8
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sendara
4
+ class MessagePage
5
+ include Enumerable
6
+
7
+ attr_reader :messages, :next_cursor
8
+
9
+ def initialize(messages:, next_cursor: nil)
10
+ @messages = messages
11
+ @next_cursor = next_cursor
12
+ end
13
+
14
+ def self.from_response(response)
15
+ response ||= {}
16
+ raw = response["messages"]
17
+ messages = raw.is_a?(Array) ? raw : []
18
+ cursor = response["next_cursor"]
19
+ cursor = nil unless cursor.is_a?(String) && !cursor.empty?
20
+
21
+ new(messages: messages, next_cursor: cursor)
22
+ end
23
+
24
+ def each(&block)
25
+ return enum_for(:each) unless block_given?
26
+
27
+ messages.each(&block)
28
+ end
29
+
30
+ def has_more?
31
+ !next_cursor.nil?
32
+ end
33
+ end
34
+ end