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 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