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 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
+ [![Gem](https://img.shields.io/gem/v/agdirlabs-email.svg)](https://rubygems.org/gems/agdirlabs-email)
4
+ [![CI](https://github.com/AgdirAS/agdirlabs-sdks/actions/workflows/ruby.yml/badge.svg)](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