anypost 1.0.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 +352 -0
- data/lib/anypost/client.rb +72 -0
- data/lib/anypost/errors.rb +203 -0
- data/lib/anypost/http_client.rb +149 -0
- data/lib/anypost/page.rb +55 -0
- data/lib/anypost/resources/api_keys.rb +38 -0
- data/lib/anypost/resources/base.rb +36 -0
- data/lib/anypost/resources/domains.rb +44 -0
- data/lib/anypost/resources/email.rb +48 -0
- data/lib/anypost/resources/events.rb +34 -0
- data/lib/anypost/resources/identity.rb +13 -0
- data/lib/anypost/resources/suppressions.rb +55 -0
- data/lib/anypost/resources/templates.rb +62 -0
- data/lib/anypost/resources/webhooks.rb +51 -0
- data/lib/anypost/response.rb +67 -0
- data/lib/anypost/version.rb +7 -0
- data/lib/anypost/webhook_signature.rb +114 -0
- data/lib/anypost.rb +22 -0
- metadata +81 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 65b858025a9f3fda8fb8a21e6c704503e49eaad8f0fcac5913c4a7808524acb5
|
|
4
|
+
data.tar.gz: 82615f81815ca2da06ae99b3e3a07c8e76eef059ec229c21a1324d5326f75030
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: dd83c2564df58e7dcc5c1819833578cd60040e1d25f0b0a2a8542c06968870b3b6615287dacddf95a7e058337a823e66f4394662a4fd26ba3ef20faf90219c7d
|
|
7
|
+
data.tar.gz: ce4d157c91e82055a0bd191f6ce3cd6bb8505bd8be8dac779f67e3b592f96855b79c9caf82c8250a93eab85b278e862d36eea2b2d23aca98287d83837f4f2f58
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 unMTA LLC
|
|
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,352 @@
|
|
|
1
|
+
# Anypost Ruby SDK
|
|
2
|
+
|
|
3
|
+
The official Ruby gem for the [Anypost](https://anypost.com) email API.
|
|
4
|
+
|
|
5
|
+
Requires Ruby 3.2+. Built on [Faraday](https://github.com/lostisland/faraday).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
gem install anypost
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or add it to your Gemfile:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem "anypost"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quickstart
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
require "anypost"
|
|
23
|
+
|
|
24
|
+
client = Anypost::Client.new("ap_your_api_key")
|
|
25
|
+
|
|
26
|
+
email = client.email.send(
|
|
27
|
+
from: "Acme <you@yourdomain.com>",
|
|
28
|
+
to: ["someone@example.com"],
|
|
29
|
+
subject: "Hello from Anypost",
|
|
30
|
+
html: "<p>It worked.</p>"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
puts email.id
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The constructor also reads `ANYPOST_API_KEY` from the environment:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
client = Anypost::Client.new
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Keep the key server-side. It is a bearer credential; never ship it to a browser or mobile app.
|
|
43
|
+
|
|
44
|
+
Request bodies are plain hashes with symbol keys that match the API one-to-one. Responses come back as `Anypost::Response` objects: read fields with either method or bracket syntax (`email.id` or `email[:id]`), and nested objects are themselves responses. Call `email.to_h` for the raw decoded hash.
|
|
45
|
+
|
|
46
|
+
## Sending
|
|
47
|
+
|
|
48
|
+
One of `text`, `html`, or `template_id` is required. All recipients in `to`, `cc`, and `bcc` share one envelope and count against a combined limit of 50.
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
client.email.send(
|
|
52
|
+
from: "Acme <you@yourdomain.com>",
|
|
53
|
+
to: ["a@example.com", "b@example.com"],
|
|
54
|
+
cc: ["team@example.com"],
|
|
55
|
+
reply_to: "support@yourdomain.com",
|
|
56
|
+
subject: "Receipt #4823",
|
|
57
|
+
html: "<p>Thanks for your order.</p>",
|
|
58
|
+
text: "Thanks for your order.",
|
|
59
|
+
tags: ["receipt"]
|
|
60
|
+
)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Attachment `content` is the raw file bytes — pass what `File.binread` returns and the SDK base64-encodes it. Do not pre-encode it. The request body is capped at 5 MB.
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
client.email.send(
|
|
67
|
+
from: "you@yourdomain.com",
|
|
68
|
+
to: ["someone@example.com"],
|
|
69
|
+
subject: "Your report",
|
|
70
|
+
text: "Attached.",
|
|
71
|
+
attachments: [
|
|
72
|
+
{filename: "report.pdf", content: File.binread("report.pdf")}
|
|
73
|
+
]
|
|
74
|
+
)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Send with a published template and per-recipient variables:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
client.email.send(
|
|
81
|
+
from: "you@yourdomain.com",
|
|
82
|
+
to: ["someone@example.com"],
|
|
83
|
+
template_id: "template_018f2c5e-3a40-7a91-9c25-3a0b1d5e6f78",
|
|
84
|
+
variables: {name: "Ada", plan: "pro"}
|
|
85
|
+
)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Batch
|
|
89
|
+
|
|
90
|
+
Send 1 to 100 independent messages in one request. `defaults` fills any field an entry omits.
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
result = client.email.send_batch(
|
|
94
|
+
defaults: {from: "you@yourdomain.com"},
|
|
95
|
+
emails: [
|
|
96
|
+
{to: ["a@example.com"], subject: "Hi A", text: "..."},
|
|
97
|
+
{to: ["b@example.com"], subject: "Hi B", text: "..."}
|
|
98
|
+
]
|
|
99
|
+
)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
A batch with mixed outcomes returns HTTP `207` and resolves normally. Inspect each entry rather than rescuing an error:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
result.summary # { total:, queued:, failed: }
|
|
106
|
+
|
|
107
|
+
result.data.each do |entry|
|
|
108
|
+
if entry.status == "queued"
|
|
109
|
+
puts "#{entry.index} #{entry.id}"
|
|
110
|
+
else
|
|
111
|
+
puts "#{entry.index} #{entry.error.type} #{entry.error.message}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Domains
|
|
117
|
+
|
|
118
|
+
Manage sending domains under `client.domains`. Add a domain, publish the CNAMEs it returns, then verify.
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
domain = client.domains.create(name: "example.com")
|
|
122
|
+
|
|
123
|
+
domain.dns_records.each do |record|
|
|
124
|
+
puts "#{record.type} #{record.name} -> #{record.value}"
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`verify` always returns the current domain — a still-`pending` domain does not raise. Read `status` and `verification_failure`, and poll while DNS propagates.
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
checked = client.domains.verify(domain.id)
|
|
132
|
+
puts checked.verification_failure.code unless checked.status == "verified"
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`get`, `update` (tracking config only), and `delete` round out the resource:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
client.domains.update(domain.id, tracking: {opens_enabled: true, clicks_enabled: true, subdomain: "track"})
|
|
139
|
+
client.domains.delete(domain.id)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## API keys
|
|
143
|
+
|
|
144
|
+
Manage keys under `client.api_keys`. The plaintext secret comes back only once, on `create`, as `key`:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
created = client.api_keys.create(
|
|
148
|
+
name: "Production server",
|
|
149
|
+
permissions: "send_only",
|
|
150
|
+
allowed_domains: ["example.com"]
|
|
151
|
+
)
|
|
152
|
+
puts created.key # store now; never retrievable again
|
|
153
|
+
|
|
154
|
+
client.api_keys.update(created.id, name: "Production server", permissions: "full")
|
|
155
|
+
client.api_keys.delete(created.id)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`get` returns metadata only — `key_prefix`, never the secret. Permission and restriction changes take up to 5 minutes to propagate through the gateway cache.
|
|
159
|
+
|
|
160
|
+
## Templates
|
|
161
|
+
|
|
162
|
+
Templates use a draft/published model: edits land in a draft, and `publish` promotes it. A template can't be used for sending until it's published.
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
template = client.templates.create(
|
|
166
|
+
name: "Welcome email",
|
|
167
|
+
kind: "html",
|
|
168
|
+
html: "<h1>Welcome, {{ name }}</h1>"
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
client.templates.update_draft(template.id, subject: "Welcome to Acme", html: "<h1>Welcome, {{ name }}</h1>")
|
|
172
|
+
client.templates.publish(template.id)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`kind` is `html` or `markdown` and is immutable once set. The plain-text body is always derived server-side. `get_draft`, `delete_draft`, `duplicate`, `get`, `update` (name only), and `delete` round out the resource. Send with a published template via `template_id` (see [Sending](#sending)).
|
|
176
|
+
|
|
177
|
+
## Suppressions
|
|
178
|
+
|
|
179
|
+
A suppression blocks sends to an address, scoped to a `topic`. The wildcard `*` blocks every topic; a specific topic (e.g. `marketing`) leaves transactional traffic untouched. Bounces and complaints write `*` automatically.
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
client.suppressions.create(email: "alice@example.com", topic: "marketing", note: "Customer requested removal")
|
|
183
|
+
|
|
184
|
+
row = client.suppressions.get("alice@example.com", "*")
|
|
185
|
+
client.suppressions.delete("alice@example.com", "marketing")
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`list` accepts `email_contains`, `topic`, `reason`, and `origin` filters. `list_for_email` returns every row for an address across all topics; `delete_for_email` removes them all.
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
client.suppressions.list(reason: "complaint").each do |s|
|
|
192
|
+
puts "#{s.email} #{s.topic} #{s.suppressed_at}"
|
|
193
|
+
end
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Webhooks
|
|
197
|
+
|
|
198
|
+
Manage webhook subscriptions under `client.webhooks`. The `signing_secret` comes back only once, on `create`; later reads return only `signing_secret_prefix`.
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
webhook = client.webhooks.create(
|
|
202
|
+
name: "Production events",
|
|
203
|
+
url: "https://hooks.example.com/anypost",
|
|
204
|
+
events: ["email.delivered", "email.bounced", "email.complained"]
|
|
205
|
+
)
|
|
206
|
+
puts webhook.signing_secret # store now; never retrievable again
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
`update` sets the name, URL, events, and `status` together — set `status` to `"disabled"` to pause delivery, `"active"` to resume. `test` sends one synthetic `webhook.test` event and returns the outcome even when the endpoint fails. `rotate_secret` issues a new secret and keeps the previous one valid for a 24-hour grace window; `get`, `list`, and `delete` round out the resource.
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
result = client.webhooks.test(webhook.id)
|
|
213
|
+
puts "#{result.status_code} #{result.error}" unless result.delivered
|
|
214
|
+
|
|
215
|
+
rotated = client.webhooks.rotate_secret(webhook.id)
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Verifying deliveries
|
|
219
|
+
|
|
220
|
+
`Anypost::WebhookSignature.verify` is a module method — it needs the signing secret, not an API key, so call it in your handler without a client. Pass the **raw** request body (the exact bytes, before JSON parsing), the `Anypost-Signature` header, and the secret. It returns on success and raises `Anypost::WebhookVerificationError` otherwise. `Anypost::WebhookSignature.unwrap` does the same and returns the parsed delivery as a `Response`.
|
|
221
|
+
|
|
222
|
+
```ruby
|
|
223
|
+
begin
|
|
224
|
+
delivery = Anypost::WebhookSignature.unwrap(raw_body, signature_header, secret)
|
|
225
|
+
delivery.events.each do |event|
|
|
226
|
+
# event.type, event.data.email_id, ...
|
|
227
|
+
end
|
|
228
|
+
rescue Anypost::WebhookVerificationError => e
|
|
229
|
+
# e.reason: :no_match | :timestamp_out_of_tolerance | ...
|
|
230
|
+
halt 400
|
|
231
|
+
end
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Reach for `verify` when something else has already parsed the body. Keep the raw bytes for the verify step, then use your parsed object once it passes — a Rack-style handler:
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
post "/anypost" do
|
|
238
|
+
raw = request.body.read
|
|
239
|
+
begin
|
|
240
|
+
Anypost::WebhookSignature.verify(raw, request.env["HTTP_ANYPOST_SIGNATURE"], secret)
|
|
241
|
+
rescue Anypost::WebhookVerificationError
|
|
242
|
+
halt 400
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
JSON.parse(raw)["events"].each { |event| handle(event) }
|
|
246
|
+
status 204
|
|
247
|
+
end
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Deliveries older than five minutes are rejected by default to bound replay; pass `tolerance_seconds:` to widen, narrow, or disable (`0`) that check. During a secret rotation the header carries a `v1=` component per active secret, and a match on any one passes — so deliveries keep verifying while you redeploy.
|
|
251
|
+
|
|
252
|
+
## Events
|
|
253
|
+
|
|
254
|
+
`client.events.list` pages the team's event stream, newest-first. The window defaults to the last 24 hours and is clamped to your plan's retention. Events are read-only and not addressable by id — there is no `get`.
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
client.events.list(event_type: "email.bounced").each do |event|
|
|
258
|
+
puts "#{event.occurred_at} #{event.recipient} #{event.bounce_classification}"
|
|
259
|
+
end
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Filter by `start`, `end`, `event_type`, `recipient`, `email_id`, `message_id`, `domain`, `topic`, `campaign`, `template_id`, and `tags`. All filters are exact-match, except `tags`, which takes an array and matches an event carrying *any* of the given tags. A filter value that matches no row returns an empty page. This is also how you backfill the gap after a webhook endpoint was disabled — page the events that occurred during the outage once it's healthy.
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
# Events tagged "onboarding" OR "welcome", that also bounced.
|
|
266
|
+
page = client.events.list(tags: ["onboarding", "welcome"], event_type: "email.bounced")
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
## Pagination
|
|
270
|
+
|
|
271
|
+
List endpoints return a `Page`. Read one page directly, or iterate it to walk every page — the client fetches each one as needed.
|
|
272
|
+
|
|
273
|
+
```ruby
|
|
274
|
+
page = client.domains.list(limit: 50)
|
|
275
|
+
page.data # this page's items
|
|
276
|
+
page.has_more # whether another page exists
|
|
277
|
+
page.next_cursor # pass as :after to fetch it yourself
|
|
278
|
+
|
|
279
|
+
client.domains.list.each do |domain|
|
|
280
|
+
puts domain.name # every domain, across all pages
|
|
281
|
+
end
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
A `Page` is `Enumerable`, so `map`, `select`, `find`, and friends all walk every page.
|
|
285
|
+
|
|
286
|
+
## Errors
|
|
287
|
+
|
|
288
|
+
A failed request raises an `Anypost::Error` subclass. Branch on `error.type`, the stable machine-readable code, not on the HTTP status.
|
|
289
|
+
|
|
290
|
+
```ruby
|
|
291
|
+
begin
|
|
292
|
+
client.email.send(message)
|
|
293
|
+
rescue Anypost::ValidationError => e
|
|
294
|
+
e.errors # {"from" => ["The from field is required."]}
|
|
295
|
+
rescue Anypost::RateLimitError => e
|
|
296
|
+
e.retry_after # seconds, or nil
|
|
297
|
+
rescue Anypost::Error => e
|
|
298
|
+
"#{e.type} #{e.status} #{e.message}"
|
|
299
|
+
end
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
| Class | `type` | Status |
|
|
303
|
+
|---|---|---|
|
|
304
|
+
| `ValidationError` | `validation_error` | `400`, `422` |
|
|
305
|
+
| `AuthenticationError` | `authentication_error` | `401` |
|
|
306
|
+
| `PermissionError` | `permission_error` | `403` |
|
|
307
|
+
| `NotFoundError` | `not_found` | `404` |
|
|
308
|
+
| `ConflictError` | `idempotency_concurrent`, `webhook_rotation_in_progress` | `409` |
|
|
309
|
+
| `IdempotencyMismatchError` | `idempotency_mismatch` | `422` |
|
|
310
|
+
| `RateLimitError` | `rate_limit_exceeded` | `429` |
|
|
311
|
+
| `PayloadTooLargeError` | `payload_too_large` | `413` |
|
|
312
|
+
| `APIError` | `internal_error`, `provisioning_error` | `5xx` |
|
|
313
|
+
| `APIConnectionError` | `connection_error` | none |
|
|
314
|
+
|
|
315
|
+
Every error carries `type`, `status`, `message`, `request_id`, and the parsed `raw` body.
|
|
316
|
+
|
|
317
|
+
## Retries and idempotency
|
|
318
|
+
|
|
319
|
+
The client retries `429`, `502`, `503`, and network failures up to `max_retries` times (default 2), with exponential backoff and full jitter. It honors `Retry-After`.
|
|
320
|
+
|
|
321
|
+
Sends are made safe to retry automatically: when retries are enabled and you do not pass an idempotency key, the client generates one and reuses it across attempts, so a retried send cannot deliver twice. Pass your own key (the second argument) to dedupe across process restarts:
|
|
322
|
+
|
|
323
|
+
```ruby
|
|
324
|
+
client.email.send(message, order_id)
|
|
325
|
+
client.email.send_batch(batch, idempotency_key)
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
## Configuration
|
|
329
|
+
|
|
330
|
+
```ruby
|
|
331
|
+
Anypost::Client.new(
|
|
332
|
+
"ap_your_api_key",
|
|
333
|
+
base_url: "https://api.anypost.com/v1",
|
|
334
|
+
timeout: 30,
|
|
335
|
+
max_retries: 2,
|
|
336
|
+
default_headers: {"X-My-Header" => "value"}
|
|
337
|
+
)
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
| Option | Default | Description |
|
|
341
|
+
|---|---|---|
|
|
342
|
+
| `base_url` | `https://api.anypost.com/v1` | API base URL. |
|
|
343
|
+
| `timeout` | `30` | Per-request timeout, in seconds. |
|
|
344
|
+
| `max_retries` | `2` | Automatic retries for transient failures. |
|
|
345
|
+
| `default_headers` | `{}` | Extra headers sent on every request. |
|
|
346
|
+
| `connection` | a new one | Bring your own Faraday connection. |
|
|
347
|
+
|
|
348
|
+
The first argument is the API key (`ap_...`); omit it to read `ANYPOST_API_KEY`. `send` and `send_batch` accept a per-call idempotency key as their second argument.
|
|
349
|
+
|
|
350
|
+
## License
|
|
351
|
+
|
|
352
|
+
MIT
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Anypost
|
|
4
|
+
# Client for the Anypost email API.
|
|
5
|
+
#
|
|
6
|
+
# require "anypost"
|
|
7
|
+
#
|
|
8
|
+
# client = Anypost::Client.new("ap_your_api_key") # or Anypost::Client.new to read ANYPOST_API_KEY
|
|
9
|
+
# email = client.email.send(
|
|
10
|
+
# from: "Acme <you@yourdomain.com>",
|
|
11
|
+
# to: ["someone@example.com"],
|
|
12
|
+
# subject: "Hello",
|
|
13
|
+
# html: "<p>It worked.</p>"
|
|
14
|
+
# )
|
|
15
|
+
# email.id
|
|
16
|
+
class Client
|
|
17
|
+
DEFAULT_BASE_URL = "https://api.anypost.com/v1"
|
|
18
|
+
DEFAULT_TIMEOUT = 30
|
|
19
|
+
DEFAULT_MAX_RETRIES = 2
|
|
20
|
+
|
|
21
|
+
# @return [Resources::Email] send operations (`/email`, `/email/batch`)
|
|
22
|
+
attr_reader :email
|
|
23
|
+
# @return [Resources::Domains] sending-domain operations (`/domains`)
|
|
24
|
+
attr_reader :domains
|
|
25
|
+
# @return [Resources::ApiKeys] API-key operations (`/api-keys`)
|
|
26
|
+
attr_reader :api_keys
|
|
27
|
+
# @return [Resources::Templates] template operations, including draft/publish
|
|
28
|
+
attr_reader :templates
|
|
29
|
+
# @return [Resources::Suppressions] suppression-list operations (`/suppressions`)
|
|
30
|
+
attr_reader :suppressions
|
|
31
|
+
# @return [Resources::Webhooks] webhook operations, including test and rotation
|
|
32
|
+
attr_reader :webhooks
|
|
33
|
+
# @return [Resources::Events] read access to the event stream (`/events`)
|
|
34
|
+
attr_reader :events
|
|
35
|
+
|
|
36
|
+
# @param api_key [String, nil] defaults to the ANYPOST_API_KEY environment variable
|
|
37
|
+
def initialize(api_key = nil, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT,
|
|
38
|
+
max_retries: DEFAULT_MAX_RETRIES, default_headers: {}, connection: nil, sleeper: nil, jitter: nil)
|
|
39
|
+
key = api_key
|
|
40
|
+
key = ENV["ANYPOST_API_KEY"] if key.nil? || key.empty?
|
|
41
|
+
if key.nil? || key.empty?
|
|
42
|
+
raise ArgumentError,
|
|
43
|
+
"An Anypost API key is required. Pass it to the constructor or set ANYPOST_API_KEY."
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
http = HttpClient.new(
|
|
47
|
+
api_key: key,
|
|
48
|
+
base_url: base_url,
|
|
49
|
+
timeout: timeout,
|
|
50
|
+
max_retries: max_retries,
|
|
51
|
+
default_headers: default_headers,
|
|
52
|
+
connection: connection,
|
|
53
|
+
sleeper: sleeper,
|
|
54
|
+
jitter: jitter
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@email = Resources::Email.new(http)
|
|
58
|
+
@domains = Resources::Domains.new(http)
|
|
59
|
+
@api_keys = Resources::ApiKeys.new(http)
|
|
60
|
+
@templates = Resources::Templates.new(http)
|
|
61
|
+
@suppressions = Resources::Suppressions.new(http)
|
|
62
|
+
@webhooks = Resources::Webhooks.new(http)
|
|
63
|
+
@events = Resources::Events.new(http)
|
|
64
|
+
@identity = Resources::Identity.new(http)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Identify the team and permission level behind the current API key.
|
|
68
|
+
def whoami
|
|
69
|
+
@identity.whoami
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Anypost
|
|
6
|
+
# Base class for every error raised by the SDK.
|
|
7
|
+
#
|
|
8
|
+
# Branch on {#type} (the stable, machine-readable code) rather than on the
|
|
9
|
+
# HTTP status or the message text.
|
|
10
|
+
class Error < StandardError
|
|
11
|
+
# @return [String] stable, machine-readable error type
|
|
12
|
+
attr_reader :type
|
|
13
|
+
# @return [Integer, nil] HTTP status, or nil when no response was received
|
|
14
|
+
attr_reader :status
|
|
15
|
+
# @return [String, nil] request id from the response, when present
|
|
16
|
+
attr_reader :request_id
|
|
17
|
+
# @return [Object] the parsed response body, or the underlying cause
|
|
18
|
+
attr_reader :raw
|
|
19
|
+
|
|
20
|
+
def initialize(message, type:, status: nil, request_id: nil, raw: nil)
|
|
21
|
+
super(message)
|
|
22
|
+
@type = type
|
|
23
|
+
@status = status
|
|
24
|
+
@request_id = request_id
|
|
25
|
+
@raw = raw
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# 400/422 — the request body or query failed validation.
|
|
30
|
+
class ValidationError < Error
|
|
31
|
+
# @return [Hash{String => Array<String>}] field path -> list of problems
|
|
32
|
+
attr_reader :errors
|
|
33
|
+
|
|
34
|
+
def initialize(message, errors: {}, **kwargs)
|
|
35
|
+
super(message, **kwargs)
|
|
36
|
+
@errors = errors || {}
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# 401 — the API key is missing or invalid.
|
|
41
|
+
class AuthenticationError < Error; end
|
|
42
|
+
|
|
43
|
+
# 403 — the key may not perform this action.
|
|
44
|
+
class PermissionError < Error; end
|
|
45
|
+
|
|
46
|
+
# 404 — no such resource for this team.
|
|
47
|
+
class NotFoundError < Error; end
|
|
48
|
+
|
|
49
|
+
# 409 — conflict, idempotency_concurrent, or webhook_rotation_in_progress.
|
|
50
|
+
class ConflictError < Error; end
|
|
51
|
+
|
|
52
|
+
# 422 idempotency_mismatch — a key was reused with a different body.
|
|
53
|
+
class IdempotencyMismatchError < Error; end
|
|
54
|
+
|
|
55
|
+
# 429 — a rate limit was exceeded.
|
|
56
|
+
class RateLimitError < Error
|
|
57
|
+
# @return [Float, nil] parsed Retry-After, in seconds, when the server sent one
|
|
58
|
+
attr_reader :retry_after
|
|
59
|
+
|
|
60
|
+
def initialize(message, retry_after: nil, **kwargs)
|
|
61
|
+
super(message, **kwargs)
|
|
62
|
+
@retry_after = retry_after
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# 413 — the request body exceeded the 5 MB gateway limit.
|
|
67
|
+
class PayloadTooLargeError < Error; end
|
|
68
|
+
|
|
69
|
+
# A server error (5xx), including internal_error and provisioning_error.
|
|
70
|
+
class APIError < Error; end
|
|
71
|
+
|
|
72
|
+
# No HTTP response was received (network failure, timeout, or abort).
|
|
73
|
+
class APIConnectionError < Error
|
|
74
|
+
def initialize(message, cause: nil)
|
|
75
|
+
super(message, type: "connection_error", raw: cause)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Maps an HTTP response into the right {Error} subclass. Keys primarily on the
|
|
80
|
+
# canonical `error.type`, falling back to the HTTP status.
|
|
81
|
+
#
|
|
82
|
+
# @api private
|
|
83
|
+
module Errors
|
|
84
|
+
REQUEST_ID_HEADERS = ["anypost-request-id", "x-anypost-request-id", "x-request-id"].freeze
|
|
85
|
+
|
|
86
|
+
module_function
|
|
87
|
+
|
|
88
|
+
def from_response(status, body, headers)
|
|
89
|
+
request_id = read_request_id(headers)
|
|
90
|
+
envelope = body.is_a?(Hash) ? body : {}
|
|
91
|
+
error = envelope["error"]
|
|
92
|
+
|
|
93
|
+
errors = {}
|
|
94
|
+
case error
|
|
95
|
+
when Hash
|
|
96
|
+
# Canonical envelope: { error: { type, message, errors? } }.
|
|
97
|
+
type = error["type"] || type_from_status(status)
|
|
98
|
+
message = error["message"] || default_message(status)
|
|
99
|
+
errors = error["errors"] if error["errors"].is_a?(Hash)
|
|
100
|
+
when String
|
|
101
|
+
# Flat envelope: { error: "<code>", message? }.
|
|
102
|
+
type = error
|
|
103
|
+
message = envelope["message"] || error.tr("_", " ")
|
|
104
|
+
else
|
|
105
|
+
type = type_from_status(status)
|
|
106
|
+
message = default_message(status)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
build(status, type, message, errors || {}, request_id, body, headers)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Parse a Retry-After header (delta-seconds or HTTP-date) into seconds.
|
|
113
|
+
def retry_after_seconds(headers)
|
|
114
|
+
value = header(headers, "retry-after")
|
|
115
|
+
return nil if value.nil? || value.empty?
|
|
116
|
+
return [value.to_f, 0.0].max if /\A\s*\d+(\.\d+)?\s*\z/.match?(value)
|
|
117
|
+
|
|
118
|
+
begin
|
|
119
|
+
target = Time.httpdate(value)
|
|
120
|
+
rescue ArgumentError
|
|
121
|
+
return nil
|
|
122
|
+
end
|
|
123
|
+
[target.to_f - Time.now.to_f, 0.0].max
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def build(status, type, message, errors, request_id, raw, headers)
|
|
127
|
+
common = {status: status, request_id: request_id, raw: raw}
|
|
128
|
+
case type
|
|
129
|
+
when "validation_error"
|
|
130
|
+
ValidationError.new(message, errors: errors, type: type, **common)
|
|
131
|
+
when "authentication_error"
|
|
132
|
+
AuthenticationError.new(message, type: type, **common)
|
|
133
|
+
when "permission_error"
|
|
134
|
+
PermissionError.new(message, type: type, **common)
|
|
135
|
+
when "not_found"
|
|
136
|
+
NotFoundError.new(message, type: type, **common)
|
|
137
|
+
when "conflict", "idempotency_concurrent", "webhook_rotation_in_progress"
|
|
138
|
+
ConflictError.new(message, type: type, **common)
|
|
139
|
+
when "idempotency_mismatch"
|
|
140
|
+
IdempotencyMismatchError.new(message, type: type, **common)
|
|
141
|
+
when "rate_limit_exceeded"
|
|
142
|
+
RateLimitError.new(message, retry_after: retry_after_seconds(headers), type: type, **common)
|
|
143
|
+
when "payload_too_large"
|
|
144
|
+
PayloadTooLargeError.new(message, type: type, **common)
|
|
145
|
+
when "provisioning_error", "internal_error"
|
|
146
|
+
APIError.new(message, type: type, **common)
|
|
147
|
+
else
|
|
148
|
+
by_status(status, type, message, errors, headers, common)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def by_status(status, type, message, errors, headers, common)
|
|
153
|
+
case status
|
|
154
|
+
when 401 then AuthenticationError.new(message, type: type, **common)
|
|
155
|
+
when 403 then PermissionError.new(message, type: type, **common)
|
|
156
|
+
when 404 then NotFoundError.new(message, type: type, **common)
|
|
157
|
+
when 409 then ConflictError.new(message, type: type, **common)
|
|
158
|
+
when 413 then PayloadTooLargeError.new(message, type: type, **common)
|
|
159
|
+
when 429 then RateLimitError.new(message, retry_after: retry_after_seconds(headers), type: type, **common)
|
|
160
|
+
when 400, 422 then ValidationError.new(message, errors: errors, type: type, **common)
|
|
161
|
+
else
|
|
162
|
+
(status >= 500) ? APIError.new(message, type: type, **common) : Error.new(message, type: type, **common)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def type_from_status(status)
|
|
167
|
+
case status
|
|
168
|
+
when 400, 422 then "validation_error"
|
|
169
|
+
when 401 then "authentication_error"
|
|
170
|
+
when 403 then "permission_error"
|
|
171
|
+
when 404 then "not_found"
|
|
172
|
+
when 409 then "conflict"
|
|
173
|
+
when 413 then "payload_too_large"
|
|
174
|
+
when 429 then "rate_limit_exceeded"
|
|
175
|
+
else
|
|
176
|
+
(status >= 500) ? "internal_error" : "api_error"
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def default_message(status)
|
|
181
|
+
"Anypost request failed with status #{status}."
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def read_request_id(headers)
|
|
185
|
+
REQUEST_ID_HEADERS.each do |name|
|
|
186
|
+
value = header(headers, name)
|
|
187
|
+
return value if value && !value.empty?
|
|
188
|
+
end
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Case-insensitive single-value header lookup over a Hash or Faraday headers.
|
|
193
|
+
def header(headers, name)
|
|
194
|
+
return nil if headers.nil?
|
|
195
|
+
|
|
196
|
+
name = name.downcase
|
|
197
|
+
headers.each do |key, value|
|
|
198
|
+
return value if key.to_s.downcase == name
|
|
199
|
+
end
|
|
200
|
+
nil
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|