posthaste-rails 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: a5665a5f083a24bc3da1115e38819ab66e4ee5c956910ec91af8b9d4ae8724ba
4
+ data.tar.gz: f728072cd752a9cd152581235f87d8b158fa9f8067ff8cb05e573d8aeaa261a1
5
+ SHA512:
6
+ metadata.gz: 866027847539374855f77ebf62aa7411b1ea2ceb22015743e646f78d7e5258024d2987bb619b3f249d40634b568e060383eaf36e40f2382001c8b3636a5ddd82
7
+ data.tar.gz: c6de9eadc6a60c2af4c365f315ff4179e3bfd518a069f5cfc6ef0fd62d99ead7989f86f18260efeba66ff76b007b6120d2c6473b49c24a0bbea656eba70eadf8
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Posthaste
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,323 @@
1
+ # posthaste-rails
2
+
3
+ An ActionMailer delivery method for the [Posthaste](https://posthastemail.dev)
4
+ transactional email API.
5
+
6
+ If your application already sends mail with ActionMailer, the whole migration is
7
+ two lines of configuration. Every `mail()` call, every view, every
8
+ `deliver_later`, every `assert_emails` in your test suite stays exactly as it is.
9
+
10
+ ```ruby
11
+ # Gemfile
12
+ gem 'posthaste-rails'
13
+ ```
14
+
15
+ ```ruby
16
+ # config/environments/production.rb
17
+ config.action_mailer.delivery_method = :posthaste
18
+ config.action_mailer.posthaste_settings = { api_key: ENV['POSTHASTE_API_KEY'] }
19
+ ```
20
+
21
+ That is the change. Nothing else in the application moves.
22
+
23
+ Requires Ruby 3.1+ and ActionMailer 6.1+. Its only dependency is ActionMailer
24
+ itself — the HTTP is `Net::HTTP` and the JSON is the stdlib, so it adds nothing
25
+ to your lockfile that Rails was not already carrying.
26
+
27
+ ## Why this and not SMTP
28
+
29
+ Posthaste runs an [SMTP relay](https://posthastemail.dev/docs/smtp), and
30
+ ActionMailer can point at it today with no gem at all. That is a perfectly good
31
+ answer, so here is the honest comparison rather than a pitch.
32
+
33
+ | Reach for | When |
34
+ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
35
+ | The SMTP relay | You are on an ordinary server or container, port 587 is open outbound, and you send ordinary mail — including things this adapter cannot express, like calendar invitations. |
36
+ | This gem | Outbound SMTP is blocked or throttled where you run. Most serverless and container platforms either block 25/587 outright or make a long-lived connection expensive; an HTTPS request has neither problem. |
37
+ | This gem | You want the fields SMTP has nowhere to put: streams, tags, metadata, stored templates, an idempotency key, scheduled sending. |
38
+ | This gem | You want to branch on _why_ a message was refused. SMTP gives you a three-digit code and a sentence. |
39
+
40
+ ## Settings
41
+
42
+ Everything is optional except the key, which falls back to `POSTHASTE_API_KEY`.
43
+
44
+ ```ruby
45
+ config.action_mailer.posthaste_settings = {
46
+ api_key: ENV['POSTHASTE_API_KEY'],
47
+ base_url: 'https://api.posthastemail.dev', # self-hosting? point it here
48
+ stream: 'transactional', # default for every message
49
+ tags: %w[rails],
50
+ metadata: { app: 'acme' },
51
+ open_timeout: 10,
52
+ read_timeout: 30,
53
+ max_retries: 2,
54
+ max_retry_delay: 60.0,
55
+ on_warning: ->(w) { Rails.logger.warn("[posthaste] #{w}") },
56
+ logger: Rails.logger,
57
+ }
58
+ ```
59
+
60
+ An unrecognised setting raises `Posthaste::ConfigurationError` naming it, rather
61
+ than being ignored — a typo like `api_kye:` is otherwise a silent fallback to
62
+ the environment variable, or to no key at all.
63
+
64
+ `base_url` is the whole story for a self-hosted install: point it at your own
65
+ API host and everything else works unchanged.
66
+
67
+ ## Reading the result
68
+
69
+ `deliver_now` hands back the `Mail::Message`, as it always has. The API's answer
70
+ is attached to it:
71
+
72
+ ```ruby
73
+ mail = InvoiceMailer.issued(invoice).deliver_now
74
+
75
+ mail.posthaste_result.id # 'msg_AZLm3kQ8T2Sf9pXbNc7HrQ'
76
+ mail.posthaste_result.status # 'queued' | 'scheduled' | 'duplicate'
77
+ mail.posthaste_result.duplicate? # an idempotency replay: no new message exists
78
+ mail.posthaste_result.suppressed # recipients skipped, and why
79
+ mail.posthaste_result.warnings # the platform's pre-send lint findings
80
+ mail.posthaste_result.mapping_warnings # what this gem had to change
81
+ ```
82
+
83
+ `#posthaste_result` is the only method this gem adds to a class it does not own.
84
+ It returns `nil` for a message delivered any other way.
85
+
86
+ ## The fields ActionMailer has no word for
87
+
88
+ Streams, tags, metadata, idempotency keys, stored templates and scheduled sends
89
+ are set with `X-Posthaste-…` headers. `mail()` already passes any unrecognised
90
+ key straight through as a header, so this needs no new API and works in every
91
+ Rails version:
92
+
93
+ ```ruby
94
+ def issued(invoice)
95
+ mail(
96
+ to: invoice.email,
97
+ subject: "Invoice #{invoice.number}",
98
+ 'X-Posthaste-Stream' => 'transactional',
99
+ 'X-Posthaste-Tags' => 'invoice,billing',
100
+ 'X-Posthaste-Metadata' => { invoice_id: invoice.id }.to_json,
101
+ 'X-Posthaste-Idempotency-Key' => "invoice-#{invoice.id}",
102
+ )
103
+ end
104
+ ```
105
+
106
+ | Header | Becomes | Notes |
107
+ | ------------------------------ | ----------------- | --------------------------------------------- |
108
+ | `X-Posthaste-Stream` | `stream` | Defaults to the settings-level `stream`. |
109
+ | `X-Posthaste-Tags` | `tags` | Comma-separated. |
110
+ | `X-Posthaste-Metadata` | `metadata` | A JSON object. Values are coerced to strings. |
111
+ | `X-Posthaste-Idempotency-Key` | `idempotencyKey` | See _Retries_ below. |
112
+ | `X-Posthaste-Template` | `template` | The stored template supplies the body. |
113
+ | `X-Posthaste-Template-Version` | `templateVersion` | An integer. |
114
+ | `X-Posthaste-Variables` | `variables` | A JSON object. |
115
+ | `X-Posthaste-Scheduled-At` | `scheduledAt` | RFC 3339, e.g. `2026-09-01T09:00:00Z`. |
116
+
117
+ They are **consumed**: none of them reach the wire as ordinary headers. A
118
+ mistyped one (`X-Posthaste-Steam`) raises rather than being forwarded as an
119
+ inert custom header, because the alternative is a send that quietly ignores the
120
+ stream it was told to use.
121
+
122
+ ## What maps, what changes, what is refused
123
+
124
+ ActionMailer hands the delivery method a fully-composed `Mail::Message` — a rich
125
+ RFC 5322 document. The send API takes _fields_. The governing rule for the gap
126
+ between them is that **nothing is dropped in silence**: a team that migrates and
127
+ does not notice their Bcc stopped arriving is the outcome this design exists to
128
+ prevent.
129
+
130
+ ### Mapped, unchanged
131
+
132
+ | ActionMailer | Becomes |
133
+ | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
134
+ | `from` | `from`, display name kept |
135
+ | `to`, `cc`, `bcc` | `to`, `cc`, `bcc` — each recipient is its own message and counts as one send |
136
+ | `reply_to` | `replyTo`, display names kept, several addresses kept |
137
+ | `subject` | `subject` |
138
+ | text and HTML templates | `text` and `html`, found wherever the message put them — including `multipart/mixed[ multipart/alternative[…], attachment ]`, which is what Rails builds for two templates plus a file |
139
+ | a single-template, non-multipart message | `text`, or `html` if its Content-Type says so |
140
+ | `attachments[…]` | `attachments`, base64-encoded, with the declared content type |
141
+ | `attachments.inline[…]` | `attachments` with `disposition: inline` and the `cid` kept, so `<img src="cid:…">` still resolves |
142
+ | `List-Unsubscribe` | `listUnsubscribe` |
143
+ | any other header | `headers` |
144
+
145
+ ### Mapped, with a warning
146
+
147
+ Warnings reach `on_warning` and `mail.posthaste_result.mapping_warnings`, so you
148
+ find out on the first send rather than in a support ticket.
149
+
150
+ | What | What happens | Why |
151
+ | ------------------------------------------------ | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
152
+ | Display names on `to` / `cc` / `bcc` | The name is dropped; the address is unchanged. | The API addresses recipients by bare address. This is the one place the mapping loses something a recipient could have seen — and it is only how their own address is labelled in their own client, which most clients override from the address book anyway. Refusing would break the migration for nearly every application that has ever set a recipient name. |
153
+ | A body whose declared charset does not decode | Unreadable bytes are replaced. | The message still goes; the damage is confined to bytes that were already unreadable. |
154
+ | `X-Posthaste-Template` alongside a rendered view | The template supplies the content and the view is not sent. | Sending both would make the API pick one, and whichever it picked would be a surprise. |
155
+
156
+ ### Composed by the platform, and dropped without a warning
157
+
158
+ `Date`, `Message-ID`, `MIME-Version`, `Content-Type`,
159
+ `Content-Transfer-Encoding`, `Content-Disposition`, `List-Unsubscribe-Post`.
160
+
161
+ Mail stamps every one of these onto every message before a delivery method ever
162
+ sees it, so a warning here would fire on every send an application ever made —
163
+ which is exactly how the warnings that matter get ignored. The platform composes
164
+ the MIME and signs the identity headers itself; the bytes the recipient's client
165
+ decodes are the same either way.
166
+
167
+ ### Refused, before the request is made
168
+
169
+ Each of these raises a `Posthaste::MappingError` naming the field, rather than
170
+ sending a message that is quietly missing something.
171
+
172
+ | What | Why |
173
+ | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
174
+ | `Sender`, `Return-Path`, `DKIM-Signature`, `Received`, `Authentication-Results`, `Feedback-ID`, `ARC-*` | The platform writes and DKIM-signs these. A second copy either duplicates a signed header — some receivers reject two `From`s — or drifts from the signed value and fails DMARC. `Feedback-ID` in particular is the key Google aggregates complaint rates by. |
175
+ | The same custom header set twice | The API carries one value per header name, and keeping one of the two would be a silent choice about which the recipient sees. |
176
+ | A `from` that parses to more than one address | Almost always an unquoted comma: `from: "Acme, Inc. <billing@acme.test>"` is _two_ addresses to any RFC 5322 parser. Quote it: `from: %q("Acme, Inc." <billing@acme.test>)`. |
177
+ | A part that is neither body nor named attachment — a `text/calendar` invitation, an attachment with no filename | Mail identifies an attachment _by its filename_, so these are not in `mail.attachments` at all and would vanish between here and the wire. Give it a filename, or use the SMTP relay, which accepts a complete MIME message. |
178
+ | A message with no `to` | There is nobody to send it to. |
179
+
180
+ ## Refusals are typed
181
+
182
+ A suppressed recipient, an unverified domain and an exhausted quota are three
183
+ different problems with three different fixes. One generic error is what makes
184
+ an integration miserable to debug.
185
+
186
+ ```ruby
187
+ begin
188
+ InvoiceMailer.issued(invoice).deliver_now
189
+
190
+ rescue Posthaste::SuppressedError => e
191
+ # Never work around this. Sending to a suppressed address is how a sending IP
192
+ # gets blocklisted, and the block lands on everyone sharing it.
193
+ stop_mailing(e.suppression.address, e.suppression.reason) # 'complaint'
194
+
195
+ rescue Posthaste::DomainNotVerifiedError
196
+ # Publish the DKIM record and verify the domain. There is no flag for this.
197
+ alert_ops!
198
+
199
+ rescue Posthaste::QuotaExhausted => e
200
+ # Hours or days, not seconds. Queue it or alert — do NOT sleep.
201
+ Retry.tomorrow(invoice, after: e.retry_after_seconds)
202
+
203
+ rescue Posthaste::RateLimited => e
204
+ # Transient, and it clears on its own.
205
+ Retry.in(e.retry_after_seconds || 60, invoice)
206
+
207
+ rescue Posthaste::ContentBlockedError => e
208
+ # The whole lint report, warnings included, so one fix pass covers everything.
209
+ Rails.logger.error([e.check, e.findings].inspect)
210
+ end
211
+ ```
212
+
213
+ ```
214
+ Posthaste::Error
215
+ ├── Posthaste::ConfigurationError no key, or an unknown setting
216
+ ├── Posthaste::MappingError the message cannot be expressed
217
+ │ └── Posthaste::UnsupportedHeaderError
218
+ ├── Posthaste::ConnectionError never reached the server (status 0)
219
+ │ └── Posthaste::TimeoutError
220
+ └── Posthaste::APIStatusError
221
+ ├── Posthaste::AuthenticationError 401
222
+ ├── Posthaste::PermissionDeniedError 403 — a real key without emails:send
223
+ ├── Posthaste::InvalidRequestError 400
224
+ ├── Posthaste::NotFoundError 404
225
+ ├── Posthaste::ConflictError 409
226
+ ├── Posthaste::UnprocessableError 422 — permanent, never retried
227
+ │ ├── Posthaste::SuppressedError
228
+ │ ├── Posthaste::DomainNotVerifiedError
229
+ │ ├── Posthaste::ContentBlockedError
230
+ │ ├── Posthaste::AttachmentError
231
+ │ ├── Posthaste::ScheduleError
232
+ │ ├── Posthaste::TemplateError
233
+ │ └── Posthaste::StreamError
234
+ ├── Posthaste::RateLimited 429 — transient
235
+ ├── Posthaste::QuotaExhausted 429 — a SIBLING, never a subclass
236
+ └── Posthaste::ServerError 5xx
237
+ ```
238
+
239
+ `QuotaExhausted` being a sibling of `RateLimited` rather than a subclass is
240
+ deliberate. All four of the API's 429s look identical at the status level, and
241
+ they are not: `rate_limited` and `platform_paused` clear in seconds, while
242
+ `daily_limit_reached` and `monthly_limit_reached` clear when the calendar moves.
243
+ A `rescue Posthaste::RateLimited` that slept would sleep for a week.
244
+
245
+ Branch on `error.type` — a stable machine-readable string — never on the
246
+ message. An `error.type` this version has never seen is not a bug: the API is
247
+ allowed to add refusal reasons, and an unknown one becomes the class its status
248
+ implies rather than being mistaken for a documented one.
249
+ `Posthaste::KNOWN_ERROR_TYPES` is the list this version knows.
250
+
251
+ `raise_delivery_errors = false` still swallows all of it, because this is a real
252
+ ActionMailer delivery method and `Mail::Message#do_delivery` is what rescues.
253
+
254
+ ## Retries
255
+
256
+ A failed send is repeated automatically **only when it carries an idempotency
257
+ key**, because without one a retry after a lost response sends the email twice.
258
+ For a mail API that is not a performance detail; it is the difference between
259
+ one invoice and two.
260
+
261
+ Set `X-Posthaste-Idempotency-Key` and you get two retries with exponential
262
+ backoff and full jitter, honouring `Retry-After` up to `max_retry_delay`
263
+ (60 seconds by default) and never retrying an exhausted quota. Beyond that
264
+ ceiling the error comes back so you can schedule it rather than blocking a web
265
+ request.
266
+
267
+ The key is a **body field**, not the `Idempotency-Key` HTTP header. The header is
268
+ in the API's CORS allowlist but no handler reads it, so a client that sends the
269
+ header and not the field gets no idempotency at all and no warning that it has
270
+ none. This gem never sends the header.
271
+
272
+ ## The API key
273
+
274
+ The key is a bearer credential: whoever holds it can send from every verified
275
+ domain on the account. It escapes through the places nobody guards — Ruby's
276
+ default `#inspect` prints every instance variable, and Rails' exception page, a
277
+ `binding.irb` pasted into a ticket, and an error reporter capturing locals all
278
+ call it on your behalf.
279
+
280
+ So every object here defines its own `#inspect`, and every string built for a
281
+ human goes through a redactor first:
282
+
283
+ ```ruby
284
+ ActionMailer::Base.delivery_method # => :posthaste
285
+ mailer.message.delivery_method.inspect
286
+ # => #<Posthaste::DeliveryMethod api_key="ph_test_***redacted***" …>
287
+ ```
288
+
289
+ The environment prefix is kept because it is not secret — it is printed beside
290
+ every key in the dashboard — and it is the one piece that tells somebody staring
291
+ at a 401 that they pasted the test key into production. It is deliberately _not_
292
+ the usual last-four-characters convention: four characters of a token this size
293
+ cannot authenticate, but they are enough to confirm a guess.
294
+
295
+ Server messages are redacted too, in two passes: this client's own key, and then
296
+ anything key-shaped, which catches a _different_ account's key echoed back by a
297
+ proxy that quoted the request line.
298
+
299
+ ## Testing your own mailers
300
+
301
+ Nothing changes. `delivery_method = :test`, `ActionMailer::Base.deliveries` and
302
+ `assert_emails` all keep working, because this is a delivery method registered
303
+ through ActionMailer's own `add_delivery_method` rather than a replacement for
304
+ any part of it.
305
+
306
+ To drive this gem in a test without a network, pass a `transport:` — anything
307
+ that responds to `call(method, url, headers, body)` and returns a
308
+ `Posthaste::Response`.
309
+
310
+ ## Running this gem's own tests
311
+
312
+ ```
313
+ cd packages/actionmailer-ruby
314
+ ruby -Ilib -Itest -e 'Dir["test/**/*_test.rb"].each { |f| require "./#{f}" }'
315
+ ```
316
+
317
+ The suite never makes a real network call and never sends real mail: `Net::HTTP`
318
+ is poisoned in `test_helper.rb`, and every address in the package is under an
319
+ RFC 2606 reserved domain, enforced by `no_real_recipients_test.rb`.
320
+
321
+ ## Licence
322
+
323
+ MIT.
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ # posthaste-rails — send ActionMailer mail through the Posthaste transactional
4
+ # email API.
5
+ #
6
+ # # config/environments/production.rb
7
+ # config.action_mailer.delivery_method = :posthaste
8
+ # config.action_mailer.posthaste_settings = { api_key: ENV['POSTHASTE_API_KEY'] }
9
+ #
10
+ # That is the whole migration. Every `mail()` call, every view, every
11
+ # `deliver_later`, every `assert_emails` in your test suite stays as it is,
12
+ # because this registers a delivery method through ActionMailer's own
13
+ # `add_delivery_method` rather than replacing anything.
14
+ #
15
+ # The fields the send API has and ActionMailer has no word for — streams, tags,
16
+ # metadata, idempotency keys, stored templates, scheduled sending — are set with
17
+ # `X-Posthaste-…` headers on the message, which `mail()` already passes through
18
+ # untouched. See the README.
19
+
20
+ require 'active_support/lazy_load_hooks'
21
+
22
+ require_relative 'delivery_method'
23
+ require_relative 'errors'
24
+ require_relative 'http_client'
25
+ require_relative 'message_mapper'
26
+ require_relative 'redaction'
27
+ require_relative 'result'
28
+ require_relative 'version'
29
+
30
+ module Posthaste
31
+ # Register `:posthaste` with ActionMailer.
32
+ #
33
+ # Idempotent, and it has to be: `add_delivery_method` resets
34
+ # `posthaste_settings` to the default options, so calling it a second time
35
+ # after the application has configured its key would silently blank the key.
36
+ # The guard is `respond_to?`, because that accessor is exactly what
37
+ # `add_delivery_method` defines.
38
+ #
39
+ # @return [Boolean] true if this call did the registering
40
+ def self.register_delivery_method!(base = ::ActionMailer::Base)
41
+ return false if base.respond_to?(:posthaste_settings)
42
+
43
+ base.add_delivery_method(:posthaste, DeliveryMethod)
44
+ true
45
+ end
46
+
47
+ # Add `Mail::Message#posthaste_result`, once Mail is loaded.
48
+ def self.extend_mail_message!(klass = ::Mail::Message)
49
+ return false if klass.include?(MessageExtensions)
50
+
51
+ klass.include(MessageExtensions)
52
+ true
53
+ end
54
+ end
55
+
56
+ # The lazy hook, not an eager `require 'action_mailer'`.
57
+ #
58
+ # `on_load(:action_mailer)` fires when ActionMailer::Base is actually loaded —
59
+ # in a Rails app that is during `action_mailer.set_configs`, and in a plain
60
+ # script it is the moment somebody requires action_mailer. Requiring
61
+ # ActionMailer here instead would drag the whole of ActionPack into the boot of
62
+ # an application that has not asked for it yet.
63
+ ActiveSupport.on_load(:action_mailer) do
64
+ Posthaste.register_delivery_method!(self)
65
+ Posthaste.extend_mail_message!
66
+ end
67
+
68
+ require_relative 'railtie' if defined?(::Rails::Railtie)
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+ require_relative 'http_client'
5
+ require_relative 'message_mapper'
6
+ require_relative 'redaction'
7
+ require_relative 'result'
8
+
9
+ module Posthaste
10
+ # An ActionMailer delivery method.
11
+ #
12
+ # This is ActionMailer's own contract and nothing more: `add_delivery_method`
13
+ # registers a class, `wrap_delivery_behavior` instantiates it with the
14
+ # settings hash, and `Mail::Message#do_delivery` calls `deliver!(mail)`. That
15
+ # is why `perform_deliveries`, `raise_delivery_errors`, `deliver_later`, the
16
+ # `:test` delivery method, `assert_emails` and every interceptor and observer
17
+ # keep working untouched — none of them are our business, and a hand-rolled
18
+ # sender that bypassed `deliver!` would break all of them.
19
+ class DeliveryMethod
20
+ SETTING_KEYS = %i[
21
+ api_key base_url stream tags metadata open_timeout read_timeout
22
+ max_retries max_retry_delay transport logger on_warning
23
+ ].freeze
24
+
25
+ def initialize(settings = {})
26
+ settings = normalize(settings)
27
+ unknown = settings.keys - SETTING_KEYS
28
+ unless unknown.empty?
29
+ raise ConfigurationError.new(
30
+ "unknown posthaste_settings: #{unknown.map(&:inspect).join(', ')}. " \
31
+ "Known settings are: #{SETTING_KEYS.map(&:inspect).join(', ')}.",
32
+ type: 'configuration_error'
33
+ )
34
+ end
35
+
36
+ # Held in one place, and never in a hash that some other object's
37
+ # `#inspect` might print.
38
+ @api_key = (settings[:api_key] || ENV['POSTHASTE_API_KEY']).to_s
39
+ if @api_key.empty?
40
+ raise ConfigurationError.new(
41
+ 'no Posthaste API key. Set `config.action_mailer.posthaste_settings = ' \
42
+ '{ api_key: … }` or the POSTHASTE_API_KEY environment variable.',
43
+ type: 'configuration_error'
44
+ )
45
+ end
46
+
47
+ @logger = settings[:logger]
48
+ @on_warning = settings[:on_warning]
49
+ @defaults = {
50
+ stream: settings[:stream],
51
+ tags: settings[:tags],
52
+ metadata: settings[:metadata]
53
+ }.compact
54
+
55
+ @client = HttpClient.new(
56
+ api_key: @api_key,
57
+ base_url: settings[:base_url] || DEFAULT_BASE_URL,
58
+ transport: settings[:transport],
59
+ max_retries: settings.fetch(:max_retries, HttpClient::DEFAULT_MAX_RETRIES),
60
+ max_retry_delay: settings.fetch(:max_retry_delay, HttpClient::DEFAULT_MAX_RETRY_DELAY),
61
+ open_timeout: settings.fetch(:open_timeout, 10),
62
+ read_timeout: settings.fetch(:read_timeout, 30)
63
+ )
64
+ end
65
+
66
+ # ActionMailer's delivery-method contract. Returns the Result, and attaches
67
+ # it to the message, because `deliver_now` hands the caller back the
68
+ # `Mail::Message` and not this return value.
69
+ def deliver!(mail)
70
+ payload, warnings = MessageMapper.new(@defaults).call(mail)
71
+ warnings.each { |warning| report(warning) }
72
+
73
+ body = @client.send_email(payload, idempotent: payload.key?(:idempotencyKey))
74
+ result = Result.new(body, mapping_warnings: warnings)
75
+ mail.instance_variable_set(:@posthaste_result, result)
76
+ result
77
+ end
78
+
79
+ # `settings` is exposed by ActionMailer as `posthaste_settings`, which means
80
+ # the key is reachable from `Rails.application.config` whatever this gem
81
+ # does. What this gem can control is that IT never prints it — and the
82
+ # default `#inspect` prints every instance variable, which is how a key
83
+ # reaches an error reporter without anybody having logged it.
84
+ def inspect
85
+ "#<Posthaste::DeliveryMethod api_key=#{Redaction.describe_key(@api_key).inspect} " \
86
+ "client=#{@client.inspect}>"
87
+ end
88
+ alias to_s inspect
89
+
90
+ private
91
+
92
+ def report(warning)
93
+ @on_warning&.call(warning)
94
+ # `warn` on a Logger is deprecated in neither Rails nor Ruby; a warning
95
+ # here is a mapping change the application should know about but that
96
+ # does not stop the send.
97
+ @logger&.warn("[posthaste] #{warning}")
98
+ end
99
+
100
+ def normalize(settings)
101
+ return {} if settings.nil?
102
+
103
+ settings.to_h.each_with_object({}) { |(k, v), out| out[k.to_sym] = v }
104
+ end
105
+ end
106
+ end