usend 1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5b268a5e6ce690133139c0bbd3825ba7063df55be6ccf0f0a7145423affb134a
4
+ data.tar.gz: f831022189dedb8270399b49791cd55f237233bc065a05dc23ebe3e122ff4e4f
5
+ SHA512:
6
+ metadata.gz: 635efa4c472902029d1f59e52f1d85325a8b5ceda309117bf066e085afd75675bcac385d8404e86f27838595fa55fbbc607eb7db521c9c7ba2e6a05c256c0487
7
+ data.tar.gz: 4ce45b1683cef56f8b892dfd88290d27fee4f893d4c512b2d1dde7e94fe22d4fc483f528350f64dd1d5c160f99a69f5f1a92e80d3156bd943c65d0af9f038bd0
data/LICENSE ADDED
@@ -0,0 +1,17 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 uSend
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
6
+ documentation files (the "Software"), to deal in the Software without restriction, including without limitation
7
+ the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and
8
+ to permit persons to whom the Software is furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of
11
+ the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
14
+ THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
15
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
16
+ CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
17
+ IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # usend for Ruby
2
+
3
+ The official Ruby SDK for [usend](https://usend.et), the email API for developers. Standard library only
4
+ (`net/http`, `json`), Ruby 3.0 or newer.
5
+
6
+ ```bash
7
+ gem install usend
8
+ # or in your Gemfile: gem "usend"
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```ruby
14
+ require "usend"
15
+
16
+ usend = Usend::Client.new(api_key: ENV["USEND_API_KEY"])
17
+
18
+ email = usend.emails.send_email(
19
+ from: "Acme <hello@acme.com>",
20
+ to: "ann@example.com",
21
+ subject: "Your login code",
22
+ html: "<p>Your code is <b>482913</b></p>"
23
+ )
24
+ puts email["id"]
25
+ ```
26
+
27
+ `emails.send` is an alias of `emails.send_email`. Methods return the API's JSON (Hash or Array with string
28
+ keys) and raise `Usend::Error` on failure.
29
+
30
+ - The key defaults to `USEND_API_KEY`; the base URL to `USEND_BASE_URL` or `https://api.usend.et`.
31
+ - Options: `timeout:` (seconds, default 30), `max_retries:` (default 2).
32
+ - Resources: `emails`, `events` (custom events), `automations`, `logs`, `received` (inbound mail), `inboxes` (shared mailboxes), `domains`, `api_keys`, `templates`,
33
+ `contacts`, `contact_properties`, `contact_imports`, `segments`, `topics`, `audiences`, `broadcasts`, `webhooks`, `suppressions`, `tools`, `oauth`.
34
+
35
+ ```ruby
36
+ usend.emails.batch([{ from: "...", to: "a@x.com", subject: "Hi", html: "..." }])
37
+ usend.emails.get("email-id")
38
+ usend.emails.list(limit: 20)
39
+ usend.emails.update("email-id", scheduled_at: "2030-01-01T09:00:00Z")
40
+ usend.emails.cancel("email-id")
41
+ usend.emails.events("email-id")
42
+ usend.domains.create(name: "acme.com")
43
+ usend.domains.verify("domain-id")
44
+ usend.templates.publish("welcome")
45
+ usend.contacts.create(email: "ann@example.com", first_name: "Ann")
46
+ usend.broadcasts.send_broadcast("broadcast-id")
47
+ ```
48
+
49
+ Pass an idempotency key to make a send safe to repeat:
50
+
51
+ ```ruby
52
+ usend.emails.send_email({ from: "...", to: "...", subject: "...", html: "..." }, idempotency_key: "order-1042")
53
+ ```
54
+
55
+ ## Errors and retries
56
+
57
+ ```ruby
58
+ begin
59
+ usend.emails.send_email(from: "nobody@unverified.com", to: "ann@example.com", subject: "Hi", html: "x")
60
+ rescue Usend::Error => e
61
+ e.status_code # 403 (nil for network failures)
62
+ e.name # "validation_error", "rate_limit_exceeded", ...
63
+ e.message
64
+ end
65
+ ```
66
+
67
+ Requests are retried up to `max_retries` times with backoff on `429` (honoring `Retry-After`), `5xx` and network
68
+ errors. A `POST` is only retried after a `5xx` or network error when it carries an `idempotency_key`, so an email
69
+ is never sent twice by accident.
70
+
71
+ ## SMTP
72
+
73
+ Send through SMTP instead; the API key is the password. Port 465 is implicit TLS, 587 is STARTTLS.
74
+
75
+ ```ruby
76
+ s = Usend.smtp_settings(api_key: ENV["USEND_API_KEY"]) # smtp.usend.et:465
77
+ s = Usend.smtp_settings(api_key: ENV["USEND_API_KEY"], port: 587) # STARTTLS
78
+ # or usend.smtp_settings(port: 587)
79
+
80
+ # s => { address:, port:, user_name: "usend", password:, authentication: :plain, tls:, enable_starttls_auto: }
81
+ ```
82
+
83
+ For a self-hosted platform pass `host:`, set `USEND_SMTP_HOST`, or construct the client with
84
+ `base_url: "https://api.example.com"` (gives `smtp.example.com`).
85
+
86
+ ## Webhooks
87
+
88
+ Verify a delivery ([Standard Webhooks](https://www.standardwebhooks.com)) using the **raw** request body:
89
+
90
+ ```ruby
91
+ event = Usend::Webhook.verify(
92
+ payload: request.raw_post,
93
+ headers: request.headers, # any Hash-like with webhook-id, webhook-timestamp, webhook-signature
94
+ secret: ENV["USEND_WEBHOOK_SECRET"] # whsec_...
95
+ )
96
+ ```
97
+
98
+ It returns the parsed event, or raises `Usend::Error` (`name: "invalid_webhook"`). The default tolerance is 5
99
+ minutes (`tolerance_seconds:`).
100
+
101
+ ## Tests
102
+
103
+ ```bash
104
+ ruby -Ilib -Itest test/usend_test.rb
105
+ ```
106
+
107
+ Docs: https://usend.et/docs
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usend
4
+ # Client for the usend API.
5
+ #
6
+ # usend = Usend::Client.new(api_key: ENV["USEND_API_KEY"])
7
+ # usend.emails.send_email(from: "Acme <hi@acme.com>", to: "ann@example.com", subject: "Hi", html: "<p>Hi</p>")
8
+ class Client
9
+ attr_reader :api_key, :base_url, :emails, :events, :automations, :logs, :received, :inboxes, :domains, :api_keys, :templates,
10
+ :contacts, :contact_properties, :contact_imports, :segments, :topics, :short_links, :audiences, :broadcasts, :webhooks, :suppressions, :tools, :oauth
11
+
12
+ # api_key defaults to ENV["USEND_API_KEY"]; base_url to ENV["USEND_BASE_URL"] or https://api.usend.et.
13
+ # max_retries applies to 429 and 5xx responses and network errors (a POST is only retried after
14
+ # a 5xx or network error when it carries an idempotency key).
15
+ def initialize(api_key: nil, base_url: nil, timeout: 30, max_retries: 2)
16
+ @api_key = api_key || ENV["USEND_API_KEY"]
17
+ if @api_key.nil? || @api_key.empty?
18
+ raise Error.new('Missing API key. Pass api_key: or set USEND_API_KEY.', name: "missing_api_key")
19
+ end
20
+
21
+ @base_url = (base_url || ENV["USEND_BASE_URL"] || DEFAULT_BASE_URL).sub(%r{/+\z}, "")
22
+ http = HTTP.new(api_key: @api_key, base_url: @base_url, timeout: timeout, max_retries: max_retries)
23
+ @emails = Emails.new(http)
24
+ @events = Events.new(http)
25
+ @logs = Logs.new(http)
26
+ @received = Received.new(http)
27
+ @inboxes = Inboxes.new(http)
28
+ @automations = Automations.new(http)
29
+ @contact_properties = ContactProperties.new(http)
30
+ @contact_imports = ContactImports.new(http)
31
+ @tools = Tools.new(http)
32
+ @oauth = OAuth.new(http)
33
+ @domains = Domains.new(http)
34
+ @api_keys = ApiKeys.new(http)
35
+ @templates = Templates.new(http)
36
+ @contacts = Contacts.new(http)
37
+ @segments = Segments.new(http)
38
+ @topics = Topics.new(http)
39
+ @short_links = ShortLinks.new(http)
40
+ @audiences = Audiences.new(http)
41
+ @broadcasts = Broadcasts.new(http)
42
+ @webhooks = Webhooks.new(http)
43
+ @suppressions = Suppressions.new(http)
44
+ end
45
+
46
+ # SMTP settings for this key (see Usend.smtp_settings).
47
+ def smtp_settings(port: 465, host: nil)
48
+ Usend.smtp_settings(api_key: @api_key, port: port, host: host, base_url: @base_url)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usend
4
+ # An error from the usend API ({ statusCode, name, message }). Network failures have
5
+ # status_code nil and name "application_error".
6
+ class Error < StandardError
7
+ attr_reader :status_code, :name
8
+
9
+ def initialize(message = "usend error", name: "application_error", status_code: nil)
10
+ super(message)
11
+ @name = name
12
+ @status_code = status_code
13
+ end
14
+
15
+ def inspect
16
+ "#<Usend::Error status_code=#{status_code.inspect} name=#{name.inspect} message=#{message.inspect}>"
17
+ end
18
+ end
19
+ end
data/lib/usend/http.rb ADDED
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "uri"
7
+
8
+ module Usend
9
+ DEFAULT_BASE_URL = "https://api.usend.et"
10
+
11
+ # Minimal HTTP layer: JSON in and out, retries on 429 and 5xx with backoff.
12
+ class HTTP
13
+ RETRY_STATUSES = [429, 500, 502, 503, 504].freeze
14
+
15
+ attr_reader :base_url
16
+
17
+ def initialize(api_key:, base_url:, timeout:, max_retries:)
18
+ @api_key = api_key
19
+ @base_url = base_url
20
+ @timeout = timeout
21
+ @max_retries = max_retries
22
+ end
23
+
24
+ # Returns the parsed JSON body (Hash/Array), or {} when empty. Raises Usend::Error.
25
+ def request(method, path, body: nil, headers: {}, auth: true)
26
+ uri = URI.parse(@base_url + path)
27
+ attempt = 0
28
+ loop do
29
+ attempt += 1
30
+ begin
31
+ res = perform(uri, method, body, headers, auth)
32
+ rescue SystemCallError, SocketError, IOError, Timeout::Error, OpenSSL::SSL::SSLError, Net::ProtocolError => e
33
+ # A POST without an idempotency key may already have been processed: never repeat it.
34
+ unless retry_safe?(method, headers) && attempt <= @max_retries
35
+ raise Error.new(e.message.to_s.empty? ? e.class.name : e.message)
36
+ end
37
+
38
+ sleep(backoff(attempt, nil))
39
+ next
40
+ end
41
+ status = res.code.to_i
42
+ return(res.body.nil? || res.body.empty? ? {} : JSON.parse(res.body)) if status >= 200 && status < 300
43
+
44
+ if RETRY_STATUSES.include?(status) && attempt <= @max_retries && (status == 429 || retry_safe?(method, headers))
45
+ sleep(backoff(attempt, res["retry-after"]))
46
+ next
47
+ end
48
+ raise api_error(res, status)
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def perform(uri, method, body, headers, auth)
55
+ http = Net::HTTP.new(uri.host, uri.port)
56
+ http.use_ssl = uri.scheme == "https"
57
+ http.open_timeout = @timeout
58
+ http.read_timeout = @timeout
59
+ http.write_timeout = @timeout
60
+ req = Net::HTTPGenericRequest.new(method.to_s.upcase, !body.nil?, true, uri.request_uri)
61
+ req["Authorization"] = "Bearer #{@api_key}" if auth
62
+ req["User-Agent"] = "usend-ruby/#{VERSION}"
63
+ req["Accept"] = "application/json"
64
+ headers.each { |k, v| req[k] = v }
65
+ unless body.nil?
66
+ req["Content-Type"] = "application/json"
67
+ req.body = JSON.generate(body)
68
+ end
69
+ http.start { |h| h.request(req) }
70
+ end
71
+
72
+ def retry_safe?(method, headers)
73
+ method.to_s.upcase != "POST" || headers.key?("Idempotency-Key")
74
+ end
75
+
76
+ def backoff(attempt, retry_after)
77
+ if retry_after && retry_after.to_s.match?(/\A\d+(\.\d+)?\z/)
78
+ [retry_after.to_f, 30.0].min
79
+ else
80
+ [0.5 * (2**(attempt - 1)), 8.0].min
81
+ end
82
+ end
83
+
84
+ def api_error(res, status)
85
+ err = begin
86
+ JSON.parse(res.body.to_s)
87
+ rescue JSON::ParserError
88
+ {}
89
+ end
90
+ err = {} unless err.is_a?(Hash)
91
+ Error.new(err["message"] || "HTTP #{status}", name: err["name"] || "application_error", status_code: status)
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,623 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "uri"
5
+
6
+ module Usend
7
+ # Base for resource objects. Every method returns the API's parsed JSON (Hash or Array)
8
+ # and raises Usend::Error on failure.
9
+ class Resource
10
+ def initialize(http)
11
+ @http = http
12
+ end
13
+
14
+ private
15
+
16
+ def req(method, path, body: nil, headers: {}, auth: true)
17
+ @http.request(method, path, body: body, headers: headers, auth: auth)
18
+ end
19
+
20
+ def esc(value)
21
+ URI.encode_www_form_component(value.to_s).gsub("+", "%20")
22
+ end
23
+
24
+ def qs(params)
25
+ pairs = (params || {}).reject { |_, v| v.nil? }
26
+ pairs.empty? ? "" : "?#{URI.encode_www_form(pairs)}"
27
+ end
28
+
29
+ def idem(key)
30
+ key ? { "Idempotency-Key" => key } : {}
31
+ end
32
+ end
33
+
34
+ # Standard create/list/get/update/remove on one path.
35
+ class Crud < Resource
36
+ def create(body)
37
+ req("POST", self.class::PATH, body: body)
38
+ end
39
+
40
+ def list(params = {})
41
+ req("GET", "#{self.class::PATH}#{qs(params)}")
42
+ end
43
+
44
+ def get(id)
45
+ req("GET", "#{self.class::PATH}/#{esc(id)}")
46
+ end
47
+
48
+ def update(id, body)
49
+ req("PATCH", "#{self.class::PATH}/#{esc(id)}", body: body)
50
+ end
51
+
52
+ def remove(id)
53
+ req("DELETE", "#{self.class::PATH}/#{esc(id)}")
54
+ end
55
+ end
56
+
57
+ class Emails < Resource
58
+ # idempotency_key: makes the send safe to retry.
59
+ #
60
+ # Takes the email as a Hash or as keywords: send_email(from: "...", to: "...", subject: "...", html: "...").
61
+ def send_email(email = nil, idempotency_key: nil, **fields)
62
+ email = fields if email.nil?
63
+ req("POST", "/emails", body: email, headers: idem(idempotency_key))
64
+ end
65
+ alias send send_email
66
+
67
+ # validation: "strict" (default) or "permissive".
68
+ def batch(emails, idempotency_key: nil, validation: nil)
69
+ headers = idem(idempotency_key)
70
+ headers["x-batch-validation"] = validation if validation
71
+ req("POST", "/emails/batch", body: emails, headers: headers)
72
+ end
73
+
74
+ def get(id)
75
+ req("GET", "/emails/#{esc(id)}")
76
+ end
77
+
78
+ def list(params = {})
79
+ req("GET", "/emails#{qs(params)}")
80
+ end
81
+
82
+ # Reschedules a scheduled email.
83
+ def update(id, scheduled_at:)
84
+ req("PATCH", "/emails/#{esc(id)}", body: { scheduled_at: scheduled_at })
85
+ end
86
+
87
+ def cancel(id)
88
+ req("POST", "/emails/#{esc(id)}/cancel")
89
+ end
90
+
91
+ def events(id)
92
+ req("GET", "/emails/#{esc(id)}/events")
93
+ end
94
+
95
+ def attachments(id)
96
+ req("GET", "/emails/#{esc(id)}/attachments")
97
+ end
98
+
99
+ def attachment(id, attachment_id)
100
+ req("GET", "/emails/#{esc(id)}/attachments/#{esc(attachment_id)}")
101
+ end
102
+
103
+ # Aggregate sending metrics (start_date, end_date, metrics, dimensions, granularity, ...).
104
+ def metrics(params = {})
105
+ req("GET", "/emails/metrics#{qs(params)}")
106
+ end
107
+
108
+ # Creates a public read-only link; expires_in like "10m", "2 hours", "1 day" (max 48h).
109
+ def share(id, expires_in: nil)
110
+ req("POST", "/emails/#{esc(id)}/share", body: expires_in ? { expires_in: expires_in } : {})
111
+ end
112
+
113
+ # Sends the content to the signed-in user's own address (dashboard sessions and OAuth tokens).
114
+ def test(body)
115
+ req("POST", "/emails/test", body: body)
116
+ end
117
+
118
+ # Deliverability findings for the stored message.
119
+ def insights(id)
120
+ req("GET", "/emails/#{esc(id)}/insights")
121
+ end
122
+ end
123
+
124
+ # Custom events that trigger automations (not email delivery events: see Emails#events).
125
+ class Events < Crud
126
+ PATH = "/events"
127
+
128
+ # Fires an event for a contact: { event:, contact_id: | email:, payload: }. The API answers 202.
129
+ def send_event(body)
130
+ req("POST", "/events/send", body: body)
131
+ end
132
+ alias send send_event
133
+ end
134
+
135
+ class Automations < Crud
136
+ PATH = "/automations"
137
+
138
+ def duplicate(id)
139
+ req("POST", "/automations/#{esc(id)}/duplicate")
140
+ end
141
+
142
+ def stop(id)
143
+ req("POST", "/automations/#{esc(id)}/stop")
144
+ end
145
+
146
+ def runs(id, params = {})
147
+ req("GET", "/automations/#{esc(id)}/runs#{qs(params)}")
148
+ end
149
+
150
+ def run(id, run_id)
151
+ req("GET", "/automations/#{esc(id)}/runs/#{esc(run_id)}")
152
+ end
153
+ end
154
+
155
+ class ContactProperties < Crud
156
+ PATH = "/contact-properties"
157
+ end
158
+
159
+ # Bulk contact imports (POST /contacts/imports) and their progress.
160
+ class ContactImports < Resource
161
+ def create(body)
162
+ req("POST", "/contacts/imports", body: body)
163
+ end
164
+
165
+ def list(params = {})
166
+ req("GET", "/contacts/imports#{qs(params)}")
167
+ end
168
+
169
+ def get(id)
170
+ req("GET", "/contacts/imports/#{esc(id)}")
171
+ end
172
+ end
173
+
174
+ # Deliverability tools.
175
+ class Tools < Resource
176
+ def dmarc_check(domain)
177
+ req("POST", "/tools/dmarc-check", body: { domain: domain })
178
+ end
179
+ end
180
+
181
+ # API request logs: GET /logs.
182
+ class Logs < Resource
183
+ def list(params = {})
184
+ req("GET", "/logs#{qs(params)}")
185
+ end
186
+
187
+ def get(id)
188
+ req("GET", "/logs/#{esc(id)}")
189
+ end
190
+
191
+ def user_agents
192
+ req("GET", "/logs/user-agents")
193
+ end
194
+ end
195
+
196
+ # Received (inbound) emails.
197
+ class Received < Resource
198
+ def list(params = {})
199
+ req("GET", "/inbound/emails#{qs(params)}")
200
+ end
201
+
202
+ def get(id)
203
+ req("GET", "/inbound/emails/#{esc(id)}")
204
+ end
205
+
206
+ def raw(id)
207
+ req("GET", "/inbound/emails/#{esc(id)}/raw")
208
+ end
209
+
210
+ def attachment(id, attachment_id)
211
+ req("GET", "/inbound/emails/#{esc(id)}/attachments/#{esc(attachment_id)}")
212
+ end
213
+ end
214
+
215
+ # Shared mailboxes with threads, labels, drafts and replies (full-access keys only).
216
+ class Inboxes < Crud
217
+ PATH = "/inboxes"
218
+
219
+ def create_label(inbox_id, body)
220
+ req("POST", "/inboxes/#{esc(inbox_id)}/labels", body: body)
221
+ end
222
+
223
+ def labels(inbox_id)
224
+ req("GET", "/inboxes/#{esc(inbox_id)}/labels")
225
+ end
226
+
227
+ def label(inbox_id, label_id)
228
+ req("GET", "/inboxes/#{esc(inbox_id)}/labels/#{esc(label_id)}")
229
+ end
230
+
231
+ def update_label(inbox_id, label_id, body)
232
+ req("PATCH", "/inboxes/#{esc(inbox_id)}/labels/#{esc(label_id)}", body: body)
233
+ end
234
+
235
+ def remove_label(inbox_id, label_id)
236
+ req("DELETE", "/inboxes/#{esc(inbox_id)}/labels/#{esc(label_id)}")
237
+ end
238
+
239
+ # params: folder, label, assignee, unread, q, limit, after, before
240
+ def threads(inbox_id, params = {})
241
+ req("GET", "/inboxes/#{esc(inbox_id)}/threads#{qs(params)}")
242
+ end
243
+
244
+ def thread(inbox_id, thread_id)
245
+ req("GET", "/inboxes/#{esc(inbox_id)}/threads/#{esc(thread_id)}")
246
+ end
247
+
248
+ # body: folder, labels, assignee_user_id, read
249
+ def update_thread(inbox_id, thread_id, body)
250
+ req("PATCH", "/inboxes/#{esc(inbox_id)}/threads/#{esc(thread_id)}", body: body)
251
+ end
252
+
253
+ def remove_thread(inbox_id, thread_id)
254
+ req("DELETE", "/inboxes/#{esc(inbox_id)}/threads/#{esc(thread_id)}")
255
+ end
256
+
257
+ def thread_emails(inbox_id, thread_id, params = {})
258
+ req("GET", "/inboxes/#{esc(inbox_id)}/threads/#{esc(thread_id)}/emails#{qs(params)}")
259
+ end
260
+
261
+ def thread_email(inbox_id, thread_id, email_id)
262
+ req("GET", "/inboxes/#{esc(inbox_id)}/threads/#{esc(thread_id)}/emails/#{esc(email_id)}")
263
+ end
264
+
265
+ def reply(inbox_id, thread_id, email_id, body)
266
+ req("POST", "/inboxes/#{esc(inbox_id)}/threads/#{esc(thread_id)}/emails/#{esc(email_id)}/reply", body: body)
267
+ end
268
+
269
+ def forward(inbox_id, thread_id, email_id, body)
270
+ req("POST", "/inboxes/#{esc(inbox_id)}/threads/#{esc(thread_id)}/emails/#{esc(email_id)}/forward", body: body)
271
+ end
272
+
273
+ def create_draft(inbox_id, body)
274
+ req("POST", "/inboxes/#{esc(inbox_id)}/drafts", body: body)
275
+ end
276
+
277
+ def drafts(inbox_id, params = {})
278
+ req("GET", "/inboxes/#{esc(inbox_id)}/drafts#{qs(params)}")
279
+ end
280
+
281
+ def draft(inbox_id, draft_id)
282
+ req("GET", "/inboxes/#{esc(inbox_id)}/drafts/#{esc(draft_id)}")
283
+ end
284
+
285
+ def update_draft(inbox_id, draft_id, body)
286
+ req("PATCH", "/inboxes/#{esc(inbox_id)}/drafts/#{esc(draft_id)}", body: body)
287
+ end
288
+
289
+ def remove_draft(inbox_id, draft_id)
290
+ req("DELETE", "/inboxes/#{esc(inbox_id)}/drafts/#{esc(draft_id)}")
291
+ end
292
+
293
+ def send_draft(inbox_id, draft_id)
294
+ req("POST", "/inboxes/#{esc(inbox_id)}/drafts/#{esc(draft_id)}/send")
295
+ end
296
+ end
297
+
298
+ class Domains < Crud
299
+ PATH = "/domains"
300
+
301
+ # Domain claim. body: { name: }
302
+ def create_claim(body)
303
+ req("POST", "/domains/claim", body: body)
304
+ end
305
+
306
+ def claim(id)
307
+ req("GET", "/domains/claim/#{esc(id)}")
308
+ end
309
+
310
+ def verify_claim(id)
311
+ req("POST", "/domains/claim/#{esc(id)}/verify")
312
+ end
313
+
314
+ def insights(id)
315
+ req("GET", "/domains/#{esc(id)}/insights")
316
+ end
317
+
318
+ def verify(id)
319
+ req("POST", "/domains/#{esc(id)}/verify")
320
+ end
321
+
322
+ def auto_configure(id, api_token:, provider: "cloudflare")
323
+ req("POST", "/domains/#{esc(id)}/auto-configure", body: { provider: provider, api_token: api_token })
324
+ end
325
+ end
326
+
327
+ class ApiKeys < Resource
328
+ def create(body)
329
+ req("POST", "/api-keys", body: body)
330
+ end
331
+
332
+ def update(id, body)
333
+ req("PATCH", "/api-keys/#{esc(id)}", body: body)
334
+ end
335
+
336
+ def list
337
+ req("GET", "/api-keys")
338
+ end
339
+
340
+ def remove(id)
341
+ req("DELETE", "/api-keys/#{esc(id)}")
342
+ end
343
+ end
344
+
345
+ class Templates < Crud
346
+ PATH = "/templates"
347
+
348
+ def publish(id_or_alias)
349
+ req("POST", "/templates/#{esc(id_or_alias)}/publish")
350
+ end
351
+
352
+ def duplicate(id_or_alias)
353
+ req("POST", "/templates/#{esc(id_or_alias)}/duplicate")
354
+ end
355
+
356
+ def versions(id_or_alias)
357
+ req("GET", "/templates/#{esc(id_or_alias)}/versions")
358
+ end
359
+
360
+ def version(id_or_alias, version_id)
361
+ req("GET", "/templates/#{esc(id_or_alias)}/versions/#{esc(version_id)}")
362
+ end
363
+
364
+ def restore_version(id_or_alias, version_id)
365
+ req("POST", "/templates/#{esc(id_or_alias)}/versions/#{esc(version_id)}/restore")
366
+ end
367
+ end
368
+
369
+ class Contacts < Crud
370
+ PATH = "/contacts"
371
+
372
+ def import(csv)
373
+ req("POST", "/contacts/import", body: { csv: csv })
374
+ end
375
+
376
+ def stats
377
+ req("GET", "/contacts/stats")
378
+ end
379
+
380
+ def segments(id_or_email)
381
+ req("GET", "/contacts/#{esc(id_or_email)}/segments")
382
+ end
383
+
384
+ def add_to_segment(id_or_email, segment_id)
385
+ req("POST", "/contacts/#{esc(id_or_email)}/segments/#{esc(segment_id)}")
386
+ end
387
+
388
+ def remove_from_segment(id_or_email, segment_id)
389
+ req("DELETE", "/contacts/#{esc(id_or_email)}/segments/#{esc(segment_id)}")
390
+ end
391
+
392
+ def topics(id_or_email)
393
+ req("GET", "/contacts/#{esc(id_or_email)}/topics")
394
+ end
395
+
396
+ def update_topics(id_or_email, body)
397
+ req("PATCH", "/contacts/#{esc(id_or_email)}/topics", body: body)
398
+ end
399
+ end
400
+
401
+ class Segments < Resource
402
+ def create(body)
403
+ req("POST", "/segments", body: body)
404
+ end
405
+
406
+ def list
407
+ req("GET", "/segments")
408
+ end
409
+
410
+ def get(id)
411
+ req("GET", "/segments/#{esc(id)}")
412
+ end
413
+
414
+ def update(id, body)
415
+ req("PATCH", "/segments/#{esc(id)}", body: body)
416
+ end
417
+
418
+ def remove(id)
419
+ req("DELETE", "/segments/#{esc(id)}")
420
+ end
421
+
422
+ # params: limit, after, before, subscription ("subscribed" | "unsubscribed")
423
+ def contacts(id, params = {})
424
+ req("GET", "/segments/#{esc(id)}/contacts#{qs(params)}")
425
+ end
426
+
427
+ def metrics(id)
428
+ req("GET", "/segments/#{esc(id)}/metrics")
429
+ end
430
+ end
431
+
432
+ class Topics < Crud
433
+ PATH = "/topics"
434
+ end
435
+
436
+ # Short links on the shared tracking host that redirect to a target URL and count clicks.
437
+ #
438
+ # usend.short_links.create(target_url: "https://acme.com/sale", code: "sale")
439
+ #
440
+ # update(id, ...) takes target_url:, title:, disabled:, expires_at: (nil clears the expiry).
441
+ class ShortLinks < Crud
442
+ PATH = "/short-links"
443
+
444
+ # Click statistics for the last `days` days (1 to 365, default 30).
445
+ def stats(id, days: nil)
446
+ req("GET", "/short-links/#{esc(id)}/stats#{qs(days: days)}")
447
+ end
448
+ end
449
+
450
+ # Audiences (v1 name for segments) and their contacts.
451
+ class Audiences < Resource
452
+ def create(body)
453
+ req("POST", "/audiences", body: body)
454
+ end
455
+
456
+ def list
457
+ req("GET", "/audiences")
458
+ end
459
+
460
+ def get(id)
461
+ req("GET", "/audiences/#{esc(id)}")
462
+ end
463
+
464
+ def remove(id)
465
+ req("DELETE", "/audiences/#{esc(id)}")
466
+ end
467
+
468
+ def create_contact(audience_id, body)
469
+ req("POST", "/audiences/#{esc(audience_id)}/contacts", body: body)
470
+ end
471
+
472
+ def list_contacts(audience_id, params = {})
473
+ req("GET", "/audiences/#{esc(audience_id)}/contacts#{qs(params)}")
474
+ end
475
+
476
+ def get_contact(audience_id, id_or_email)
477
+ req("GET", "/audiences/#{esc(audience_id)}/contacts/#{esc(id_or_email)}")
478
+ end
479
+
480
+ def update_contact(audience_id, id_or_email, body)
481
+ req("PATCH", "/audiences/#{esc(audience_id)}/contacts/#{esc(id_or_email)}", body: body)
482
+ end
483
+
484
+ def remove_contact(audience_id, id_or_email)
485
+ req("DELETE", "/audiences/#{esc(audience_id)}/contacts/#{esc(id_or_email)}")
486
+ end
487
+
488
+ def import_contacts(audience_id, csv)
489
+ req("POST", "/audiences/#{esc(audience_id)}/contacts/import", body: { csv: csv })
490
+ end
491
+ end
492
+
493
+ class Broadcasts < Crud
494
+ PATH = "/broadcasts"
495
+
496
+ def send_broadcast(id, scheduled_at: nil)
497
+ req("POST", "/broadcasts/#{esc(id)}/send", body: scheduled_at ? { scheduled_at: scheduled_at } : {})
498
+ end
499
+ alias send send_broadcast
500
+
501
+ def test(id, to)
502
+ req("POST", "/broadcasts/#{esc(id)}/test", body: { to: to })
503
+ end
504
+
505
+ def cancel(id)
506
+ req("POST", "/broadcasts/#{esc(id)}/cancel")
507
+ end
508
+
509
+ def duplicate(id)
510
+ req("POST", "/broadcasts/#{esc(id)}/duplicate")
511
+ end
512
+
513
+ # type is required: sent, delivered, opened, clicked, bounced, complained, unsubscribed or suppressed.
514
+ # Rows are { id, contact_id, email, count?, bounce_type?, clicked_links? }. Other params: limit, after,
515
+ # before, email, bounce_type.
516
+ def recipients(id, type:, **params)
517
+ req("GET", "/broadcasts/#{esc(id)}/recipients#{qs(params.merge(type: type))}")
518
+ end
519
+
520
+ def clicked_links(id)
521
+ req("GET", "/broadcasts/#{esc(id)}/clicked-links")
522
+ end
523
+
524
+ def performance(id)
525
+ req("GET", "/broadcasts/#{esc(id)}/performance")
526
+ end
527
+ end
528
+
529
+ class Webhooks < Crud
530
+ PATH = "/webhooks"
531
+
532
+ def deliveries(id, params = {})
533
+ req("GET", "/webhooks/#{esc(id)}/deliveries#{qs(params)}")
534
+ end
535
+
536
+ def replay(id, delivery_id)
537
+ req("POST", "/webhooks/#{esc(id)}/deliveries/#{esc(delivery_id)}/replay")
538
+ end
539
+
540
+ # Returns the new signing secret; the old one stops signing at once.
541
+ def rotate_secret(id)
542
+ req("POST", "/webhooks/#{esc(id)}/rotate-secret")
543
+ end
544
+
545
+ # Webhook events across endpoints.
546
+ def events(params = {})
547
+ req("GET", "/webhooks/events#{qs(params)}")
548
+ end
549
+
550
+ def event(event_id)
551
+ req("GET", "/webhooks/events/#{esc(event_id)}")
552
+ end
553
+
554
+ def replay_event(event_id)
555
+ req("POST", "/webhooks/events/#{esc(event_id)}/replay")
556
+ end
557
+
558
+ def event_attempts(event_id)
559
+ req("GET", "/webhooks/events/#{esc(event_id)}/attempts")
560
+ end
561
+
562
+ # See Usend::Webhook.verify.
563
+ def verify(**opts)
564
+ Usend::Webhook.verify(**opts)
565
+ end
566
+ end
567
+
568
+ class Suppressions < Resource
569
+ def list(params = {})
570
+ req("GET", "/suppressions#{qs(params)}")
571
+ end
572
+
573
+ def create(body)
574
+ req("POST", "/suppressions", body: body)
575
+ end
576
+
577
+ def get(id_or_email)
578
+ req("GET", "/suppressions/#{esc(id_or_email)}")
579
+ end
580
+
581
+ # emails: up to 100 addresses.
582
+ def batch_create(emails)
583
+ req("POST", "/suppressions/batch", body: { emails: emails })
584
+ end
585
+
586
+ def batch_remove(emails)
587
+ req("DELETE", "/suppressions/batch", body: { emails: emails })
588
+ end
589
+
590
+ def remove(id)
591
+ req("DELETE", "/suppressions/#{esc(id)}")
592
+ end
593
+ end
594
+
595
+ # OAuth 2.0 for apps acting on a customer's org (PKCE required). These endpoints take no API key.
596
+ class OAuth < Resource
597
+ def metadata
598
+ req("GET", "/.well-known/oauth-authorization-server", auth: false)
599
+ end
600
+
601
+ # { client_name:, redirect_uris: [...] }
602
+ def register(body)
603
+ req("POST", "/oauth/register", body: body, auth: false)
604
+ end
605
+
606
+ # grant_type "authorization_code" (client_id, code, code_verifier, redirect_uri)
607
+ # or "refresh_token" (client_id, refresh_token)
608
+ def token(body)
609
+ req("POST", "/oauth/token", body: body, auth: false)
610
+ end
611
+
612
+ def revoke(token, client_id: nil)
613
+ req("POST", "/oauth/revoke", body: { token: token, client_id: client_id }.compact, auth: false)
614
+ end
615
+
616
+ # The URL to send the user's browser to.
617
+ def authorize_url(client_id:, redirect_uri:, scope:, code_challenge:, state: nil)
618
+ q = { response_type: "code", client_id: client_id, redirect_uri: redirect_uri, scope: scope,
619
+ code_challenge: code_challenge, code_challenge_method: "S256", state: state }.compact
620
+ "#{@http.base_url}/oauth/authorize?#{URI.encode_www_form(q)}"
621
+ end
622
+ end
623
+ end
data/lib/usend/smtp.rb ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Usend
6
+ DEFAULT_SMTP_HOST = "smtp.usend.et"
7
+
8
+ # SMTP settings for sending through usend; the API key is the password. Port 465 is implicit
9
+ # TLS (tls: true), 587 is STARTTLS (enable_starttls_auto: true). The host is smtp.usend.et; for a
10
+ # self-hosted platform pass host:, set USEND_SMTP_HOST, or pass base_url: ("https://api.x.com"
11
+ # gives "smtp.x.com"). The keys match ActionMailer's smtp_settings.
12
+ def self.smtp_settings(api_key: nil, port: 465, host: nil, base_url: nil)
13
+ key = api_key || ENV["USEND_API_KEY"]
14
+ raise Error.new("Missing API key. Pass api_key: or set USEND_API_KEY.", name: "missing_api_key") if key.nil? || key.empty?
15
+ raise Error.new("usend accepts SMTP on port 465 (TLS) or 587 (STARTTLS).", name: "invalid_port") unless [465, 587].include?(port)
16
+
17
+ {
18
+ address: host || ENV["USEND_SMTP_HOST"] || smtp_host_for(base_url),
19
+ port: port,
20
+ user_name: "usend",
21
+ password: key,
22
+ authentication: :plain,
23
+ tls: port == 465,
24
+ enable_starttls_auto: port == 587
25
+ }
26
+ end
27
+
28
+ def self.smtp_host_for(base_url)
29
+ return DEFAULT_SMTP_HOST if base_url.nil? || base_url.empty?
30
+
31
+ hostname = URI.parse(base_url).host.to_s
32
+ hostname.start_with?("api.") ? "smtp.#{hostname[4..]}" : DEFAULT_SMTP_HOST
33
+ rescue URI::InvalidURIError
34
+ DEFAULT_SMTP_HOST
35
+ end
36
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Usend
4
+ VERSION = "1.3.0"
5
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "openssl"
6
+
7
+ module Usend
8
+ # Standard Webhooks verification.
9
+ module Webhook
10
+ # Verifies a delivery and returns the parsed event (a Hash); raises Usend::Error if invalid.
11
+ # payload must be the raw request body exactly as received; headers is a Hash (any key case;
12
+ # Rack-style "HTTP_WEBHOOK_ID" keys also work); secret is the endpoint's whsec_... secret.
13
+ def self.verify(payload:, headers:, secret:, tolerance_seconds: 300)
14
+ h = headers.each_with_object({}) { |(k, v), m| m[k.to_s.downcase.sub(/\Ahttp_/, "").tr("_", "-")] = v }
15
+ id = h["webhook-id"]
16
+ ts = h["webhook-timestamp"]
17
+ sigs = h["webhook-signature"]
18
+ raise invalid("Missing webhook-id, webhook-timestamp or webhook-signature header") unless id && ts && sigs
19
+ raise invalid("Invalid webhook timestamp") unless ts.to_s.match?(/\A\d+\z/)
20
+ raise invalid("Webhook timestamp is too old or too new") if (Time.now.to_i - ts.to_i).abs > tolerance_seconds
21
+
22
+ key = Base64.decode64(secret.to_s.sub(/\Awhsec_/, ""))
23
+ expected = Base64.strict_encode64(OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{ts}.#{payload}"))
24
+ ok = sigs.to_s.split(" ").any? do |part|
25
+ version, sig = part.split(",", 2)
26
+ version == "v1" && sig && sig.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(sig, expected)
27
+ end
28
+ raise invalid("Invalid webhook signature") unless ok
29
+
30
+ JSON.parse(payload)
31
+ end
32
+
33
+ def self.invalid(msg)
34
+ Error.new(msg, name: "invalid_webhook")
35
+ end
36
+ private_class_method :invalid
37
+ end
38
+ end
data/lib/usend.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "usend/version"
4
+ require "usend/error"
5
+ require "usend/smtp"
6
+ require "usend/webhook"
7
+ require "usend/http"
8
+ require "usend/resources"
9
+ require "usend/client"
metadata ADDED
@@ -0,0 +1,55 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: usend
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.3.0
5
+ platform: ruby
6
+ authors:
7
+ - usend
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-23 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Send email, manage domains, templates, contacts, broadcasts and webhooks
14
+ with usend. Standard library only.
15
+ email:
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - LICENSE
21
+ - README.md
22
+ - lib/usend.rb
23
+ - lib/usend/client.rb
24
+ - lib/usend/error.rb
25
+ - lib/usend/http.rb
26
+ - lib/usend/resources.rb
27
+ - lib/usend/smtp.rb
28
+ - lib/usend/version.rb
29
+ - lib/usend/webhook.rb
30
+ homepage: https://usend.et
31
+ licenses:
32
+ - MIT
33
+ metadata:
34
+ source_code_uri: https://github.com/usend/usend-ruby
35
+ documentation_uri: https://usend.et/docs
36
+ post_install_message:
37
+ rdoc_options: []
38
+ require_paths:
39
+ - lib
40
+ required_ruby_version: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: '3.0'
45
+ required_rubygems_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '0'
50
+ requirements: []
51
+ rubygems_version: 3.5.22
52
+ signing_key:
53
+ specification_version: 4
54
+ summary: Official Ruby SDK for usend, the email API for developers
55
+ test_files: []