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 +7 -0
- data/LICENSE +21 -0
- data/README.md +323 -0
- data/lib/posthaste/actionmailer.rb +68 -0
- data/lib/posthaste/delivery_method.rb +106 -0
- data/lib/posthaste/errors.rb +311 -0
- data/lib/posthaste/http_client.rb +256 -0
- data/lib/posthaste/message_mapper.rb +494 -0
- data/lib/posthaste/railtie.rb +31 -0
- data/lib/posthaste/redaction.rb +62 -0
- data/lib/posthaste/result.rb +61 -0
- data/lib/posthaste/version.rb +8 -0
- data/lib/posthaste-rails.rb +6 -0
- metadata +77 -0
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
|