runearc 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 +7 -0
- data/LICENSE +21 -0
- data/README.md +369 -0
- data/lib/runearc/client.rb +265 -0
- data/lib/runearc/errors.rb +163 -0
- data/lib/runearc/resources.rb +396 -0
- data/lib/runearc/support.rb +18 -0
- data/lib/runearc/version.rb +5 -0
- data/lib/runearc/webhook.rb +123 -0
- data/lib/runearc.rb +15 -0
- data/sig/runearc.rbs +171 -0
- metadata +60 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ff088997a6f1f5b35201d2db5050fe8d77acb58fdd26e934e58df59e5cca1383
|
|
4
|
+
data.tar.gz: 94f737ea26643155d1c3b4d66d0465385757965dde162273920d954a11abac77
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2a398b853957db2a9621d5ab7b661e569a0a421db349083baa72e12834eee29f59010221b3e899460c19545060b8ecb49b946be971f9530b81720d05a797f07c
|
|
7
|
+
data.tar.gz: b21f2290cd6b2b8dcb8a203f17721c2aa2760f11297bca7cf945b145c7cb4c7709dbd5a373c258ba285ad18f6d515efa8bc9315362637677158966eef9291e77
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Runearc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://runearc.com/integrations/ruby">
|
|
3
|
+
<picture>
|
|
4
|
+
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/runearc-labs/runearc-ruby/main/.github/assets/banner-light.png">
|
|
5
|
+
<img alt="Runearc for Ruby. Install with the command shown, then send email and give agents inboxes." src="https://raw.githubusercontent.com/runearc-labs/runearc-ruby/main/.github/assets/banner-dark.png" width="100%">
|
|
6
|
+
</picture>
|
|
7
|
+
</a>
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
<p align="center">
|
|
11
|
+
<a href="https://github.com/runearc-labs/runearc-ruby/blob/main/CHANGELOG.md"><img alt="version 0.1.0" src="https://img.shields.io/badge/version-0.1.0-0007cd?style=flat-square&labelColor=0f0f0f"></a> <a href="https://github.com/runearc-labs/runearc-ruby/blob/main/LICENSE"><img alt="MIT licence" src="https://img.shields.io/badge/licence-MIT-2b2b2b?style=flat-square&labelColor=0f0f0f"></a> <a href="https://runearc.com/integrations/ruby"><img alt="requires ruby 3.0+" src="https://img.shields.io/badge/requires-ruby%203.0%2B-2b2b2b?style=flat-square&labelColor=0f0f0f"></a> <a href="https://runearc.com/docs/sdk"><img alt="tested against the real API" src="https://img.shields.io/badge/tests-real%20API-14833b?style=flat-square&labelColor=0f0f0f"></a>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<a href="https://runearc.com"><b>Website</b></a> ·
|
|
16
|
+
<a href="https://runearc.com/docs/sdk">Docs</a> ·
|
|
17
|
+
<a href="https://runearc.com/integrations/ruby">Ruby guide</a> ·
|
|
18
|
+
<a href="https://runearc.com/docs/mcp">MCP</a> ·
|
|
19
|
+
<a href="https://runearc.com/status">Status</a> ·
|
|
20
|
+
<a href="https://runearc.com/changelog">Changelog</a>
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
# runearc
|
|
24
|
+
|
|
25
|
+
The official Ruby SDK for [Runearc](https://runearc.com), email infrastructure for apps and AI agents. Send email and give your agents an inbox, from Ruby.
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
gem install runearc
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
require "runearc"
|
|
33
|
+
|
|
34
|
+
runearc = Runearc::Client.new(ENV["RUNEARC_API_KEY"])
|
|
35
|
+
email = runearc.emails.deliver(from: "Acme <noreply@acme.com>", to: "bob@example.com", subject: "Welcome to Acme", html: "<p>Thanks for signing up.</p>")
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
See [runearc.com/integrations/ruby](https://runearc.com/integrations/ruby) for the guide and more examples.
|
|
39
|
+
|
|
40
|
+
The official Ruby SDK for [Runearc](https://runearc.com): send, batch and schedule email, create inboxes your agents can read, manage domains and verify webhooks.
|
|
41
|
+
|
|
42
|
+
Standard library only (`net/http`, `json`, `openssl`), no runtime dependencies. Ruby 3.0 or later.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
gem install runearc
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Or in a `Gemfile`:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
gem "runearc"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Quick start
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
require "runearc"
|
|
60
|
+
|
|
61
|
+
runearc = Runearc::Client.new(ENV["RUNEARC_API_KEY"])
|
|
62
|
+
|
|
63
|
+
email = runearc.emails.deliver(
|
|
64
|
+
from: "Acme <noreply@acme.com>",
|
|
65
|
+
to: "bob@example.com",
|
|
66
|
+
subject: "Welcome to Acme",
|
|
67
|
+
html: "<p>Thanks for signing up.</p>"
|
|
68
|
+
)
|
|
69
|
+
email[:id] # => "eml_..."
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`Runearc::Client.new` with no key reads `RUNEARC_API_KEY`. Keyword options:
|
|
73
|
+
|
|
74
|
+
| Option | Default | |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| `base_url:` | `RUNEARC_BASE_URL`, else `https://api.runearc.com` | A trailing `/v1` is fine. |
|
|
77
|
+
| `max_retries:` | `2` | Retries after the first attempt (see [Retries](#retries)). |
|
|
78
|
+
| `timeout:` | `60` | Seconds, per attempt. |
|
|
79
|
+
| `headers:` | none | Sent with every request. |
|
|
80
|
+
|
|
81
|
+
Responses are the API's JSON as Hashes with symbol keys, and field names are the API's own snake case (`reply_to`, `scheduled_at`, `next_cursor`), so the [API reference](https://runearc.com/docs) and this SDK read the same. Every method also takes `request_timeout:` (seconds, overrides `timeout:` for that call), and the methods that send mail take `idempotency_key:`.
|
|
82
|
+
|
|
83
|
+
Ruby already has `Object#send`, so sending is `emails.deliver` and `messages.deliver`.
|
|
84
|
+
|
|
85
|
+
To try things without sending real mail, send to `delivered@test.runearc.com`. `bounced@` and `complained@` at the same domain simulate those outcomes.
|
|
86
|
+
|
|
87
|
+
## Errors
|
|
88
|
+
|
|
89
|
+
Methods return the parsed response and raise a `Runearc::Error` when something fails. There is no result wrapper.
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
begin
|
|
93
|
+
runearc.emails.deliver(from: "noreply@acme.com", to: "bob@example.com", subject: "Hi", text: "Hi")
|
|
94
|
+
rescue Runearc::Error => e
|
|
95
|
+
e.status # => 403
|
|
96
|
+
e.code # => "sender_not_verified", a stable string to branch on
|
|
97
|
+
e.kind # => "forbidden"
|
|
98
|
+
e.message
|
|
99
|
+
e.fix # what to do about it, when there is something to do
|
|
100
|
+
raise
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`Runearc::Error` has `status` (the HTTP status, or `0` when no response came back), `code`, `kind`, `message`, `fix`, `retry_after` (seconds, when the API sent `Retry-After`) and, for a refused batch, `errors`. `to_h` gives all of it as a Hash. `kind` is the API's error kind (`invalid`, `unauthorized`, `limit_reached`, `forbidden`, `not_found`, `conflict`, `rate_limited`, `unavailable`, `internal`), worked out from the status, or `connection` when no response came back.
|
|
105
|
+
|
|
106
|
+
Rescue one class for everything, or a narrower one:
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
Runearc::Error
|
|
110
|
+
ConfigurationError no API key (code missing_api_key)
|
|
111
|
+
ConnectionError network failed (code network_error)
|
|
112
|
+
TimeoutError code timeout
|
|
113
|
+
WebhookVerificationError see Verifying webhooks
|
|
114
|
+
ApiError the API answered with an error
|
|
115
|
+
InvalidRequestError 400
|
|
116
|
+
AuthenticationError 401
|
|
117
|
+
LimitReachedError 402
|
|
118
|
+
PermissionError 403
|
|
119
|
+
NotFoundError 404
|
|
120
|
+
ConflictError 409
|
|
121
|
+
RateLimitError 429
|
|
122
|
+
ServerError 5xx
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Lists come back as the API sends them: `{ data: [...] }`, plus `next_cursor` on paginated lists.
|
|
126
|
+
|
|
127
|
+
## Emails
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
# Send
|
|
131
|
+
id = runearc.emails.deliver(
|
|
132
|
+
from: "Acme <noreply@acme.com>",
|
|
133
|
+
to: ["bob@example.com"],
|
|
134
|
+
cc: "carol@example.com",
|
|
135
|
+
reply_to: "support@acme.com",
|
|
136
|
+
subject: "Your receipt",
|
|
137
|
+
html: "<p>Thanks for your order.</p>",
|
|
138
|
+
text: "Thanks for your order.",
|
|
139
|
+
attachments: [{ filename: "receipt.pdf", content: Base64.strict_encode64(pdf) }],
|
|
140
|
+
tags: [{ name: "kind", value: "receipt" }]
|
|
141
|
+
)[:id]
|
|
142
|
+
|
|
143
|
+
# Read
|
|
144
|
+
email = runearc.emails.get(id) # :status, :last_event, :recipients, :html, :text
|
|
145
|
+
page = runearc.emails.list(limit: 50)
|
|
146
|
+
more = runearc.emails.list(cursor: page[:next_cursor]) if page[:next_cursor]
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Scheduled send
|
|
150
|
+
|
|
151
|
+
Pass `scheduled_at:` as a `Time`, an RFC 3339 time with an offset, or the relative form `in <n> <unit>`. Up to 30 days ahead.
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
id = runearc.emails.deliver(
|
|
155
|
+
from: "Acme <noreply@acme.com>",
|
|
156
|
+
to: "bob@example.com",
|
|
157
|
+
subject: "Your trial ends tomorrow",
|
|
158
|
+
text: "Here is what happens next.",
|
|
159
|
+
scheduled_at: Time.now + 24 * 60 * 60 # or "2026-10-20T09:00:00Z", or "in 1 day"
|
|
160
|
+
)[:id]
|
|
161
|
+
|
|
162
|
+
runearc.emails.get(id) # status: "scheduled", scheduled_at: "..."
|
|
163
|
+
runearc.emails.reschedule(id, "in 2 days") # same as emails.update(id, scheduled_at: ...)
|
|
164
|
+
runearc.emails.cancel(id) # status: "canceled"
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Rescheduling or canceling an email that is no longer scheduled raises `ConflictError` with the code `email_not_scheduled`.
|
|
168
|
+
|
|
169
|
+
### Batch send
|
|
170
|
+
|
|
171
|
+
Up to 100 emails in one request. Each item has the shape of `emails.deliver`, `scheduled_at` included.
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
ids = runearc.emails.batch([
|
|
175
|
+
{ from: "Acme <noreply@acme.com>", to: "bob@example.com", subject: "Hi Bob", text: "..." },
|
|
176
|
+
{ from: "Acme <noreply@acme.com>", to: "carol@example.com", subject: "Hi Carol", text: "..." }
|
|
177
|
+
])[:data] # [{ id: }, { id: }] in request order
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
By default validation is strict: if any item is invalid nothing is sent, and the error lists every bad item.
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
begin
|
|
184
|
+
runearc.emails.batch(emails)
|
|
185
|
+
rescue Runearc::InvalidRequestError => e
|
|
186
|
+
e.errors.each { |item| puts [item[:index], item[:code], item[:message], item[:fix]].inspect } if e.code == "invalid_batch"
|
|
187
|
+
end
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
With `batch_validation: :permissive` the valid items are sent and the invalid ones are reported:
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
result = runearc.emails.batch(emails, batch_validation: :permissive)
|
|
194
|
+
result[:data] # the emails sent
|
|
195
|
+
result[:errors] # the items refused, each with its :index
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Senders
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
runearc.senders.list[:data] # addresses you can use as `from`
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## Inboxes and messages
|
|
205
|
+
|
|
206
|
+
Inboxes receive mail. Give one to an agent, wait for a message, read the code in it, reply.
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
inbox = runearc.inboxes.create(local_part: "signup-bot", display_name: "Signup bot")
|
|
210
|
+
runearc.inboxes.list
|
|
211
|
+
runearc.inboxes.get(inbox[:id])
|
|
212
|
+
|
|
213
|
+
# Wait up to 120 seconds for a matching message. Returns nil when none arrived.
|
|
214
|
+
got = runearc.messages.wait(inbox[:id], from: "github.com", subject: "verify", timeout: 60)
|
|
215
|
+
puts got[:codes], got[:links], got[:message][:subject] if got
|
|
216
|
+
|
|
217
|
+
page = runearc.messages.list(inbox[:id], limit: 20)
|
|
218
|
+
message = runearc.messages.get(page[:data].first[:id])
|
|
219
|
+
rfc822 = runearc.messages.raw(message[:id])
|
|
220
|
+
|
|
221
|
+
# Send from the inbox, and reply in a thread
|
|
222
|
+
runearc.messages.deliver(inbox[:id], to: "bob@example.com", subject: "Hello", text: "Hi Bob")
|
|
223
|
+
runearc.messages.reply(message[:id], text: "Thanks, on it", reply_all: false)
|
|
224
|
+
|
|
225
|
+
# Threads
|
|
226
|
+
runearc.threads.list(inbox[:id])
|
|
227
|
+
thread = runearc.threads.get(message[:thread_id]) # with :messages
|
|
228
|
+
|
|
229
|
+
runearc.inboxes.delete(inbox[:id])
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## Conversations
|
|
233
|
+
|
|
234
|
+
The shared support inbox view across every inbox you can read.
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
conversations = runearc.conversations.list(status: "open", assignee: "none", q: "refund")[:data]
|
|
238
|
+
id = conversations.first[:id]
|
|
239
|
+
runearc.conversations.counts
|
|
240
|
+
runearc.conversations.update(id, status: "closed")
|
|
241
|
+
runearc.conversations.update(id, snoozed_until: Time.now + 3600)
|
|
242
|
+
runearc.conversations.update(id, assignee_user_id: nil) # an explicit nil is sent as null: unassigns
|
|
243
|
+
runearc.conversations.read(id)
|
|
244
|
+
runearc.conversations.unread(id)
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## Domains
|
|
248
|
+
|
|
249
|
+
```ruby
|
|
250
|
+
domain = runearc.domains.create(name: "acme.com")
|
|
251
|
+
domain[:records] # the DNS records to publish
|
|
252
|
+
domain[:next_step]
|
|
253
|
+
|
|
254
|
+
runearc.domains.verify(domain[:id]) # checks DNS now
|
|
255
|
+
runearc.domains.list
|
|
256
|
+
runearc.domains.get("acme.com") # id or name
|
|
257
|
+
runearc.domains.delete(domain[:id])
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
## Webhooks
|
|
261
|
+
|
|
262
|
+
```ruby
|
|
263
|
+
hook = runearc.webhooks.create(
|
|
264
|
+
url: "https://acme.com/runearc/webhook",
|
|
265
|
+
event_types: ["message.received", "message.delivered", "message.bounced"]
|
|
266
|
+
)
|
|
267
|
+
# hook[:secret] ("whsec_...") is shown once: store it, you need it to verify deliveries.
|
|
268
|
+
|
|
269
|
+
runearc.webhooks.list
|
|
270
|
+
deliveries = runearc.webhooks.deliveries(hook[:id], limit: 20)[:data]
|
|
271
|
+
runearc.webhooks.replay(hook[:id], deliveries.first[:id])
|
|
272
|
+
runearc.webhooks.delete(hook[:id])
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### Verifying webhooks
|
|
276
|
+
|
|
277
|
+
Runearc signs every delivery with [Standard Webhooks](https://www.standardwebhooks.com): headers `webhook-id`, `webhook-timestamp` and `webhook-signature` (`v1,<base64 HMAC SHA256>`). `Runearc::Webhook.verify` (also `runearc.webhooks.verify` and `Runearc.verify_webhook`) checks the signature with OpenSSL and a constant time comparison, refuses timestamps more than 5 minutes off (`tolerance:` changes that), and returns the parsed event. It raises `Runearc::WebhookVerificationError`, a `Runearc::Error`, otherwise; its `code` is one of `missing_headers`, `invalid_timestamp`, `timestamp_out_of_range`, `invalid_secret`, `invalid_signature` or `invalid_payload`. Always pass the raw body, not JSON you parsed and serialized again.
|
|
278
|
+
|
|
279
|
+
`headers:` is a Hash with keys in any case, a Rack env (`HTTP_WEBHOOK_ID` keys), or any object that answers `headers["webhook-id"]`, such as Rails' `request.headers`.
|
|
280
|
+
|
|
281
|
+
Rails controller:
|
|
282
|
+
|
|
283
|
+
```ruby
|
|
284
|
+
class RunearcWebhooksController < ApplicationController
|
|
285
|
+
skip_forgery_protection
|
|
286
|
+
|
|
287
|
+
def create
|
|
288
|
+
event = Runearc::Webhook.verify(
|
|
289
|
+
payload: request.raw_post,
|
|
290
|
+
headers: request.headers,
|
|
291
|
+
secret: ENV.fetch("RUNEARC_WEBHOOK_SECRET")
|
|
292
|
+
)
|
|
293
|
+
if event[:type] == "message.received"
|
|
294
|
+
# event[:data][:message_id], event[:data][:inbox_id], ...
|
|
295
|
+
end
|
|
296
|
+
head :no_content
|
|
297
|
+
rescue Runearc::WebhookVerificationError => e
|
|
298
|
+
render plain: e.message, status: :bad_request
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
Sinatra or plain Rack:
|
|
304
|
+
|
|
305
|
+
```ruby
|
|
306
|
+
post "/runearc/webhook" do
|
|
307
|
+
event = Runearc::Webhook.verify(
|
|
308
|
+
payload: request.body.read,
|
|
309
|
+
headers: request.env,
|
|
310
|
+
secret: ENV.fetch("RUNEARC_WEBHOOK_SECRET")
|
|
311
|
+
)
|
|
312
|
+
puts event[:type], event[:id] # event[:id] is the webhook-id: use it to drop duplicates
|
|
313
|
+
status 204
|
|
314
|
+
rescue Runearc::WebhookVerificationError
|
|
315
|
+
halt 400, "invalid signature"
|
|
316
|
+
end
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
## API keys, suppressions and events
|
|
320
|
+
|
|
321
|
+
```ruby
|
|
322
|
+
created = runearc.keys.create(name: "app", permissions: "sending", domain_id: "acme.com")
|
|
323
|
+
created[:secret] # shown once
|
|
324
|
+
runearc.keys.list
|
|
325
|
+
runearc.keys.delete(created[:key][:id]) # revokes at once
|
|
326
|
+
|
|
327
|
+
runearc.suppressions.list
|
|
328
|
+
runearc.suppressions.delete("bob@example.com") # lets mail go to bob again
|
|
329
|
+
|
|
330
|
+
page = runearc.events.list(limit: 100) # oldest first
|
|
331
|
+
page = runearc.events.list(after: page[:next_after])
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
## Retries
|
|
335
|
+
|
|
336
|
+
The SDK retries a request up to `max_retries` times (default 2) with exponential backoff and jitter, and waits as long as the API's `Retry-After` header asks (seconds or an HTTP date, at most 60 seconds). It retries:
|
|
337
|
+
|
|
338
|
+
* 429 (rate limited), always;
|
|
339
|
+
* 5xx, network errors and timeouts, when the request is safe to repeat: GET, PATCH and DELETE, and any POST that carries an `Idempotency-Key`.
|
|
340
|
+
|
|
341
|
+
It never retries other 4xx errors. A POST that could create something twice (such as creating an inbox) is not repeated after a 5xx or a network error; the error is raised instead.
|
|
342
|
+
|
|
343
|
+
## Idempotency
|
|
344
|
+
|
|
345
|
+
Every call that sends mail (`emails.deliver`, `emails.batch`, `messages.deliver`, `messages.reply`) gets an `Idempotency-Key` automatically when you do not give one, and every retry reuses it, so a retry never sends twice. Runearc remembers keys for 24 hours. To make your own retries safe too, for example from a job queue, pass a key that is stable for that piece of work:
|
|
346
|
+
|
|
347
|
+
```ruby
|
|
348
|
+
runearc.emails.deliver(idempotency_key: "welcome/#{user.id}", **email)
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Repeating a key with the same body returns the first result. The same key with a different body raises `ConflictError` with the code `idempotency_conflict`.
|
|
352
|
+
|
|
353
|
+
## Types
|
|
354
|
+
|
|
355
|
+
RBS signatures for the whole public surface ship in `sig/runearc.rbs`, and every public method has YARD docs.
|
|
356
|
+
|
|
357
|
+
## Development
|
|
358
|
+
|
|
359
|
+
Tests run against a real local API and Postgres: `Stack` in `test/support/stack.rb` creates a throwaway database, bootstraps the API, and starts `runearc-api` and `runearc-worker` from `RUNEARC_BIN_DIR` (default `../../target/agent-sdk/debug`). Build them with `CARGO_TARGET_DIR=../../target/agent-sdk cargo build -p runearc-api -p runearc-worker`. Postgres must be on localhost with `createdb`, `dropdb` and `psql` on `PATH`.
|
|
360
|
+
|
|
361
|
+
```sh
|
|
362
|
+
bundle install
|
|
363
|
+
bundle exec rake test
|
|
364
|
+
bundle exec rubocop
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
## License
|
|
368
|
+
|
|
369
|
+
MIT, see [LICENSE](LICENSE). Copyright Runearc.
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "time"
|
|
8
|
+
require "uri"
|
|
9
|
+
|
|
10
|
+
module Runearc
|
|
11
|
+
# The Runearc API client. Create one and use its resources:
|
|
12
|
+
#
|
|
13
|
+
# runearc = Runearc::Client.new(ENV["RUNEARC_API_KEY"])
|
|
14
|
+
# runearc.emails.deliver(from: "Acme <noreply@acme.com>", to: "bob@example.com", subject: "Hi", text: "Hi")
|
|
15
|
+
#
|
|
16
|
+
# Responses are the API's JSON as Hashes with symbol keys, so the API reference and the
|
|
17
|
+
# SDK read the same (+:next_cursor+, +:scheduled_at+). Failures raise {Runearc::Error}.
|
|
18
|
+
#
|
|
19
|
+
# Every method takes the per call options +idempotency_key:+ and +request_timeout:+ (seconds).
|
|
20
|
+
class Client
|
|
21
|
+
# Retries after the first attempt, on 429, 5xx and network errors.
|
|
22
|
+
DEFAULT_MAX_RETRIES = 2
|
|
23
|
+
|
|
24
|
+
# Per attempt timeout in seconds.
|
|
25
|
+
DEFAULT_TIMEOUT = 60
|
|
26
|
+
|
|
27
|
+
# Longest wait between attempts, in seconds, even when +Retry-After+ asks for more.
|
|
28
|
+
MAX_RETRY_WAIT = 60
|
|
29
|
+
|
|
30
|
+
NETWORK_ERRORS = [
|
|
31
|
+
SocketError, SystemCallError, IOError, EOFError, OpenSSL::SSL::SSLError, Net::ProtocolError
|
|
32
|
+
].freeze
|
|
33
|
+
private_constant :NETWORK_ERRORS
|
|
34
|
+
|
|
35
|
+
TIMEOUT_ERRORS = [Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout].freeze
|
|
36
|
+
private_constant :TIMEOUT_ERRORS
|
|
37
|
+
|
|
38
|
+
HTTP_METHODS = {
|
|
39
|
+
get: Net::HTTP::Get, post: Net::HTTP::Post, patch: Net::HTTP::Patch, delete: Net::HTTP::Delete
|
|
40
|
+
}.freeze
|
|
41
|
+
private_constant :HTTP_METHODS
|
|
42
|
+
|
|
43
|
+
# @return [String] the API origin, without a trailing +/v1+
|
|
44
|
+
attr_reader :base_url
|
|
45
|
+
|
|
46
|
+
# @return [Integer] retries after the first attempt
|
|
47
|
+
attr_reader :max_retries
|
|
48
|
+
|
|
49
|
+
# @return [Numeric] per attempt timeout in seconds
|
|
50
|
+
attr_reader :timeout
|
|
51
|
+
|
|
52
|
+
# @return [Emails]
|
|
53
|
+
attr_reader :emails
|
|
54
|
+
# @return [Senders]
|
|
55
|
+
attr_reader :senders
|
|
56
|
+
# @return [Inboxes]
|
|
57
|
+
attr_reader :inboxes
|
|
58
|
+
# @return [Messages]
|
|
59
|
+
attr_reader :messages
|
|
60
|
+
# @return [Threads]
|
|
61
|
+
attr_reader :threads
|
|
62
|
+
# @return [Conversations]
|
|
63
|
+
attr_reader :conversations
|
|
64
|
+
# @return [Domains]
|
|
65
|
+
attr_reader :domains
|
|
66
|
+
# @return [Webhooks]
|
|
67
|
+
attr_reader :webhooks
|
|
68
|
+
# @return [Keys]
|
|
69
|
+
attr_reader :keys
|
|
70
|
+
# @return [Suppressions]
|
|
71
|
+
attr_reader :suppressions
|
|
72
|
+
# @return [Events]
|
|
73
|
+
attr_reader :events
|
|
74
|
+
|
|
75
|
+
# @param api_key [String, nil] the API key; +RUNEARC_API_KEY+ when nil
|
|
76
|
+
# @param base_url [String, nil] API origin. Default +RUNEARC_BASE_URL+ or https://api.runearc.com.
|
|
77
|
+
# A trailing +/v1+ is fine.
|
|
78
|
+
# @param max_retries [Integer] retries after the first attempt, on 429, 5xx and network errors (default 2)
|
|
79
|
+
# @param timeout [Numeric] per attempt timeout in seconds (default 60)
|
|
80
|
+
# @param headers [Hash{String => String}] extra headers sent with every request
|
|
81
|
+
# @raise [ConfigurationError] when there is no API key
|
|
82
|
+
def initialize(api_key = nil, base_url: nil, max_retries: DEFAULT_MAX_RETRIES, timeout: DEFAULT_TIMEOUT,
|
|
83
|
+
headers: {})
|
|
84
|
+
@api_key = present(api_key) || present(ENV.fetch("RUNEARC_API_KEY", nil)) || raise(missing_key)
|
|
85
|
+
base = present(base_url) || present(ENV.fetch("RUNEARC_BASE_URL", nil)) || DEFAULT_BASE_URL
|
|
86
|
+
@base_url = base.sub(%r{/+\z}, "").sub(%r{/v1\z}, "")
|
|
87
|
+
@max_retries = [max_retries, 0].max
|
|
88
|
+
@timeout = timeout
|
|
89
|
+
@headers = headers.to_h { |k, v| [k.to_s, v.to_s] }
|
|
90
|
+
build_resources
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Hides the API key.
|
|
94
|
+
#
|
|
95
|
+
# @return [String]
|
|
96
|
+
def inspect
|
|
97
|
+
"#<#{self.class.name} base_url=#{base_url.inspect}>"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Every API call goes through here.
|
|
101
|
+
#
|
|
102
|
+
# @api private
|
|
103
|
+
# @return [Hash, Array, String, nil] the parsed JSON, the body when +text+, or nil for an empty body
|
|
104
|
+
def request(method, path, query: nil, body: nil, headers: nil, sends_mail: false, idempotent: false,
|
|
105
|
+
text: false, idempotency_key: nil, request_timeout: nil)
|
|
106
|
+
uri = build_uri(path, query)
|
|
107
|
+
idem = idempotency_key || (SecureRandom.uuid if sends_mail)
|
|
108
|
+
head = build_headers(headers, idem, body)
|
|
109
|
+
payload = JSON.generate(body) unless body.nil?
|
|
110
|
+
# Repeating must not create a second resource: GET, DELETE and PATCH are idempotent,
|
|
111
|
+
# and a POST is when it carries an Idempotency-Key.
|
|
112
|
+
repeatable = method != :post || !idem.nil? || idempotent
|
|
113
|
+
seconds = request_timeout || timeout
|
|
114
|
+
|
|
115
|
+
attempt = 0
|
|
116
|
+
loop do
|
|
117
|
+
last = attempt >= max_retries
|
|
118
|
+
begin
|
|
119
|
+
res = perform(method, uri, head, payload, seconds)
|
|
120
|
+
rescue *TIMEOUT_ERRORS, *NETWORK_ERRORS => e
|
|
121
|
+
raise connection_error(e, idem, seconds) unless repeatable && !last
|
|
122
|
+
|
|
123
|
+
sleep(backoff(attempt))
|
|
124
|
+
attempt += 1
|
|
125
|
+
next
|
|
126
|
+
end
|
|
127
|
+
return parse_success(res, text) if res.is_a?(Net::HTTPSuccess)
|
|
128
|
+
|
|
129
|
+
wait = retry_after(res["retry-after"])
|
|
130
|
+
status = res.code.to_i
|
|
131
|
+
if (status == 429 || (status >= 500 && repeatable)) && !last
|
|
132
|
+
sleep([wait || backoff(attempt), MAX_RETRY_WAIT].min)
|
|
133
|
+
attempt += 1
|
|
134
|
+
next
|
|
135
|
+
end
|
|
136
|
+
raise api_error(res, wait)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def present(value)
|
|
143
|
+
value unless value.nil? || value.to_s.empty?
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def missing_key
|
|
147
|
+
ConfigurationError.new(
|
|
148
|
+
"no Runearc API key was given",
|
|
149
|
+
code: "missing_api_key",
|
|
150
|
+
fix: "Pass it as Runearc::Client.new('mb_...') or set RUNEARC_API_KEY"
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def build_resources
|
|
155
|
+
@emails = Emails.new(self)
|
|
156
|
+
@senders = Senders.new(self)
|
|
157
|
+
@inboxes = Inboxes.new(self)
|
|
158
|
+
@messages = Messages.new(self)
|
|
159
|
+
@threads = Threads.new(self)
|
|
160
|
+
@conversations = Conversations.new(self)
|
|
161
|
+
@domains = Domains.new(self)
|
|
162
|
+
@webhooks = Webhooks.new(self)
|
|
163
|
+
@keys = Keys.new(self)
|
|
164
|
+
@suppressions = Suppressions.new(self)
|
|
165
|
+
@events = Events.new(self)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def build_uri(path, query)
|
|
169
|
+
uri = URI.parse(base_url + path)
|
|
170
|
+
pairs = (query || {}).compact.map { |k, v| [k.to_s, Support.time(v).to_s] }
|
|
171
|
+
uri.query = URI.encode_www_form(pairs) unless pairs.empty?
|
|
172
|
+
uri
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def build_headers(extra, idempotency_key, body)
|
|
176
|
+
head = { "Accept" => "application/json", "User-Agent" => "runearc-ruby/#{VERSION}" }
|
|
177
|
+
head.merge!(@headers)
|
|
178
|
+
head.merge!(extra) if extra
|
|
179
|
+
head["Authorization"] = "Bearer #{@api_key}"
|
|
180
|
+
head["Idempotency-Key"] = idempotency_key if idempotency_key
|
|
181
|
+
head["Content-Type"] = "application/json" unless body.nil?
|
|
182
|
+
head
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def perform(method, uri, headers, payload, seconds)
|
|
186
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
187
|
+
http.use_ssl = uri.scheme == "https"
|
|
188
|
+
http.open_timeout = http.read_timeout = http.write_timeout = seconds
|
|
189
|
+
# Net::HTTP would repeat idempotent requests by itself; the SDK owns retries.
|
|
190
|
+
http.max_retries = 0
|
|
191
|
+
req = HTTP_METHODS.fetch(method).new(uri.request_uri, headers)
|
|
192
|
+
req.body = payload if payload
|
|
193
|
+
http.start { |conn| conn.request(req) }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Seconds to wait from a Retry-After header (seconds or an HTTP date).
|
|
197
|
+
def retry_after(value)
|
|
198
|
+
return nil if value.nil? || value.empty?
|
|
199
|
+
|
|
200
|
+
seconds = Float(value, exception: false)
|
|
201
|
+
return [seconds, 0].max if seconds&.finite?
|
|
202
|
+
|
|
203
|
+
at = http_date(value)
|
|
204
|
+
at && [at - Time.now, 0].max
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def http_date(value)
|
|
208
|
+
Time.httpdate(value)
|
|
209
|
+
rescue ArgumentError
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def backoff(attempt)
|
|
214
|
+
cap = [8.0, 0.5 * (2**attempt)].min
|
|
215
|
+
(cap / 2) + (rand * (cap / 2))
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def parse_success(res, text)
|
|
219
|
+
raw = res.body.to_s.dup.force_encoding(Encoding::UTF_8)
|
|
220
|
+
return raw if text
|
|
221
|
+
return nil if raw.empty?
|
|
222
|
+
|
|
223
|
+
JSON.parse(raw, symbolize_names: true)
|
|
224
|
+
rescue JSON::ParserError => e
|
|
225
|
+
raise ApiError.build(
|
|
226
|
+
"the API sent a response that is not JSON (#{e.message[0, 80]})",
|
|
227
|
+
status: res.code.to_i, code: "invalid_response", fix: "Check that base_url points at the Runearc API"
|
|
228
|
+
)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def connection_error(cause, idempotency_key, seconds)
|
|
232
|
+
fix = if idempotency_key
|
|
233
|
+
"Retry with the same idempotency_key (#{idempotency_key}) so the email is not sent twice"
|
|
234
|
+
else
|
|
235
|
+
"Check your network and base_url, then retry"
|
|
236
|
+
end
|
|
237
|
+
if TIMEOUT_ERRORS.any? { |k| cause.is_a?(k) }
|
|
238
|
+
TimeoutError.new("no response from #{base_url} within #{seconds} s", fix: fix)
|
|
239
|
+
else
|
|
240
|
+
ConnectionError.new("could not reach #{base_url}: #{cause.message}", fix: fix)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def api_error(res, wait)
|
|
245
|
+
status = res.code.to_i
|
|
246
|
+
text = res.body.to_s.dup.force_encoding(Encoding::UTF_8).scrub
|
|
247
|
+
info = begin
|
|
248
|
+
JSON.parse(text, symbolize_names: true)[:error]
|
|
249
|
+
rescue JSON::ParserError, TypeError, NoMethodError
|
|
250
|
+
nil
|
|
251
|
+
end
|
|
252
|
+
info = {} unless info.is_a?(Hash)
|
|
253
|
+
fallback = text.empty? ? "HTTP #{status}" : text[0, 500]
|
|
254
|
+
ApiError.build(
|
|
255
|
+
info[:message] || fallback,
|
|
256
|
+
status: status,
|
|
257
|
+
code: info[:code] || "http_#{status}",
|
|
258
|
+
kind: info[:kind],
|
|
259
|
+
fix: info[:fix],
|
|
260
|
+
errors: (info[:errors] if info[:errors].is_a?(Array)),
|
|
261
|
+
retry_after: wait&.ceil
|
|
262
|
+
)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|