agdirlabs-email 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/CHANGELOG.md +20 -0
- data/LICENSE +21 -0
- data/README.md +284 -0
- data/lib/agdirlabs/email/client.rb +65 -0
- data/lib/agdirlabs/email/delivery_method.rb +143 -0
- data/lib/agdirlabs/email/errors.rb +88 -0
- data/lib/agdirlabs/email/models.rb +172 -0
- data/lib/agdirlabs/email/railtie.rb +17 -0
- data/lib/agdirlabs/email/resources/domains.rb +65 -0
- data/lib/agdirlabs/email/resources/emails.rb +119 -0
- data/lib/agdirlabs/email/resources/webhooks.rb +135 -0
- data/lib/agdirlabs/email/transport.rb +161 -0
- data/lib/agdirlabs/email/version.rb +10 -0
- data/lib/agdirlabs/email/webhook.rb +92 -0
- data/lib/agdirlabs/email/wire.rb +164 -0
- data/lib/agdirlabs/email.rb +30 -0
- data/sig/agdirlabs/email.rbs +318 -0
- metadata +64 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e90d39e665d132cba468bcb1cf9a87de5e19832933825e954f812c3991d16593
|
|
4
|
+
data.tar.gz: '02390d75130537e82db815bd5636258d931e419a168ce4484a4adf9ffdc0b697'
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 87bd23713224b953ab8e78039363967631996e2f7d358be91cf608da1f9f698f0bfee3324d71c6b548877d74bd5cca05077be8994caa2fdfdd910a6a4ad2e479
|
|
7
|
+
data.tar.gz: eda7c94113ad820da4087fc5552986687e22bbb0e8bf732a61a96ca31f05f177968bb98f242bfc80f8b3e9334ecec22f1d9630bbd5bd013828b8cfa905039842
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog — agdirlabs-email (Ruby)
|
|
2
|
+
|
|
3
|
+
All notable changes to this gem are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the gem uses [Semantic Versioning](https://semver.org).
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.1.0] — 2026-10-30
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `AgdirLabs::Email::Client` for the Agdir Email API, with no runtime dependencies (Ruby 3.1+).
|
|
12
|
+
- `emails`: `send`, `send_batch` (strict or permissive validation), `get`, `cancel`.
|
|
13
|
+
- `domains`: `create`, `get`, `list`, `list_all`, `verify`, `delete`.
|
|
14
|
+
- `webhooks`: `create`, `get`, `list`, `list_all`, `update`, `delete`, `rotate_secret`, and `deliveries` (`list`, `list_all`, `get`, `attempts`, `replay`).
|
|
15
|
+
- Frozen response objects with `Time` timestamps; `list_all` returns a lazily paging `Enumerator`.
|
|
16
|
+
- `AgdirLabs::Email::Webhook.verify` for signed webhook requests (Standard Webhooks).
|
|
17
|
+
- `AgdirLabs::Email::Error` with `code`, `status` and `retry_after`, and subclasses per error family.
|
|
18
|
+
- Automatic retries for rate limits, server errors and network failures, with generated idempotency keys for sends.
|
|
19
|
+
- `:agdir_email` ActionMailer delivery method (Railtie) and `AgdirLabs::Email::DeliveryMethod` for the `mail` gem.
|
|
20
|
+
- RBS signatures.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Agdir AS
|
|
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,284 @@
|
|
|
1
|
+
# agdirlabs-email
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/agdirlabs-email)
|
|
4
|
+
[](https://github.com/AgdirAS/agdirlabs-sdks/actions/workflows/ruby.yml)
|
|
5
|
+
|
|
6
|
+
The official Ruby SDK for [Agdir Email](https://agdir.email) — programmatic SMTP for developers, hosted in the EU. Built by [Agdir Labs](https://agdirlabs.com) at [Agdir](https://agdir.no).
|
|
7
|
+
|
|
8
|
+
- No runtime dependencies (Net::HTTP, JSON, OpenSSL from the standard library)
|
|
9
|
+
- Ruby 3.1+, thread-safe client, RBS signatures and YARD docs
|
|
10
|
+
- Rails ActionMailer delivery method (`:agdir_email`), also usable with the `mail` gem
|
|
11
|
+
- Safe automatic retries with idempotency keys
|
|
12
|
+
- Lazy cursor pagination with `Enumerator`
|
|
13
|
+
- Webhook signature verification built in
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
# Gemfile
|
|
19
|
+
gem "agdirlabs-email"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
or `gem install agdirlabs-email`.
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
Create an API key at [app.agdir.email](https://app.agdir.email) and verify a sending domain, then:
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
require "agdirlabs/email"
|
|
30
|
+
|
|
31
|
+
client = AgdirLabs::Email::Client.new(api_key: ENV["AGDIR_EMAIL_API_KEY"])
|
|
32
|
+
|
|
33
|
+
result = client.emails.send(
|
|
34
|
+
from: "Acme <hello@acme.com>",
|
|
35
|
+
to: "user@example.com",
|
|
36
|
+
subject: "Welcome to Acme",
|
|
37
|
+
html: "<p>Thanks for signing up!</p>"
|
|
38
|
+
)
|
|
39
|
+
result.id # => "4ef9a417-…"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`AgdirLabs::Email::Client.new` with no arguments reads `AGDIR_EMAIL_API_KEY` from the environment. Create one client and share it; it is thread-safe.
|
|
43
|
+
|
|
44
|
+
## Emails
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
# Plain text, several recipients, reply-to, tags, custom headers
|
|
48
|
+
client.emails.send(
|
|
49
|
+
from: "Acme <billing@acme.com>",
|
|
50
|
+
to: ["ana@example.com", "ben@example.com"],
|
|
51
|
+
cc: "finance@acme.com",
|
|
52
|
+
reply_to: "support@acme.com",
|
|
53
|
+
subject: "Your invoice",
|
|
54
|
+
text: "Invoice #1042 is ready in your dashboard.",
|
|
55
|
+
tags: { kind: "invoice" }, # or [{ name: "kind", value: "invoice" }]
|
|
56
|
+
headers: { "X-Entity-Ref-ID" => "inv-1042" }
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Schedule for later, then cancel if plans change
|
|
60
|
+
sent = client.emails.send(**email, scheduled_at: Time.now + 3600)
|
|
61
|
+
client.emails.cancel(sent.id)
|
|
62
|
+
|
|
63
|
+
# Delivery status
|
|
64
|
+
email = client.emails.get(sent.id)
|
|
65
|
+
email.status # => "queued" | "scheduled" | "sent" | "delivered" | "bounced" | "complained" | …
|
|
66
|
+
email.created_at # => Time
|
|
67
|
+
|
|
68
|
+
# Up to 100 emails per request (all-or-nothing by default)
|
|
69
|
+
batch = client.emails.send_batch([email1, email2])
|
|
70
|
+
batch.data.map(&:id)
|
|
71
|
+
|
|
72
|
+
# Accept the valid ones, report the rest
|
|
73
|
+
batch = client.emails.send_batch(emails, validation: :permissive)
|
|
74
|
+
batch.errors.each { |e| warn "email #{e.index}: #{e.message}" }
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Responses are frozen, keyword-initialized structs (`SendResult`, `EmailDetails`, `Domain`, …) with `Time` timestamps; they support `to_h`, `==` and pattern matching:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
case client.emails.get(id)
|
|
81
|
+
in { status: "bounced" | "complained" } then suppress!(id)
|
|
82
|
+
in { status: "delivered" } then nil
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Domains
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
domain = client.domains.create(name: "acme.com")
|
|
90
|
+
domain.records.each { |r| puts "#{r.type} #{r.name} #{r.value}" } # add these to your DNS (r.purpose: DKIM/SPF/DMARC)
|
|
91
|
+
|
|
92
|
+
client.domains.verify(domain.id) # check DNS now instead of waiting
|
|
93
|
+
client.domains.get(domain.id).status # "pending" → "verified"
|
|
94
|
+
|
|
95
|
+
page = client.domains.list(limit: 20) # one page: page.data, page.has_more?, page.next_cursor
|
|
96
|
+
client.domains.list(after: page.next_cursor) if page.has_more?
|
|
97
|
+
|
|
98
|
+
client.domains.list_all.each { |d| puts "#{d.name} #{d.status}" } # every domain, fetched lazily page by page
|
|
99
|
+
client.domains.list_all.lazy.select(&:verified?).first(5)
|
|
100
|
+
|
|
101
|
+
client.domains.delete(domain.id)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Webhooks
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
hook = client.webhooks.create(
|
|
108
|
+
endpoint: "https://acme.com/webhooks/email",
|
|
109
|
+
events: %w[email.delivered email.bounced email.complained]
|
|
110
|
+
)
|
|
111
|
+
hook.signing_secret # => "whsec_…" — store it to verify incoming events
|
|
112
|
+
|
|
113
|
+
client.webhooks.update(hook.id, status: :disabled)
|
|
114
|
+
client.webhooks.rotate_secret(hook.id)
|
|
115
|
+
|
|
116
|
+
# What was sent to your endpoint, and each HTTP attempt
|
|
117
|
+
deliveries = client.webhooks.deliveries.list(hook.id)
|
|
118
|
+
attempts = client.webhooks.deliveries.attempts(hook.id, deliveries.first.id)
|
|
119
|
+
client.webhooks.deliveries.replay(hook.id, deliveries.first.id)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Verifying incoming events
|
|
123
|
+
|
|
124
|
+
Every request to your endpoint is signed ([Standard Webhooks](https://www.standardwebhooks.com)). Verify it against the **raw** body before trusting it:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
# Rails
|
|
128
|
+
class EmailWebhooksController < ActionController::API
|
|
129
|
+
def create
|
|
130
|
+
event = AgdirLabs::Email::Webhook.verify(
|
|
131
|
+
payload: request.raw_post,
|
|
132
|
+
headers: request.headers,
|
|
133
|
+
secret: ENV.fetch("AGDIR_EMAIL_WEBHOOK_SECRET")
|
|
134
|
+
)
|
|
135
|
+
mark_bounced(event.data.to, event.data.bounce) if event.type == "email.bounced"
|
|
136
|
+
head :no_content
|
|
137
|
+
rescue AgdirLabs::Email::WebhookVerificationError
|
|
138
|
+
head :bad_request
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Sinatra / Rack
|
|
143
|
+
post "/webhooks/email" do
|
|
144
|
+
event = AgdirLabs::Email::Webhook.verify(payload: request.body.read, headers: request.env, secret: SECRET)
|
|
145
|
+
# …
|
|
146
|
+
204
|
|
147
|
+
end
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`headers:` accepts a Hash (any key case, Rack `HTTP_*` keys work too) or anything with a `[]` reader, like Rails' `request.headers`. Messages older than 5 minutes are rejected (`tolerance:` to change). Failed deliveries are retried for about 18 hours; use `event.data.email_id` to deduplicate.
|
|
151
|
+
|
|
152
|
+
## Rails (ActionMailer)
|
|
153
|
+
|
|
154
|
+
Requiring the gem in a Rails app registers the `:agdir_email` delivery method:
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
# config/environments/production.rb
|
|
158
|
+
config.action_mailer.delivery_method = :agdir_email
|
|
159
|
+
config.action_mailer.agdir_email_settings = {
|
|
160
|
+
api_key: Rails.application.credentials.agdir_email_api_key # default: ENV["AGDIR_EMAIL_API_KEY"]
|
|
161
|
+
# base_url:, max_retries:, timeout: — see Configuration
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Your mailers stay as they are. From, To, Cc, Bcc, Reply-To, Subject, the HTML and text parts, and every `X-…` header are sent. A few headers control the API and are not sent:
|
|
166
|
+
|
|
167
|
+
| Header | Effect |
|
|
168
|
+
| --- | --- |
|
|
169
|
+
| `X-Agdir-Tags: kind=welcome, plan=pro` | tags (echoed back in webhook events) |
|
|
170
|
+
| `X-Agdir-Scheduled-At: 2026-11-01T09:00:00+01:00` | deliver later |
|
|
171
|
+
| `X-Agdir-Idempotency-Key: welcome/42` | idempotency key (one is generated otherwise) |
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
class UserMailer < ApplicationMailer
|
|
175
|
+
def welcome(user)
|
|
176
|
+
headers["X-Agdir-Tags"] = "kind=welcome"
|
|
177
|
+
headers["X-Agdir-Idempotency-Key"] = "welcome/#{user.id}"
|
|
178
|
+
mail(to: user.email, subject: "Welcome!")
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
message = UserMailer.welcome(user).deliver_now
|
|
183
|
+
message["X-Agdir-Email-Id"].value # the Agdir Email id, for client.emails.get
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Attachments are not supported by the API yet: a message with attachments raises `AgdirLabs::Email::UnsupportedMessageError` (link to the file instead). API errors raise `AgdirLabs::Email::Error` subclasses, so `raise_delivery_errors` and `deliver_later` retries work as usual.
|
|
187
|
+
|
|
188
|
+
Without Rails, use it with the [`mail`](https://github.com/mikel/mail) gem:
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
Mail.defaults { delivery_method AgdirLabs::Email::DeliveryMethod, api_key: ENV["AGDIR_EMAIL_API_KEY"] }
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Errors
|
|
195
|
+
|
|
196
|
+
Every failed call raises an `AgdirLabs::Email::Error`:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
begin
|
|
200
|
+
client.emails.send(**email)
|
|
201
|
+
rescue AgdirLabs::Email::QuotaExceededError => e
|
|
202
|
+
# daily_quota_exceeded / monthly_quota_exceeded — never retried
|
|
203
|
+
rescue AgdirLabs::Email::Error => e
|
|
204
|
+
e.code # "validation_error" | "rate_limit_exceeded" | "network_error" | "timeout" | …
|
|
205
|
+
e.status # HTTP status, 0 if no response
|
|
206
|
+
e.message # human-readable explanation
|
|
207
|
+
e.retry_after # seconds from retry-after, if any
|
|
208
|
+
end
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
| Class | Codes |
|
|
212
|
+
| --- | --- |
|
|
213
|
+
| `AuthenticationError` | `missing_api_key`, `invalid_api_key`, `restricted_api_key` |
|
|
214
|
+
| `ValidationError` | `validation_error` (422; 403 for an unverified domain, paused team or domain limit) |
|
|
215
|
+
| `NotFoundError` | `not_found` |
|
|
216
|
+
| `IdempotencyError` | `invalid_idempotent_request`, `concurrent_idempotent_requests` |
|
|
217
|
+
| `RateLimitError` | `rate_limit_exceeded` (after retries) |
|
|
218
|
+
| `QuotaExceededError` | `daily_quota_exceeded`, `monthly_quota_exceeded` |
|
|
219
|
+
| `ServerError` | 5xx |
|
|
220
|
+
| `ConnectionError` / `TimeoutError` | `network_error` / `timeout` (no response) |
|
|
221
|
+
|
|
222
|
+
A missing API key raises `AgdirLabs::Email::ConfigurationError` when the client is created.
|
|
223
|
+
|
|
224
|
+
## Retries and idempotency
|
|
225
|
+
|
|
226
|
+
Rate limits (`429 rate_limit_exceeded`), server errors (408/500/502/503/504) and network failures are retried up to `max_retries` times (default 2) with exponential backoff, honouring `retry-after`. Sends are only retried with an idempotency key, so a retry can never deliver an email twice: the SDK generates one per call, or you can pass your own to make sends safe across process restarts too:
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
client.emails.send(**email, idempotency_key: "welcome/#{user.id}")
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Quota errors are not retried.
|
|
233
|
+
|
|
234
|
+
## Configuration
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
AgdirLabs::Email::Client.new(
|
|
238
|
+
api_key: "re_…", # default: ENV["AGDIR_EMAIL_API_KEY"]
|
|
239
|
+
base_url: "https://api.agdir.email", # default: ENV["AGDIR_EMAIL_BASE_URL"], else this
|
|
240
|
+
max_retries: 2,
|
|
241
|
+
timeout: 30, # seconds, per attempt
|
|
242
|
+
http_client: nil # custom transport (see below)
|
|
243
|
+
)
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Requests use Net::HTTP and honour the standard `https_proxy` / `no_proxy` environment variables. To use another HTTP library, pass any object with `call(method:, url:, headers:, body:, timeout:)` returning an `AgdirLabs::Email::HttpResponse` (`status`, `headers`, `body`). To cancel a long-running call, run it in a thread or use `timeout:`.
|
|
247
|
+
|
|
248
|
+
## Development
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
bundle install
|
|
252
|
+
bundle exec rake # tests, RuboCop, RBS validation
|
|
253
|
+
bundle exec rake test
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Live tests run against a real API only when configured (never against production):
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
AGDIR_EMAIL_LIVE_BASE_URL=http://localhost:8080/api \
|
|
260
|
+
AGDIR_EMAIL_LIVE_API_KEY=re_… \
|
|
261
|
+
AGDIR_EMAIL_LIVE_FROM=hi@your-verified-domain.example \
|
|
262
|
+
bundle exec ruby -Ilib -Itest test/live_test.rb
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
## Releasing
|
|
266
|
+
|
|
267
|
+
Releases are published from GitHub Actions with [RubyGems Trusted Publishing](https://guides.rubygems.org/trusted-publishing/) (OIDC): no API key is stored anywhere.
|
|
268
|
+
|
|
269
|
+
1. Bump `AgdirLabs::Email::VERSION` in `lib/agdirlabs/email/version.rb` and add a `CHANGELOG.md` entry; merge to `main`.
|
|
270
|
+
2. Push a tag `ruby/v<version>`, e.g. `git tag ruby/v0.1.0 && git push origin ruby/v0.1.0`.
|
|
271
|
+
3. `.github/workflows/ruby-release.yml` checks the tag against `VERSION`, runs the tests and publishes with `rubygems/release-gem` (`rake release` from `ruby/`; the Rakefile disables its own `v*` git tag push).
|
|
272
|
+
|
|
273
|
+
One-time setup on rubygems.org (by a maintainer of the AgdirAS organisation account):
|
|
274
|
+
|
|
275
|
+
- **Before the first release** (the gem does not exist yet): rubygems.org → your profile → *Trusted publishers* → *Create pending trusted publisher*: gem name `agdirlabs-email`, repository owner `AgdirAS`, repository name `agdirlabs-sdks`, workflow filename `ruby-release.yml`, environment empty.
|
|
276
|
+
- **After the first release**: the pending publisher becomes the gem's trusted publisher (gem page → *Trusted publishers*). Add the other maintainers as gem owners.
|
|
277
|
+
|
|
278
|
+
## Contributing
|
|
279
|
+
|
|
280
|
+
Issues and pull requests are welcome — see [CONTRIBUTING.md](../CONTRIBUTING.md). Changes are listed in [CHANGELOG.md](CHANGELOG.md).
|
|
281
|
+
|
|
282
|
+
## License
|
|
283
|
+
|
|
284
|
+
[MIT](LICENSE) © [Agdir AS](https://agdir.no)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgdirLabs
|
|
4
|
+
module Email
|
|
5
|
+
# Default API base URL.
|
|
6
|
+
DEFAULT_BASE_URL = "https://api.agdir.email"
|
|
7
|
+
|
|
8
|
+
# Agdir Email API client. Thread-safe: share one instance.
|
|
9
|
+
#
|
|
10
|
+
# client = AgdirLabs::Email::Client.new(api_key: "re_…")
|
|
11
|
+
# client.emails.send(from: "Acme <hi@acme.com>", to: "ana@example.com", subject: "Hi", html: "<p>Hi</p>")
|
|
12
|
+
class Client
|
|
13
|
+
# @return [Resources::Emails]
|
|
14
|
+
attr_reader :emails
|
|
15
|
+
# @return [Resources::Domains]
|
|
16
|
+
attr_reader :domains
|
|
17
|
+
# @return [Resources::Webhooks]
|
|
18
|
+
attr_reader :webhooks
|
|
19
|
+
# @return [String]
|
|
20
|
+
attr_reader :base_url
|
|
21
|
+
# @return [Integer]
|
|
22
|
+
attr_reader :max_retries
|
|
23
|
+
# @return [Numeric]
|
|
24
|
+
attr_reader :timeout
|
|
25
|
+
|
|
26
|
+
# @param api_key [String, nil] defaults to `ENV["AGDIR_EMAIL_API_KEY"]`
|
|
27
|
+
# @param base_url [String, nil] defaults to `ENV["AGDIR_EMAIL_BASE_URL"]`, else https://api.agdir.email
|
|
28
|
+
# @param max_retries [Integer] retries for rate limits, server errors and network failures; 0 disables
|
|
29
|
+
# @param timeout [Numeric] seconds per attempt
|
|
30
|
+
# @param http_client [#call, nil] custom HTTP client:
|
|
31
|
+
# `call(method:, url:, headers:, body:, timeout:)` returning an {HttpResponse}. Defaults to {NetHttpClient}.
|
|
32
|
+
# @raise [ConfigurationError] when no API key is available
|
|
33
|
+
def initialize(api_key: nil, base_url: nil, max_retries: 2, timeout: 30, http_client: nil)
|
|
34
|
+
api_key = present(api_key) || present(ENV.fetch("AGDIR_EMAIL_API_KEY", nil))
|
|
35
|
+
unless api_key
|
|
36
|
+
raise ConfigurationError,
|
|
37
|
+
"Missing API key: pass api_key: or set AGDIR_EMAIL_API_KEY. Create one at https://app.agdir.email."
|
|
38
|
+
end
|
|
39
|
+
unless max_retries.is_a?(Integer) && !max_retries.negative?
|
|
40
|
+
raise ConfigurationError, "max_retries must be a non-negative Integer"
|
|
41
|
+
end
|
|
42
|
+
raise ConfigurationError, "timeout must be a positive number" unless timeout.is_a?(Numeric) && timeout.positive?
|
|
43
|
+
|
|
44
|
+
@base_url = (present(base_url) || present(ENV.fetch("AGDIR_EMAIL_BASE_URL", nil)) || DEFAULT_BASE_URL).freeze
|
|
45
|
+
@max_retries = max_retries
|
|
46
|
+
@timeout = timeout
|
|
47
|
+
transport = Transport.new(api_key: api_key, base_url: @base_url, max_retries: max_retries, timeout: timeout,
|
|
48
|
+
http_client: http_client || NetHttpClient.new)
|
|
49
|
+
@emails = Resources::Emails.new(transport)
|
|
50
|
+
@domains = Resources::Domains.new(transport)
|
|
51
|
+
@webhooks = Resources::Webhooks.new(transport)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Keeps the API key out of logs and consoles.
|
|
55
|
+
def inspect = "#<#{self.class.name} base_url=#{@base_url.inspect}>"
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# Empty values (e.g. `AGDIR_EMAIL_BASE_URL=` in a .env file) count as unset.
|
|
60
|
+
def present(value)
|
|
61
|
+
value.nil? || value.to_s.empty? ? nil : value.to_s
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgdirLabs
|
|
4
|
+
module Email
|
|
5
|
+
# A delivery method for the `mail` gem and Rails ActionMailer that sends through the Agdir Email API.
|
|
6
|
+
#
|
|
7
|
+
# Rails (registered by the Railtie):
|
|
8
|
+
#
|
|
9
|
+
# config.action_mailer.delivery_method = :agdir_email
|
|
10
|
+
# config.action_mailer.agdir_email_settings = { api_key: ENV["AGDIR_EMAIL_API_KEY"] }
|
|
11
|
+
#
|
|
12
|
+
# Plain `mail` gem:
|
|
13
|
+
#
|
|
14
|
+
# Mail.defaults { delivery_method AgdirLabs::Email::DeliveryMethod, api_key: "re_…" }
|
|
15
|
+
#
|
|
16
|
+
# Mapped: From, To, Cc, Bcc, Reply-To, Subject, the HTML and text parts, and every `X-…` header.
|
|
17
|
+
# Special headers (removed before sending):
|
|
18
|
+
#
|
|
19
|
+
# - `X-Agdir-Tags: kind=welcome, plan=pro` → `tags`
|
|
20
|
+
# - `X-Agdir-Scheduled-At: 2026-11-01T09:00:00+01:00` → `scheduled_at`
|
|
21
|
+
# - `X-Agdir-Idempotency-Key: welcome/42` → idempotency key
|
|
22
|
+
#
|
|
23
|
+
# After sending, the email id is stored in the message's `X-Agdir-Email-Id` header and returned
|
|
24
|
+
# (Mail returns it from `deliver!` when `return_response: true` is set).
|
|
25
|
+
class DeliveryMethod
|
|
26
|
+
TAGS_HEADER = "X-Agdir-Tags"
|
|
27
|
+
SCHEDULED_AT_HEADER = "X-Agdir-Scheduled-At"
|
|
28
|
+
IDEMPOTENCY_KEY_HEADER = "X-Agdir-Idempotency-Key"
|
|
29
|
+
EMAIL_ID_HEADER = "X-Agdir-Email-Id"
|
|
30
|
+
CONTROL_HEADERS = [TAGS_HEADER, SCHEDULED_AT_HEADER, IDEMPOTENCY_KEY_HEADER, EMAIL_ID_HEADER]
|
|
31
|
+
.map(&:downcase).freeze
|
|
32
|
+
CLIENT_OPTIONS = %i[api_key base_url max_retries timeout http_client].freeze
|
|
33
|
+
|
|
34
|
+
# @return [Hash] the settings this delivery method was created with
|
|
35
|
+
attr_accessor :settings
|
|
36
|
+
|
|
37
|
+
# @param settings [Hash] `api_key:`, `base_url:`, `max_retries:`, `timeout:`, `http_client:`
|
|
38
|
+
# (see {Client#initialize}) or a ready `client:`. Missing values fall back to the environment.
|
|
39
|
+
def initialize(settings = {})
|
|
40
|
+
@settings = (settings || {}).to_h.transform_keys(&:to_sym)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Sends a Mail::Message.
|
|
44
|
+
# @param mail [Mail::Message]
|
|
45
|
+
# @return [SendResult]
|
|
46
|
+
# @raise [UnsupportedMessageError] for messages with attachments
|
|
47
|
+
# @raise [Error] when the API rejects the email
|
|
48
|
+
def deliver!(mail)
|
|
49
|
+
params, key = self.class.to_params(mail)
|
|
50
|
+
result = client.emails.send(idempotency_key: key, **params)
|
|
51
|
+
mail[EMAIL_ID_HEADER] = result.id
|
|
52
|
+
result
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The {Client} used to send (built lazily from {#settings}).
|
|
56
|
+
# @return [Client]
|
|
57
|
+
def client
|
|
58
|
+
@client ||= settings[:client] || Client.new(**settings.slice(*CLIENT_OPTIONS))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
class << self
|
|
62
|
+
# Maps a Mail::Message to keyword arguments for {Resources::Emails#send}.
|
|
63
|
+
# @return [Array(Hash, String)] the email params and the idempotency key (or nil)
|
|
64
|
+
def to_params(mail)
|
|
65
|
+
if mail.has_attachments?
|
|
66
|
+
raise UnsupportedMessageError,
|
|
67
|
+
"Attachments are not supported by the Agdir Email API yet; remove them or link to the files instead"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
params = {
|
|
71
|
+
from: addresses(mail, :from).first,
|
|
72
|
+
to: addresses(mail, :to),
|
|
73
|
+
cc: addresses(mail, :cc),
|
|
74
|
+
bcc: addresses(mail, :bcc),
|
|
75
|
+
reply_to: addresses(mail, :reply_to),
|
|
76
|
+
subject: mail.subject.to_s,
|
|
77
|
+
html: body(mail, "text/html"),
|
|
78
|
+
text: body(mail, "text/plain"),
|
|
79
|
+
headers: custom_headers(mail),
|
|
80
|
+
tags: tags(mail),
|
|
81
|
+
scheduled_at: header(mail, SCHEDULED_AT_HEADER)
|
|
82
|
+
}
|
|
83
|
+
optional = %i[cc bcc reply_to headers tags]
|
|
84
|
+
params.reject! { |k, v| v.nil? || (optional.include?(k) && v.empty?) }
|
|
85
|
+
[params, header(mail, IDEMPOTENCY_KEY_HEADER)]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def addresses(mail, field)
|
|
91
|
+
value = mail[field]
|
|
92
|
+
return [] if value.nil?
|
|
93
|
+
|
|
94
|
+
formatted = value.respond_to?(:formatted) ? value.formatted : Array(value.to_s)
|
|
95
|
+
formatted.map { |a| utf8(a) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def body(mail, mime_type)
|
|
99
|
+
part = if mail.multipart?
|
|
100
|
+
mime_type == "text/html" ? mail.html_part : mail.text_part
|
|
101
|
+
elsif (mail.mime_type || "text/plain") == mime_type
|
|
102
|
+
mail
|
|
103
|
+
end
|
|
104
|
+
return nil if part.nil?
|
|
105
|
+
|
|
106
|
+
text = utf8(part.decoded)
|
|
107
|
+
text.empty? ? nil : text
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def custom_headers(mail)
|
|
111
|
+
mail.header.fields.each_with_object({}) do |field, out|
|
|
112
|
+
name = field.name.to_s
|
|
113
|
+
next unless name.downcase.start_with?("x-") && !CONTROL_HEADERS.include?(name.downcase)
|
|
114
|
+
|
|
115
|
+
out[name] = out.key?(name) ? "#{out[name]}, #{utf8(field.value)}" : utf8(field.value)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def tags(mail)
|
|
120
|
+
values = mail.header.fields.select { |f| f.name.to_s.casecmp?(TAGS_HEADER) }.map { |f| f.value.to_s }
|
|
121
|
+
values.flat_map { |v| v.split(",") }.filter_map do |pair|
|
|
122
|
+
name, value = pair.split("=", 2).map(&:strip)
|
|
123
|
+
next if name.nil? || name.empty?
|
|
124
|
+
|
|
125
|
+
{ name: name, value: value.to_s }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def header(mail, name)
|
|
130
|
+
field = mail.header.fields.find { |f| f.name.to_s.casecmp?(name) }
|
|
131
|
+
value = field&.value.to_s.strip
|
|
132
|
+
value.nil? || value.empty? ? nil : value
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def utf8(value)
|
|
136
|
+
string = value.to_s
|
|
137
|
+
string = string.dup.force_encoding(Encoding::UTF_8) if string.encoding == Encoding::BINARY
|
|
138
|
+
string.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AgdirLabs
|
|
4
|
+
module Email
|
|
5
|
+
# Raised by every client method when a request fails.
|
|
6
|
+
#
|
|
7
|
+
# Rescue this class for everything, or one of its subclasses for a family of errors.
|
|
8
|
+
# {#code} is the API's machine-readable error name (e.g. `"validation_error"`), or
|
|
9
|
+
# `"network_error"` / `"timeout"` when no response was received.
|
|
10
|
+
class Error < StandardError
|
|
11
|
+
# @return [String] machine-readable code, e.g. `"validation_error"` or `"rate_limit_exceeded"`
|
|
12
|
+
attr_reader :code
|
|
13
|
+
# @return [Integer] HTTP status, or 0 when no response was received
|
|
14
|
+
attr_reader :status
|
|
15
|
+
# @return [Float, nil] seconds the API asked us to wait (from `retry-after`), if any
|
|
16
|
+
attr_reader :retry_after
|
|
17
|
+
|
|
18
|
+
# @param message [String]
|
|
19
|
+
# @param code [String]
|
|
20
|
+
# @param status [Integer]
|
|
21
|
+
# @param retry_after [Float, nil]
|
|
22
|
+
def initialize(message = nil, code: "unknown_error", status: 0, retry_after: nil)
|
|
23
|
+
super(message)
|
|
24
|
+
@code = code
|
|
25
|
+
@status = status
|
|
26
|
+
@retry_after = retry_after
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Builds the most specific subclass for an API error code / HTTP status.
|
|
30
|
+
# @return [Error]
|
|
31
|
+
def self.build(message, code:, status:, retry_after: nil)
|
|
32
|
+
klass = CODES.fetch(code) do
|
|
33
|
+
if status >= 500 then ServerError
|
|
34
|
+
elsif status == 409 then IdempotencyError
|
|
35
|
+
else Error
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
klass.new(message, code: code, status: status, retry_after: retry_after)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# `missing_api_key`, `invalid_api_key`, `restricted_api_key`.
|
|
43
|
+
class AuthenticationError < Error; end
|
|
44
|
+
# `validation_error` (422; 403 for an unverified domain, paused team or domain limit).
|
|
45
|
+
class ValidationError < Error; end
|
|
46
|
+
# `not_found`.
|
|
47
|
+
class NotFoundError < Error; end
|
|
48
|
+
# `invalid_idempotent_request`, `concurrent_idempotent_requests`.
|
|
49
|
+
class IdempotencyError < Error; end
|
|
50
|
+
# `rate_limit_exceeded`: retried automatically; raised once retries are exhausted.
|
|
51
|
+
class RateLimitError < Error; end
|
|
52
|
+
# `daily_quota_exceeded`, `monthly_quota_exceeded`: never retried.
|
|
53
|
+
class QuotaExceededError < Error; end
|
|
54
|
+
# 5xx responses.
|
|
55
|
+
class ServerError < Error; end
|
|
56
|
+
# The API could not be reached (`network_error`).
|
|
57
|
+
class ConnectionError < Error; end
|
|
58
|
+
# An attempt took longer than the configured timeout (`timeout`).
|
|
59
|
+
class TimeoutError < ConnectionError; end
|
|
60
|
+
|
|
61
|
+
class Error
|
|
62
|
+
CODES = {
|
|
63
|
+
"missing_api_key" => AuthenticationError,
|
|
64
|
+
"invalid_api_key" => AuthenticationError,
|
|
65
|
+
"restricted_api_key" => AuthenticationError,
|
|
66
|
+
"validation_error" => ValidationError,
|
|
67
|
+
"not_found" => NotFoundError,
|
|
68
|
+
"invalid_idempotent_request" => IdempotencyError,
|
|
69
|
+
"concurrent_idempotent_requests" => IdempotencyError,
|
|
70
|
+
"rate_limit_exceeded" => RateLimitError,
|
|
71
|
+
"daily_quota_exceeded" => QuotaExceededError,
|
|
72
|
+
"monthly_quota_exceeded" => QuotaExceededError,
|
|
73
|
+
"internal_server_error" => ServerError,
|
|
74
|
+
"network_error" => ConnectionError,
|
|
75
|
+
"timeout" => TimeoutError
|
|
76
|
+
}.freeze
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Raised when the client is misconfigured, e.g. no API key is available.
|
|
80
|
+
class ConfigurationError < ArgumentError; end
|
|
81
|
+
|
|
82
|
+
# Raised by {Webhook.verify} when a payload's signature or timestamp is invalid.
|
|
83
|
+
class WebhookVerificationError < StandardError; end
|
|
84
|
+
|
|
85
|
+
# Raised by {DeliveryMethod} for a message the API cannot send (e.g. one with attachments).
|
|
86
|
+
class UnsupportedMessageError < ArgumentError; end
|
|
87
|
+
end
|
|
88
|
+
end
|