hailer 0.1.0-aarch64-linux-gnu → 0.3.0-aarch64-linux-gnu
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 +4 -4
- data/CHANGELOG.md +24 -1
- data/README.md +299 -32
- data/docs/durable-delivery.md +217 -0
- data/hailer.example.toml +43 -2
- data/lib/hailer/delivery_context.rb +19 -0
- data/lib/hailer/delivery_method.rb +9 -5
- data/lib/hailer/errors.rb +4 -0
- data/lib/hailer/lifecycle_client.rb +124 -0
- data/lib/hailer/mail_delivery_job.rb +18 -0
- data/lib/hailer/queued_client.rb +53 -0
- data/lib/hailer/queued_delivery_method.rb +18 -0
- data/lib/hailer/railtie.rb +6 -1
- data/lib/hailer/transport.rb +24 -7
- data/lib/hailer/version.rb +1 -1
- data/lib/hailer.rb +4 -0
- data/vendor/aarch64-linux-gnu/bin/hailer +0 -0
- data/vendor/aarch64-linux-gnu/manifest.json +2 -2
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: af151d1a799326f58a0e633db43f554f010800d7887e2b00633d5cd024f1bb19
|
|
4
|
+
data.tar.gz: 6e915bb5ecd8759c5634438e0204469f5a63766b4c97f739d7ffd06e02e047b7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b43e8ddde4f4f61e4f2aeb07c47aebdfed075d93384d282e3068589f36bcedd9e10b52e17c93960200bc5fe56ab094b18c81d20224dccb1681803fa77be7b1c0
|
|
7
|
+
data.tar.gz: ac7905996749e173459b181bc71ae4716011258eaa82b741c9fd163882bb3c4228cdec759c1f4c942936bf7879b47255e058351300daa661e5d4b41dbc22a6c1
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## Working tree — durable delivery (unreleased)
|
|
4
|
+
|
|
5
|
+
- Opt-in `:hailer_queued` Rails adapter with stable job keys and frozen intake retries.
|
|
6
|
+
- PostgreSQL ownership receipts, immutable submission replay/conflict and explicit spool budgets.
|
|
7
|
+
- Supervised fenced dispatch, journaled attempts, safe retries and held unknown outcomes.
|
|
8
|
+
- Tagged SES feedback correlation, recipient facts, audited cancellation and resolution.
|
|
9
|
+
- Deadline/body retention, independent lifecycle feed and intake/dispatcher readiness.
|
|
10
|
+
- Native Rust future traits without `async-trait`; throttle-machines 0.3.1.
|
|
11
|
+
- Current-source CommonShare queued smoke with loopback SES; no production cutover.
|
|
12
|
+
- New Linux binary builds target Debian 13 Trixie (glibc 2.41), with locked GNU Ruby dependency variants.
|
|
13
|
+
- Opt-in pre-send address verification (Emailable first; swappable via `Verifier` enum), `[email_verification]` in `hailer.toml`, cached verdicts in `hailer.address_verifications`. Fails open by default - a verifier outage or `unknown`/`risky` result never blocks a send unless explicitly configured into `reject_states`.
|
|
14
|
+
- Domain/pattern blocklist (`hailer.domain_blocks`): exact domains or `*.`-suffix globs, always active. A small hand-curated disposable-provider list ships with the repo (source `curated`, only changes by upgrading); operators add their own via `hailer ban-domain`/`unban-domain` (source `operator`). Checked before suppression and verification - the cheapest, broadest filter runs first.
|
|
15
|
+
- New `quarantined` message status, queued pipeline only: a non-ASCII recipient domain, or (opt-in `[domain_intel]`, RDAP-backed) a newly registered one, holds the whole message for operator review (`hailer release-quarantine`/`cancel-message`) instead of a hard reject. Excluded from the dispatch index like `held_unknown`; expires on the normal deadline like `queued`.
|
|
16
|
+
|
|
17
|
+
## 0.2.0 (unreleased)
|
|
18
|
+
|
|
19
|
+
- PostgreSQL-only live state, with atomic feedback and commit-safe cursors.
|
|
20
|
+
- Resumable, read-only AWS suppression reconciliation scoped by account/region.
|
|
21
|
+
- Pure behavior admission circuit, per-process throttle, and SES circuit breaker.
|
|
22
|
+
- Single-attempt sends, explicit ambiguous outcomes, and typed rate-limit errors.
|
|
23
|
+
- Private health/readiness sensors, supervised workers, and bounded graceful drain.
|
|
24
|
+
- Explicit read-only migration of stopped 0.1 redb state into an empty database.
|
|
25
|
+
|
|
26
|
+
## 0.1.0
|
|
4
27
|
|
|
5
28
|
- Rails 8.1+/Ruby 4.0+ ActionMailer delivery with automatic defaults.
|
|
6
29
|
- Typed provider-neutral feedback client and Rails callback consumer.
|
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Rails writes the email. Hailer deals with sending it.
|
|
4
4
|
|
|
5
|
-
This repository
|
|
5
|
+
This repository prepares two things at version **0.2.0** (not published yet):
|
|
6
6
|
|
|
7
7
|
- **`hailer` gem** — an ActionMailer adapter, feedback client, and executable
|
|
8
8
|
launcher. Platform gems bundle the Rust binary. No Ruby AWS SDK or native extension.
|
|
@@ -13,6 +13,12 @@ This repository ships two things at version **0.1.0**:
|
|
|
13
13
|
ActionMailer → hailer gem → private Mothership route → hailer binary → SES
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
+
The working tree also implements an **opt-in durable queued adapter**. See
|
|
17
|
+
[durable delivery](docs/durable-delivery.md) for ownership, recovery, routing
|
|
18
|
+
and operator controls. It is not included in the preserved 0.2 release
|
|
19
|
+
candidates. Current-source macOS, ARM64 Linux and x86_64 Linux verification
|
|
20
|
+
gates pass locally; this work has not been published or deployed.
|
|
21
|
+
|
|
16
22
|
## Rails
|
|
17
23
|
|
|
18
24
|
Requires Ruby 4.0+ and Rails 8.1 or newer within Rails 8.x.
|
|
@@ -78,7 +84,7 @@ name = "hailer"
|
|
|
78
84
|
command = "hailer"
|
|
79
85
|
workers = 0
|
|
80
86
|
threads = 2
|
|
81
|
-
routes = [{ bind = "mail", pattern = "^/internal/mail/send$" }]
|
|
87
|
+
routes = [{ bind = "mail", pattern = "^/internal/mail/(send|events|health|ready)$" }]
|
|
82
88
|
```
|
|
83
89
|
|
|
84
90
|
Mothership exports `MS_PID`, `MS_SHIP`, and `MS_SOCKET_DIR`. Hailer binds its
|
|
@@ -90,6 +96,31 @@ Give the **service**, not the Rails adapter, AWS region and credentials through
|
|
|
90
96
|
the standard AWS credential chain. Its identity needs `ses:SendEmail`, and the
|
|
91
97
|
SES account/identities must be configured for sending.
|
|
92
98
|
|
|
99
|
+
### PostgreSQL
|
|
100
|
+
|
|
101
|
+
PostgreSQL is the **only live backend** in 0.2. Hailer owns a `hailer` schema,
|
|
102
|
+
not Rails tables. Use a separate database/role where practical:
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
export HAILER_DATABASE_URL='postgres://hailer:password@db/hailer?sslmode=verify-full'
|
|
106
|
+
hailer migrate
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Supply the database CA through the normal PostgreSQL connection options.
|
|
110
|
+
Use certificate verification in deployment; a private network is not TLS.
|
|
111
|
+
Keep the URL in a secret manager, not TOML or a committed initializer.
|
|
112
|
+
Startup also applies embedded, versioned migrations. The bootstrap identity
|
|
113
|
+
needs database `CREATE` and ownership of its schema/tables. Runtime startup
|
|
114
|
+
currently runs that same bootstrap/migrator, so provision the service role for
|
|
115
|
+
it too; a separate DML-only runtime mode is not implemented.
|
|
116
|
+
The runtime never needs Rails schema ownership. Back up the database with your
|
|
117
|
+
normal PostgreSQL backup/restore tooling.
|
|
118
|
+
|
|
119
|
+
Configuration defaults: pool size 5 (range 1–64), shutdown timeout 30 seconds
|
|
120
|
+
(range 1–300). Every replica shares PostgreSQL; each has its own pool and optional
|
|
121
|
+
rate budget. Set your supervisor/container stop grace longer than Hailer's
|
|
122
|
+
shutdown timeout. PostgreSQL 18 is exercised locally and in CI.
|
|
123
|
+
|
|
93
124
|
## Bounces, complaints, and suppressions
|
|
94
125
|
|
|
95
126
|
```text
|
|
@@ -97,7 +128,7 @@ SES → SNS → dedicated SQS queue → Hailer → durable events + suppressions
|
|
|
97
128
|
```
|
|
98
129
|
|
|
99
130
|
Hailer atomically commits each event, deduplication key, and suppression change
|
|
100
|
-
to
|
|
131
|
+
to PostgreSQL **before** acknowledging SQS. Failed acknowledgements
|
|
101
132
|
can be retried without duplicating stored events. Invalid/untrusted messages
|
|
102
133
|
are left for SQS's dead-letter policy.
|
|
103
134
|
|
|
@@ -113,14 +144,16 @@ are left for SQS's dead-letter policy.
|
|
|
113
144
|
Copy [hailer.example.toml](hailer.example.toml), set the real queue/topic, and
|
|
114
145
|
pass `--config /path/to/hailer.toml` or `HAILER_CONFIG`. Without `[feedback]`,
|
|
115
146
|
the consumer is disabled, but previously stored suppressions are still enforced.
|
|
116
|
-
`
|
|
117
|
-
|
|
147
|
+
`HAILER_DATABASE_URL` is required, even with feedback disabled.
|
|
148
|
+
The old `state_path`, `--state-path`, and `HAILER_STATE_PATH` are not supported.
|
|
149
|
+
Replicas may consume the same dedicated queue when they share the same Hailer
|
|
150
|
+
database. Commits serialize cursor allocation so a reader cannot skip an earlier
|
|
151
|
+
transaction that has not committed yet.
|
|
118
152
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
no automatic retention/pruning.
|
|
153
|
+
Event history and deduplication have **no automatic retention/pruning**.
|
|
154
|
+
Do not delete events casually: event IDs also carry the SQS deduplication
|
|
155
|
+
contract, and Rails consumers may still need their cursors. Plan retention
|
|
156
|
+
against consumer lag and redelivery windows before adding a pruning policy.
|
|
124
157
|
|
|
125
158
|
Hailer also exposes a provider-neutral, nondestructive event feed:
|
|
126
159
|
|
|
@@ -157,12 +190,159 @@ updates in one application database transaction. Retried pages require
|
|
|
157
190
|
idempotent handlers; no hidden poller, scheduling, or checkpoint database is
|
|
158
191
|
started by the gem. Reading does not acknowledge or erase events.
|
|
159
192
|
|
|
160
|
-
|
|
193
|
+
The SQS policy must allow only the intended SNS topic, using `aws:SourceArn`
|
|
194
|
+
and account restrictions. Subscribe with `RawMessageDelivery=false`, restrict
|
|
195
|
+
direct `sqs:SendMessage`, set a dead-letter queue, and monitor its depth.
|
|
196
|
+
The SNS topic allowlist in TOML is defense in depth, not signature verification.
|
|
197
|
+
Hailer needs `sqs:ReceiveMessage` and `sqs:DeleteMessage` on that queue, plus
|
|
198
|
+
applicable KMS decrypt permissions if customer-managed encryption is used.
|
|
199
|
+
|
|
200
|
+
### Import AWS suppressions
|
|
201
|
+
|
|
202
|
+
```sh
|
|
203
|
+
hailer sync-suppressions
|
|
204
|
+
# If AWS expires a saved pagination token:
|
|
205
|
+
hailer sync-suppressions --restart
|
|
206
|
+
# Existing application export, JSON array:
|
|
207
|
+
hailer import-suppressions --file suppressions.json
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
The service identity needs `ses:ListSuppressedDestinations`; STS
|
|
211
|
+
`GetCallerIdentity` identifies the account (AWS does not require an explicit
|
|
212
|
+
allow for that operation). The configured region is part of the source identity.
|
|
213
|
+
These commands **read AWS only**; they never mutate the account suppression list.
|
|
214
|
+
Missing AWS entries do not unsuppress local entries, and a bounce never
|
|
215
|
+
downgrades a complaint. Imports do not fabricate delivery-history events.
|
|
216
|
+
Account-level AWS data does not include every possible global SES suppression.
|
|
217
|
+
|
|
218
|
+
Enable `[suppression_sync]` as in the example to reconcile on startup and hourly.
|
|
219
|
+
The first complete sync gates send readiness. Later errors mark health degraded,
|
|
220
|
+
retain committed pages, and retry safe reads with bounded backoff. A token is
|
|
221
|
+
stored with its page in one transaction. Replicas coordinate a source-scoped
|
|
222
|
+
advisory lock; they cannot advance the same checkpoint concurrently.
|
|
223
|
+
An expired token deliberately requires the operator's `--restart`. Omitting
|
|
224
|
+
the section disables scheduled sync, not existing suppression enforcement.
|
|
225
|
+
|
|
226
|
+
### Control plane
|
|
227
|
+
|
|
228
|
+
The behavior circuit evaluates **pure admission decisions**: input validity,
|
|
229
|
+
service/storage readiness, suppression, and local rate admission. PostgreSQL,
|
|
230
|
+
AWS calls, clocks, and token reservations remain owned by the async host.
|
|
231
|
+
The SES circuit breaker counts transient/ambiguous failures, not bad addresses
|
|
232
|
+
or permanent request rejections. An optional `[rate_limit]` budgets envelope
|
|
233
|
+
recipients **per process**, not per account; it does not discover SES quotas.
|
|
234
|
+
|
|
235
|
+
Private `GET /internal/mail/health` returns sanitized component states and
|
|
236
|
+
counters. `GET /internal/mail/ready` returns 503 during initial sync or storage
|
|
237
|
+
failure, otherwise 200. A provider circuit can be degraded while readiness
|
|
238
|
+
remains true so protected half-open probes can recover it. No credentials,
|
|
239
|
+
recipient addresses, message bodies, or pagination tokens appear in these sensors.
|
|
240
|
+
They are operational signals, not a guarantee SES will accept a message.
|
|
241
|
+
|
|
242
|
+
TERM/ctrl-c stops admission and SQS receives, then drains owned requests and
|
|
243
|
+
the current feedback commit/ack within the configured deadline. Idle links
|
|
244
|
+
close immediately. Deadline expiry aborts remaining tasks and exits nonzero;
|
|
245
|
+
an interrupted send can have an unknown outcome.
|
|
161
246
|
|
|
162
247
|
**Security:** the route currently has no application-level authentication.
|
|
163
248
|
`/internal/` is a name, not an access control. Keep the bind private. The client
|
|
164
249
|
does not follow redirects or use environment HTTP proxies.
|
|
165
250
|
|
|
251
|
+
## Impossible addresses
|
|
252
|
+
|
|
253
|
+
Always on, no configuration: `toto@example.com` is syntactically a real
|
|
254
|
+
address, but RFC 2606 reserves `example.com`/`.net`/`.org` and the
|
|
255
|
+
`test`/`example`/`invalid`/`localhost` TLDs for documentation - no real
|
|
256
|
+
mailbox ever lives there. Rejected at the same boundary as a malformed
|
|
257
|
+
address (`SendRequest::validate`), before any guard in the chain runs, for
|
|
258
|
+
both the synchronous and queued adapters. Not a policy choice like the
|
|
259
|
+
domain blocklist below - there is nothing to configure or an operator could
|
|
260
|
+
turn off.
|
|
261
|
+
|
|
262
|
+
## Domain blocklist
|
|
263
|
+
|
|
264
|
+
```text
|
|
265
|
+
SendRequest -> impossible-address check -> domain blocklist (free, local) -> suppression (free, local) -> verification (paid, external) -> SES
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Always active - an empty `hailer.domain_blocks` table is a no-op, there is
|
|
269
|
+
no toggle. A pattern is an exact domain (`guerrillamail.com`) or a
|
|
270
|
+
`*.`-prefixed suffix glob (`*.kp` matches that domain and any subdomain).
|
|
271
|
+
A small hand-curated list of known disposable-mail providers ships with the
|
|
272
|
+
repo (`source = 'curated'`, migration `05_domain_blocks.sql`); it only grows
|
|
273
|
+
by upgrading Hailer, never fetched from a third party at runtime. Operators
|
|
274
|
+
add their own:
|
|
275
|
+
|
|
276
|
+
```sh
|
|
277
|
+
hailer ban-domain --pattern "*.kp" --reason "sanctioned TLD"
|
|
278
|
+
hailer unban-domain --pattern "*.kp"
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
`unban-domain` only removes an `operator`-sourced pattern - a curated one
|
|
282
|
+
cannot be removed this way, by design.
|
|
283
|
+
|
|
284
|
+
## Address verification
|
|
285
|
+
|
|
286
|
+
```text
|
|
287
|
+
SendRequest -> suppression check (cheap, local) -> address verification (paid, external) -> SES
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Opt-in pre-send deliverability check, off by default. Enable with
|
|
291
|
+
`[email_verification]` in `hailer.toml`:
|
|
292
|
+
|
|
293
|
+
```toml
|
|
294
|
+
[email_verification]
|
|
295
|
+
provider = "emailable"
|
|
296
|
+
reject_states = ["undeliverable"] # risky/unknown fail open unless added here
|
|
297
|
+
cache_ttl_seconds = 604800
|
|
298
|
+
timeout_seconds = 5
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Set `EMAILABLE_API_KEY` in the process environment, never in `hailer.toml` -
|
|
302
|
+
the same rule as `HAILER_DATABASE_URL` and AWS credentials. Suppression
|
|
303
|
+
filters first since it is a free local lookup; verification only runs for
|
|
304
|
+
recipients that survive it, so a suppressed address never costs a paid
|
|
305
|
+
lookup. Verdicts are cached in `hailer.address_verifications` (a week by
|
|
306
|
+
default) - a retried send against the same address reuses the cached verdict
|
|
307
|
+
instead of paying for another check.
|
|
308
|
+
|
|
309
|
+
A verifier outage, timeout, or an `unknown`/`risky` result all fail open: this
|
|
310
|
+
middleware is never a new reason a real send stops working unless a
|
|
311
|
+
deployment explicitly adds those states to `reject_states`. Adding another
|
|
312
|
+
provider (ZeroBounce, NeverBounce, ...) means a new `Verifier` enum variant in
|
|
313
|
+
`src/verify.rs`; nothing else in the send path changes.
|
|
314
|
+
|
|
315
|
+
### Bulk pre-screening (batch)
|
|
316
|
+
|
|
317
|
+
A separate concern from the real-time guard above: pre-verify a whole list
|
|
318
|
+
*before* ever trying to send to it, not a per-send check. Emailable's batch
|
|
319
|
+
API is async (submit, then poll) and only returns per-address results inline
|
|
320
|
+
for batches up to 1,000 - larger ones get a ZIP/CSV download link this client
|
|
321
|
+
deliberately does not fetch or parse, to avoid a zip/csv dependency:
|
|
322
|
+
|
|
323
|
+
```sh
|
|
324
|
+
hailer verify-batch --file addresses.txt # one address per line, max 1000
|
|
325
|
+
hailer batch-status --id BATCH_ID # poll; caches each verdict once complete
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
`batch-status` writes every returned verdict into `hailer.address_verifications`
|
|
329
|
+
(the same cache the real-time guard reads), so a later send to an
|
|
330
|
+
already-screened address reuses the cached verdict instead of paying for
|
|
331
|
+
another single-address check. A batch over 1,000 addresses is reported with
|
|
332
|
+
its `download_file` URL but is not cached - split into smaller batches
|
|
333
|
+
instead.
|
|
334
|
+
|
|
335
|
+
`verify-batch` never submits an address that does not need it: exact
|
|
336
|
+
duplicate lines (by the same normalized key the cache itself is keyed by -
|
|
337
|
+
`USER+alias@x` and `user@x` are the same address), and anything already in
|
|
338
|
+
`hailer.address_verifications` with an unexpired verdict, are dropped before
|
|
339
|
+
the batch is ever submitted - never spending a credit on them. Every
|
|
340
|
+
exclusion is counted and reported (`invalid_lines`, `duplicate_lines`,
|
|
341
|
+
`already_cached`), never silently dropped. `hailer emailable-account` checks
|
|
342
|
+
the Emailable account's remaining credits directly; `submit_batch` itself
|
|
343
|
+
also checks this before submitting and refuses (without ever reaching the
|
|
344
|
+
server) if the account has none left or fewer than the batch needs.
|
|
345
|
+
|
|
166
346
|
## Failure semantics
|
|
167
347
|
|
|
168
348
|
An HTTP 200 means **SES accepted the message**, not that the recipient received
|
|
@@ -172,28 +352,47 @@ it. A successful response must include a nonblank `message_id`.
|
|
|
172
352
|
| --- | --- |
|
|
173
353
|
| HTTP 400 / 422 | `Hailer::ValidationError` |
|
|
174
354
|
| HTTP 502 | `Hailer::RejectedError` |
|
|
355
|
+
| HTTP 429 | `Hailer::RateLimitedError` |
|
|
356
|
+
| HTTP 503, `code: "outcome_unknown"` | `Hailer::AmbiguousDeliveryError` |
|
|
175
357
|
| HTTP 503 | `Hailer::UnavailableError` |
|
|
176
358
|
| Other non-200 status | `Hailer::DeliveryError` |
|
|
177
359
|
| Invalid/oversized response, missing message ID | `Hailer::ProtocolError` |
|
|
178
360
|
| Connection, TLS, or timeout failure | `Hailer::TransportError` |
|
|
179
361
|
| Missing/invalid endpoint or timeout settings | `Hailer::ConfigurationError` |
|
|
180
362
|
|
|
181
|
-
The
|
|
182
|
-
message; retrying may duplicate it.
|
|
183
|
-
|
|
363
|
+
The synchronous adapter does not retry. A lost response can happen **after** SES
|
|
364
|
+
accepted the message; retrying may duplicate it. This route does not use the
|
|
365
|
+
opt-in outbound spool or its submission-key deduplication.
|
|
184
366
|
Rails jobs can also retry, so configure them deliberately.
|
|
185
|
-
The
|
|
186
|
-
|
|
367
|
+
The SES SDK makes **one send attempt**. AWS throttling/transient failures return
|
|
368
|
+
503, permanent service rejections 502, and disconnects/timeouts/missing message
|
|
369
|
+
IDs carry the explicit unknown-outcome code. None of these responses authorizes
|
|
370
|
+
an automatic retry of an ambiguous send.
|
|
187
371
|
|
|
188
372
|
## Status and migration
|
|
189
373
|
|
|
190
374
|
The delivery contract and Hailer-side feedback processing are implemented. CommonShare's
|
|
191
375
|
existing sender is the compatibility reference, not a runtime dependency.
|
|
192
|
-
|
|
376
|
+
|
|
377
|
+
### Upgrade from 0.1 redb
|
|
378
|
+
|
|
379
|
+
1. Stop all old Hailer processes cleanly. Keep the source file and a backup.
|
|
380
|
+
2. Provision an **empty** Hailer PostgreSQL database and set its URL.
|
|
381
|
+
3. Run `hailer migrate-redb --file /private/path/state.redb`.
|
|
382
|
+
4. Check reported counts, event cursors, and suppression enforcement before
|
|
383
|
+
starting 0.2. Keep the source backup; do not restart old writers after cutover.
|
|
384
|
+
|
|
385
|
+
Migration opens redb read-only, checks event/dedup mappings and suppression keys,
|
|
386
|
+
and copies bounded batches in **one destination transaction**. It preserves
|
|
387
|
+
event IDs, sequence values, suppression metadata, and next-cursor continuity.
|
|
388
|
+
It rejects missing/corrupt sources and nonempty targets without wiping anything.
|
|
389
|
+
An unclean redb file may require recovery with the old version against a backup
|
|
390
|
+
before migration. There is no implicit fallback and no dual-write period.
|
|
193
391
|
|
|
194
392
|
Before switching an existing app:
|
|
195
393
|
|
|
196
|
-
1.
|
|
394
|
+
1. Provision PostgreSQL, run Hailer on a private Mothership bind, check readiness,
|
|
395
|
+
and verify real SES delivery using a deliberate test.
|
|
197
396
|
2. Add the gem, set `HAILER_URL`, and remove the app's old delivery-method
|
|
198
397
|
override so Hailer's default takes effect.
|
|
199
398
|
3. Subscribe a dedicated SQS queue before exporting and importing existing
|
|
@@ -202,22 +401,26 @@ Before switching an existing app:
|
|
|
202
401
|
and any provider-neutral app history consumer have been verified.
|
|
203
402
|
5. Only then remove the old sender and AWS-specific feedback handling.
|
|
204
403
|
|
|
205
|
-
CommonShare's local integration is tested with its real application,
|
|
206
|
-
Mothership,
|
|
404
|
+
CommonShare's local 0.2 integration is tested with its real application,
|
|
405
|
+
test database, Mothership, the optimized binary from the macOS gem, its installed 0.1 Ruby
|
|
406
|
+
client, and a loopback SES fixture. Its existing AWS
|
|
207
407
|
receivers remain until the real subscription and suppression cutover is verified.
|
|
208
408
|
No deployed AWS routing has been changed.
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
409
|
+
The current-source queued adapter and dispatcher also pass a CommonShare smoke
|
|
410
|
+
using an isolated Bundler Gemfile, real Mothership and loopback SES. No app
|
|
411
|
+
Gemfile or production configuration was changed. Its legacy history observer
|
|
412
|
+
still needs queued-ownership semantics before opt-in deployment.
|
|
413
|
+
Validation/behavior plugins and distributed account-wide quotas remain later
|
|
414
|
+
work. No plugin API or provider exactly-once delivery is promised.
|
|
212
415
|
|
|
213
416
|
## Development
|
|
214
417
|
|
|
215
418
|
```sh
|
|
216
419
|
bundle install
|
|
217
420
|
bundle exec rake
|
|
218
|
-
cargo test --locked
|
|
421
|
+
HAILER_TEST_DATABASE_URL=postgres://test_user:password@127.0.0.1/hailer_test cargo test --locked
|
|
219
422
|
cargo fmt --all -- --check
|
|
220
|
-
gem build hailer.gemspec --output /tmp/hailer-0.
|
|
423
|
+
gem build hailer.gemspec --output /tmp/hailer-0.2.0.gem
|
|
221
424
|
bundle exec rake native:package
|
|
222
425
|
```
|
|
223
426
|
|
|
@@ -225,17 +428,81 @@ The Ruby suite exercises real local HTTP exchanges and a booted Rails app.
|
|
|
225
428
|
The Rust suite checks the same JSON fixture and the docking exchange with a
|
|
226
429
|
fake provider. Feedback tests cover redelivery, acknowledgement ordering,
|
|
227
430
|
atomic rollback, restart persistence, imports, and suppression enforcement.
|
|
228
|
-
|
|
431
|
+
Rust tests require an isolated PostgreSQL server and a test role with `CREATEDB`;
|
|
432
|
+
each test creates and drops its own database. Never point this variable at a
|
|
433
|
+
customer or Rails production database. Neither suite sends real mail or requires
|
|
434
|
+
AWS credentials.
|
|
435
|
+
|
|
436
|
+
For the opt-in CommonShare compatibility smoke, create a disposable Hailer
|
|
437
|
+
database, then run from that application's checkout:
|
|
438
|
+
|
|
439
|
+
```sh
|
|
440
|
+
RAILS_ENV=test HAILER_SMOKE_BINARY=/path/to/hailer/target/debug/hailer \
|
|
441
|
+
HAILER_DATABASE_URL=postgres://test_user:password@127.0.0.1/hailer_smoke \
|
|
442
|
+
bundle exec rails runner /path/to/hailer/test/commonshare_smoke.rb
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
It refuses a non-test app database, points the AWS SDK at a loopback fixture,
|
|
446
|
+
and rolls back application-history changes. It does not change the app's
|
|
447
|
+
dependency lockfile or send external mail.
|
|
229
448
|
|
|
230
449
|
`native:package` builds a locked release and writes
|
|
231
|
-
`pkg/hailer-0.
|
|
450
|
+
`pkg/hailer-0.2.0-<platform>.gem`. Each platform gem contains exactly its host
|
|
232
451
|
binary and a version/platform/SHA-256 manifest. The launcher verifies that
|
|
233
452
|
manifest before `exec`, preserving arguments, exit status, and signal handling.
|
|
234
453
|
`HAILER_BINARY` is an explicit, version-checked source-build override; there is
|
|
235
454
|
no download or PATH fallback.
|
|
236
455
|
|
|
237
|
-
Build on each supported release host before publishing its artifact.
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
456
|
+
Build on each supported release host before publishing its artifact. Nothing
|
|
457
|
+
in the packaging task publishes a gem.
|
|
458
|
+
|
|
459
|
+
## Linux releases and RubyGems
|
|
460
|
+
|
|
461
|
+
The **Linux binary gems** GitHub Actions workflow builds `x86_64-linux-gnu`
|
|
462
|
+
and `aarch64-linux-gnu` natively against Debian 13 Trixie (glibc 2.41).
|
|
463
|
+
New Trixie-built artifacts target glibc 2.41 or newer, not Bookworm or
|
|
464
|
+
Alpine/musl. The preserved older 0.2 candidates retain their original baseline.
|
|
465
|
+
Local OrbStack verification uses `test/linux/Dockerfile` for each architecture
|
|
466
|
+
and a separate `ruby:4.0-slim-trixie` runtime.
|
|
467
|
+
Use a copied checkout in separate per-architecture Docker work volumes; do not
|
|
468
|
+
reuse the macOS `target/` or Ruby bundle. Run the locked Ruby tests, release Rust
|
|
469
|
+
tests and `native:package` with an isolated PostgreSQL fixture, then install each
|
|
470
|
+
gem in its slim runtime and exercise the launcher, migrations and import CLI.
|
|
471
|
+
When transferring a macOS snapshot, use `COPYFILE_DISABLE=1 tar --no-xattrs`
|
|
472
|
+
so AppleDouble `._*` metadata cannot appear as spurious SQL migrations.
|
|
473
|
+
|
|
474
|
+
```sh
|
|
475
|
+
gh workflow run linux-gems.yml --repo seuros/hailer -f tag=v0.2.0
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
The workflow checks out the requested release tag, runs the Ruby and Rust
|
|
479
|
+
suites against an isolated PostgreSQL 18 service, and installs each binary gem
|
|
480
|
+
in a separate slim Debian container. Pushes/PRs also verify the branch without
|
|
481
|
+
creating a release or publishing anything; only explicit workflow dispatch
|
|
482
|
+
can attach assets to an existing release.
|
|
483
|
+
It verifies the installed platform, bundled-binary manifest, executable version,
|
|
484
|
+
and PostgreSQL suppression import before attaching either Linux gem to the
|
|
485
|
+
existing release. Existing gem assets are never overwritten; `SHA256SUMS` is
|
|
486
|
+
updated to cover all release gems.
|
|
487
|
+
|
|
488
|
+
Publishing a GitHub release does **not** publish to RubyGems. Like Shuttlebay,
|
|
489
|
+
release assets can be fetched and published with Rake:
|
|
490
|
+
|
|
491
|
+
```sh
|
|
492
|
+
bundle exec rake gems:fetch
|
|
493
|
+
bundle exec rake gems:push_all
|
|
494
|
+
# Or select an explicit release:
|
|
495
|
+
bundle exec rake 'gems:push_all[v0.2.0]'
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
`gems:push_all` fetches fresh assets, checks every archive's SHA-256,
|
|
499
|
+
name/version/platform, and publishes only that verified set. It skips
|
|
500
|
+
already-published platforms only when RubyGems reports the identical archive
|
|
501
|
+
hash, so an interrupted publication can be resumed without replacing artifacts.
|
|
502
|
+
`gems:push` is an alias. No release task rebuilds or silently republishes gems.
|
|
503
|
+
|
|
504
|
+
RubyGems may require MFA or a suitably scoped API key. Keep credentials out of
|
|
505
|
+
the repository. Publishing makes the packaged Ruby code and binaries public,
|
|
506
|
+
even when the GitHub repository is private. Once published, Bundler selects
|
|
507
|
+
the matching native gem; do not set `force_ruby_platform` in deployment if
|
|
508
|
+
you want the bundled executable.
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# Durable delivery (in development)
|
|
2
|
+
|
|
3
|
+
The queued Ruby interface, PostgreSQL spool and supervised dispatcher are
|
|
4
|
+
implemented. CommonShare source-client and installed macOS binary-gem smoke
|
|
5
|
+
tests pass. ARM64 and x86_64 Linux release tests, binary packaging and clean slim
|
|
6
|
+
Trixie runtime checks also pass in OrbStack (x86_64 under emulation).
|
|
7
|
+
Do not enable queued delivery against the existing 0.2 release
|
|
8
|
+
candidates or treat this working tree as a published durable-delivery release.
|
|
9
|
+
|
|
10
|
+
## Opt-in Rails configuration
|
|
11
|
+
|
|
12
|
+
Keep `HAILER_URL` pointing to the existing private `/internal/mail/send` endpoint.
|
|
13
|
+
The queued client uses the same validated origin with `/internal/mail/messages`.
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# config/environments/production.rb
|
|
17
|
+
config.action_mailer.delivery_method = :hailer_queued
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The initializer registers the adapter and selects `Hailer::MailDeliveryJob` for
|
|
21
|
+
this opt-in configuration. The default remains the synchronous `:hailer`
|
|
22
|
+
adapter, whose HTTP requests never retry.
|
|
23
|
+
|
|
24
|
+
For a per-mailer opt-in, select both:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
self.delivery_method = :hailer_queued
|
|
28
|
+
self.delivery_job = Hailer::MailDeliveryJob
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
An explicitly configured application delivery job is not replaced. It must
|
|
32
|
+
inherit `Hailer::MailDeliveryJob` or include `Hailer::DeliveryJobContext` to
|
|
33
|
+
obtain stable submission keys across job retries. This uses public ActiveJob
|
|
34
|
+
callbacks, not Rails monkey patches.
|
|
35
|
+
|
|
36
|
+
## Identities and retries
|
|
37
|
+
|
|
38
|
+
- Standard queued jobs use job ID plus delivery ordinal. Different jobs are
|
|
39
|
+
different intents unless the application supplies a business key.
|
|
40
|
+
- Direct `deliver_now` calls generate a fresh key per invocation.
|
|
41
|
+
- Supply a business key through `delivery_method_options` when separate calls
|
|
42
|
+
represent the same intended message:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
mail(
|
|
46
|
+
to: recipient,
|
|
47
|
+
subject: 'Invoice',
|
|
48
|
+
delivery_method_options: { submission_key: "invoice:#{invoice.id}:issued" }
|
|
49
|
+
)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- Supply an optional ISO 8601 `expires_at` through those same options. The
|
|
53
|
+
service contract defaults to 24 hours and allows shorter deadlines.
|
|
54
|
+
- A queued intake call serializes its command once. Up to three HTTP attempts
|
|
55
|
+
reuse those exact bytes, key and deadline after transport loss or retryable
|
|
56
|
+
admission errors (429/503). Set `intake_attempts: 1` to disable these retries.
|
|
57
|
+
- Validation, submission conflicts, malformed responses and provider-unknown
|
|
58
|
+
errors do not retry.
|
|
59
|
+
- Rails re-renders mail when a job is performed again. If the bytes or envelope
|
|
60
|
+
change under an accepted key, the service must return a conflict; the adapter
|
|
61
|
+
raises `Hailer::SubmissionConflictError`. Job keys alone do not provide
|
|
62
|
+
transparent exactly-once business delivery.
|
|
63
|
+
|
|
64
|
+
Submission keys are command metadata, not MIME headers.
|
|
65
|
+
|
|
66
|
+
## Private status and lifecycle feed
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
client = Hailer::LifecycleClient.new # same HAILER_URL origin
|
|
70
|
+
status = client.status(hailer_message_id)
|
|
71
|
+
page = client.poll(after: saved_cursor, limit: 100)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Status distinguishes the original RFC Message-ID, Hailer ID and provider ID,
|
|
75
|
+
and never returns MIME. `status.attempts` and `status.recipient_outcomes` contain
|
|
76
|
+
the latest 100 records each, in chronological order. Their corresponding
|
|
77
|
+
`*_truncated` flags report older records; use the lifecycle feed for complete
|
|
78
|
+
observations. An accepted message can still have an uncertain historical
|
|
79
|
+
attempt after an operator-authorized retry. Lifecycle events have their own cursor space; do not
|
|
80
|
+
reuse an existing `FeedbackClient` cursor. Persist `page.next_cursor` only after
|
|
81
|
+
processing the page successfully. Re-reading a cursor does not delete events.
|
|
82
|
+
|
|
83
|
+
The service's `[queued]` configuration requires explicit `max_bytes` and
|
|
84
|
+
`max_messages`. Bytes measure canonical logical command payload, not PostgreSQL
|
|
85
|
+
table/index/WAL/backups or a physical disk quota. All replicas of a deployment
|
|
86
|
+
must use the same budgets. Admission is transactional; a replay does not consume
|
|
87
|
+
another slot, and accepted messages are never evicted to admit new ones.
|
|
88
|
+
|
|
89
|
+
## Acceptance is not delivery
|
|
90
|
+
|
|
91
|
+
HTTP 202 means the service durably owns the message. The adapter exposes its ID
|
|
92
|
+
through `message.header[:hailer_message_id]` before Rails observers run. It does
|
|
93
|
+
not fabricate `ses_message_id`; provider acceptance and recipient delivery
|
|
94
|
+
are later observations.
|
|
95
|
+
|
|
96
|
+
Application observers must distinguish durable ownership from provider
|
|
97
|
+
acceptance: persist the Hailer ID and an owned/queued state, then consume
|
|
98
|
+
lifecycle facts separately. CommonShare's existing observer still writes
|
|
99
|
+
`status: "sent"` and only the SES ID for every delivered callback. The isolated
|
|
100
|
+
queued smoke verifies transport compatibility, not that observer's readiness
|
|
101
|
+
for production opt-in. Keep its synchronous configuration until this application
|
|
102
|
+
cutover work is done.
|
|
103
|
+
|
|
104
|
+
Unknown provider outcomes are held without an automatic resend. Seven-day
|
|
105
|
+
unknown-body retention (measured from durable intake) does not extend the
|
|
106
|
+
24-hour send-start deadline. Body purge retains receipts and uncertainty.
|
|
107
|
+
Only positive evidence of no acceptance permits automatic retry, with jittered
|
|
108
|
+
backoff capped at 600 seconds and at most 255 automatic attempts per intent.
|
|
109
|
+
Expiry stops new attempts; it never proves a previous attempt was unsent.
|
|
110
|
+
|
|
111
|
+
Live bodies are purged after confirmed acceptance, terminal rejection or
|
|
112
|
+
suppression, cancellation, or safe queued expiry. Purge releases logical
|
|
113
|
+
capacity, not PostgreSQL allocated disk, backups or copies already in-flight.
|
|
114
|
+
|
|
115
|
+
## Feedback and private routing
|
|
116
|
+
|
|
117
|
+
Set root `ses_configuration_set = "hailer-feedback"` in Hailer's TOML, or use
|
|
118
|
+
an equivalent default configuration set on the SES sending identity. Configure
|
|
119
|
+
SES event publishing through the dedicated SNS/SQS feedback route, preserving
|
|
120
|
+
`mail.tags.hailer_message_id` and `mail.tags.hailer_attempt_id`. These unique tags
|
|
121
|
+
allow feedback to resolve an unknown SDK response. A provider ID already
|
|
122
|
+
captured from an accepted response also correlates later untagged feedback.
|
|
123
|
+
Without tags and without a captured provider ID, feedback cannot resolve
|
|
124
|
+
uncertainty. Hailer never guesses from recipient, timestamps or RFC Message-ID.
|
|
125
|
+
Feedback received before an untagged provider ID is captured is not retroactively
|
|
126
|
+
correlated; retain the tags for reliable recovery.
|
|
127
|
+
|
|
128
|
+
Mothership must route the new private paths as well as the old synchronous
|
|
129
|
+
ones. A suitable bind pattern is:
|
|
130
|
+
|
|
131
|
+
```toml
|
|
132
|
+
routes = [{ bind = "mail", pattern = "^/internal/mail/(send|events|health|ready|intake-ready|dispatch-ready|messages|messages/events|messages/[0-9a-f-]{36})$" }]
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Keep this bind private. Intake readiness depends on durable writable capacity,
|
|
136
|
+
not SES health. `/internal/mail/dispatch-ready` separately reports worker,
|
|
137
|
+
bootstrap and control eligibility. Neither readiness response guarantees that
|
|
138
|
+
a later send will succeed.
|
|
139
|
+
|
|
140
|
+
## Operator control
|
|
141
|
+
|
|
142
|
+
Run against the intended database using the same `HAILER_DATABASE_URL`:
|
|
143
|
+
|
|
144
|
+
```sh
|
|
145
|
+
hailer message-status --id MESSAGE_UUID
|
|
146
|
+
hailer cancel-message --id MESSAGE_UUID --reason "intent withdrawn"
|
|
147
|
+
hailer resolve-message --id MESSAGE_UUID --outcome accepted \
|
|
148
|
+
--provider-message-id VERIFIED_SES_ID --reason "verified provider evidence"
|
|
149
|
+
hailer resolve-message --id MESSAGE_UUID --outcome not-accepted \
|
|
150
|
+
--reason "verified no provider acceptance"
|
|
151
|
+
hailer resolve-message --id MESSAGE_UUID --outcome retry \
|
|
152
|
+
--acknowledge-duplicate-risk --reason "investigated; accept duplicate risk"
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Cancellation is only possible before dispatch and cannot unsend mail.
|
|
156
|
+
Resolution defaults to the current held-unknown attempt. Use `--attempt-id
|
|
157
|
+
ATTEMPT_UUID` to resolve an older unknown journal entry without overwriting a
|
|
158
|
+
later attempt. Retry is allowed only for the current held intent, with an
|
|
159
|
+
available body, an unexpired deadline, no acceptance latch and explicit
|
|
160
|
+
duplicate-risk acknowledgement. A new attempt never erases the old uncertainty.
|
|
161
|
+
All decisions require an audit reason. After expiry or body purge, sending
|
|
162
|
+
requires a new intent/key.
|
|
163
|
+
|
|
164
|
+
## Quarantine
|
|
165
|
+
|
|
166
|
+
Opt-in, queued pipeline only - a synchronous `:hailer` send has no way to
|
|
167
|
+
hold a message for later review, so a non-ASCII domain or a newly
|
|
168
|
+
registered one makes the synchronous adapter reject outright. The queued
|
|
169
|
+
adapter holds instead:
|
|
170
|
+
|
|
171
|
+
```toml
|
|
172
|
+
[domain_intel]
|
|
173
|
+
provider = "rdap"
|
|
174
|
+
new_domain_threshold_days = 30
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
A recipient domain that is not plain ASCII (a malformed/unencoded IDN, or a
|
|
178
|
+
homograph-spoofing attempt - legitimate IDNs are transmitted as ASCII
|
|
179
|
+
punycode) is always checked, regardless of this block. `[domain_intel]`
|
|
180
|
+
additionally checks the domain's RDAP registration date; newer than the
|
|
181
|
+
threshold holds the message as `quarantined` instead of admitting it as
|
|
182
|
+
`queued`. The whole message is quarantined, not just the flagged recipient -
|
|
183
|
+
this is an operator review gate on intent, not a per-recipient filter like
|
|
184
|
+
suppression or the domain blocklist (`hailer ban-domain`). RDAP's bootstrap
|
|
185
|
+
registry is public; no credential is needed.
|
|
186
|
+
|
|
187
|
+
```sh
|
|
188
|
+
hailer release-quarantine --id MESSAGE_UUID --reason "reviewed, legitimate"
|
|
189
|
+
hailer cancel-message --id MESSAGE_UUID --reason "rejected on review"
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Releasing resets `next_attempt_at` to now, so the message re-enters the
|
|
193
|
+
normal dispatch queue immediately. `cancel-message` also accepts a
|
|
194
|
+
quarantined message. An unactioned quarantine still expires on the same
|
|
195
|
+
deadline as a normal `queued` message.
|
|
196
|
+
|
|
197
|
+
## Local application verification
|
|
198
|
+
|
|
199
|
+
`test/commonshare_smoke.rb` runs inside CommonShare's test environment with a
|
|
200
|
+
real Mothership bay and loopback SES, never real AWS credentials. Use a fresh
|
|
201
|
+
loopback PostgreSQL database named `hailer_smoke_*` for each queued run.
|
|
202
|
+
The script intentionally reuses one business key to check replay after purge.
|
|
203
|
+
|
|
204
|
+
```sh
|
|
205
|
+
RAILS_ENV=test DISABLE_BOOTSNAP=1 \
|
|
206
|
+
HAILER_SMOKE_QUEUED=1 \
|
|
207
|
+
HAILER_SMOKE_BINARY=/absolute/path/to/current/hailer \
|
|
208
|
+
HAILER_DATABASE_URL=postgres://...@127.0.0.1:5432/hailer_smoke_queued \
|
|
209
|
+
bundle exec rails runner /absolute/path/to/hailer/test/commonshare_smoke.rb
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
An isolated temporary `BUNDLE_GEMFILE` can select the working-tree gem or locally
|
|
213
|
+
installed verification gem without editing CommonShare's Gemfile or lock.
|
|
214
|
+
Set `HAILER_SMOKE_EXPECTED_PLATFORM=arm64-darwin` to assert native-gem selection.
|
|
215
|
+
Omit `HAILER_SMOKE_QUEUED` for the unchanged synchronous contract. The test holds
|
|
216
|
+
the provider response until Rails has its receipt, then checks the journal,
|
|
217
|
+
correlation tags, envelope/attachment, body purge, replay, conflict and feeds.
|
data/hailer.example.toml
CHANGED
|
@@ -1,8 +1,49 @@
|
|
|
1
|
-
#
|
|
2
|
-
state_path = "/var/lib/hailer/state.redb"
|
|
1
|
+
# Set HAILER_DATABASE_URL separately; do not commit credentials.
|
|
3
2
|
threads = 2
|
|
3
|
+
database_pool_size = 5
|
|
4
|
+
shutdown_timeout_seconds = 30
|
|
5
|
+
|
|
6
|
+
# Optional SES event-publishing configuration set. Its events must include mail
|
|
7
|
+
# tags and reach the dedicated SNS/SQS feedback route to resolve unknown sends.
|
|
8
|
+
# Otherwise configure an appropriate default set on the sending identity.
|
|
9
|
+
# ses_configuration_set = "hailer-feedback"
|
|
10
|
+
|
|
11
|
+
# Opt in: initial AWS account/region sync gates send readiness.
|
|
12
|
+
[suppression_sync]
|
|
13
|
+
interval_seconds = 3600
|
|
14
|
+
|
|
15
|
+
# Optional PER-PROCESS budget, not an account-wide SES quota.
|
|
16
|
+
# [rate_limit]
|
|
17
|
+
# recipients_per_second = 10.0
|
|
18
|
+
# burst = 50
|
|
19
|
+
|
|
20
|
+
# In development: durable intake requires explicit logical payload budgets.
|
|
21
|
+
# Requires the durable-dispatch build, not the existing 0.2 release candidates.
|
|
22
|
+
# [queued]
|
|
23
|
+
# max_bytes = 268435456
|
|
24
|
+
# max_messages = 10000
|
|
25
|
+
# unknown_retention_seconds = 604800
|
|
4
26
|
|
|
5
27
|
[feedback]
|
|
6
28
|
# Dedicated queue; subscribe the SNS topic with RawMessageDelivery=false.
|
|
7
29
|
queue_url = "https://sqs.us-east-1.amazonaws.com/123456789012/hailer-feedback"
|
|
8
30
|
topic_arn = "arn:aws:sns:us-east-1:123456789012:mail-feedback"
|
|
31
|
+
|
|
32
|
+
# Opt in: pre-send deliverability check (Emailable today; the provider is a
|
|
33
|
+
# swappable enum, not hardcoded). Set EMAILABLE_API_KEY separately - never
|
|
34
|
+
# here. Verdicts are cached (hailer.address_verifications) so a retry of the
|
|
35
|
+
# same address does not pay for a second lookup.
|
|
36
|
+
# [email_verification]
|
|
37
|
+
# provider = "emailable"
|
|
38
|
+
# reject_states = ["undeliverable"] # risky/unknown fail open by default
|
|
39
|
+
# cache_ttl_seconds = 604800
|
|
40
|
+
# timeout_seconds = 5
|
|
41
|
+
|
|
42
|
+
# Opt in, queued pipeline only: hold a message for operator review
|
|
43
|
+
# (hailer release-quarantine / cancel-message) instead of a hard reject.
|
|
44
|
+
# A non-ASCII recipient domain is always checked regardless of this block;
|
|
45
|
+
# this only adds the RDAP new-domain check. No credential needed - RDAP's
|
|
46
|
+
# bootstrap registry is public.
|
|
47
|
+
# [domain_intel]
|
|
48
|
+
# provider = "rdap"
|
|
49
|
+
# new_domain_threshold_days = 30
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support'
|
|
4
|
+
require 'active_support/current_attributes'
|
|
5
|
+
|
|
6
|
+
module Hailer
|
|
7
|
+
# Scoped by the public ActiveJob callback, restored even on exceptions and
|
|
8
|
+
# nested jobs. No submission key is injected into the rendered MIME.
|
|
9
|
+
class DeliveryContext < ActiveSupport::CurrentAttributes
|
|
10
|
+
attribute :job_id, :ordinal
|
|
11
|
+
|
|
12
|
+
def next_submission_key
|
|
13
|
+
return unless job_id
|
|
14
|
+
|
|
15
|
+
self.ordinal = ordinal.to_i + 1
|
|
16
|
+
"hailer:job:#{job_id}:#{ordinal}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -11,6 +11,14 @@ module Hailer
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def deliver!(message)
|
|
14
|
+
Client.new(**settings).deliver(payload_for(message)).tap do |response|
|
|
15
|
+
message.header[:ses_message_id] = response.message_id
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def payload_for(message)
|
|
14
22
|
payload = {
|
|
15
23
|
raw_mime: message.to_s,
|
|
16
24
|
to_addresses: Array(explicit_envelope?(message, :to) ? message.smtp_envelope_to : message.to),
|
|
@@ -23,13 +31,9 @@ module Hailer
|
|
|
23
31
|
payload[:from_email_address] = message.smtp_envelope_from
|
|
24
32
|
end
|
|
25
33
|
|
|
26
|
-
|
|
27
|
-
message.header[:ses_message_id] = response.message_id
|
|
28
|
-
end
|
|
34
|
+
payload
|
|
29
35
|
end
|
|
30
36
|
|
|
31
|
-
private
|
|
32
|
-
|
|
33
37
|
# Match the existing SESv2 adapter's distinction between Mail's computed
|
|
34
38
|
# defaults and explicitly assigned SMTP envelope addresses.
|
|
35
39
|
def explicit_envelope?(message, field)
|
data/lib/hailer/errors.rb
CHANGED
|
@@ -19,6 +19,10 @@ module Hailer
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
class ValidationError < DeliveryError; end
|
|
22
|
+
class SubmissionConflictError < DeliveryError; end
|
|
22
23
|
class RejectedError < DeliveryError; end
|
|
23
24
|
class UnavailableError < DeliveryError; end
|
|
25
|
+
class RateLimitedError < DeliveryError; end
|
|
26
|
+
# SES might have accepted the message. Automatic retries can duplicate it.
|
|
27
|
+
class AmbiguousDeliveryError < DeliveryError; end
|
|
24
28
|
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module Hailer
|
|
6
|
+
MessageStatus = Data.define(
|
|
7
|
+
:hailer_message_id, :state, :version, :body_present, :provider_message_id,
|
|
8
|
+
:rfc_message_id, :accepted_at, :expires_at, :attempts, :recipient_outcomes,
|
|
9
|
+
:attempts_truncated, :recipient_outcomes_truncated
|
|
10
|
+
)
|
|
11
|
+
MessageEvent = Data.define(:sequence, :hailer_message_id, :kind, :occurred_at, :details)
|
|
12
|
+
MessageEventPage = Data.define(:events, :next_cursor)
|
|
13
|
+
|
|
14
|
+
# Deliberately separate from the unchanged provider-feedback client/feed.
|
|
15
|
+
class LifecycleClient
|
|
16
|
+
ID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
|
|
17
|
+
STATES = %w[queued claimed dispatching retry_waiting held_unknown accepted rejected suppressed cancelled expired].freeze
|
|
18
|
+
MAX_RESPONSE_BYTES = 2 * 1024 * 1024
|
|
19
|
+
|
|
20
|
+
def initialize(**settings)
|
|
21
|
+
@transport = Transport.new(**settings)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def status(hailer_message_id)
|
|
25
|
+
unless hailer_message_id.is_a?(String) && ID.match?(hailer_message_id)
|
|
26
|
+
raise ConfigurationError, 'expected a Hailer message UUID'
|
|
27
|
+
end
|
|
28
|
+
document = @transport.get("/internal/mail/messages/#{hailer_message_id}",
|
|
29
|
+
max_response_bytes: MAX_RESPONSE_BYTES)
|
|
30
|
+
receipt = document['receipt']
|
|
31
|
+
unless receipt.is_a?(Hash) && receipt['hailer_message_id'] == hailer_message_id &&
|
|
32
|
+
STATES.include?(document['status']) &&
|
|
33
|
+
document['version'].is_a?(Integer) && document['version'] >= 0 &&
|
|
34
|
+
[true, false].include?(document['body_present']) &&
|
|
35
|
+
optional_text?(document['provider_message_id']) && optional_text?(document['rfc_message_id'])
|
|
36
|
+
raise ProtocolError, 'invalid Hailer message status'
|
|
37
|
+
end
|
|
38
|
+
accepted_at = timestamp(receipt['accepted_at'])
|
|
39
|
+
expires_at = timestamp(receipt['expires_at'])
|
|
40
|
+
raise ProtocolError, 'invalid Hailer message deadline' unless expires_at > accepted_at
|
|
41
|
+
|
|
42
|
+
MessageStatus.new(
|
|
43
|
+
hailer_message_id: hailer_message_id.dup.freeze, state: document['status'].freeze,
|
|
44
|
+
version: document['version'], body_present: document['body_present'],
|
|
45
|
+
provider_message_id: document['provider_message_id']&.freeze,
|
|
46
|
+
rfc_message_id: document['rfc_message_id']&.freeze,
|
|
47
|
+
accepted_at: accepted_at, expires_at: expires_at,
|
|
48
|
+
attempts: status_records(document, 'attempts'),
|
|
49
|
+
recipient_outcomes: status_records(document, 'recipient_outcomes'),
|
|
50
|
+
attempts_truncated: truncation_flag(document, 'attempts_truncated'),
|
|
51
|
+
recipient_outcomes_truncated: truncation_flag(document, 'recipient_outcomes_truncated')
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def poll(after:, limit: 100)
|
|
56
|
+
unless after.is_a?(Integer) && (0..9_223_372_036_854_775_807).cover?(after) &&
|
|
57
|
+
limit.is_a?(Integer) && (1..100).cover?(limit)
|
|
58
|
+
raise ConfigurationError, 'lifecycle requires a supported nonnegative cursor and limit in 1..100'
|
|
59
|
+
end
|
|
60
|
+
document = @transport.post('/internal/mail/messages/events', { after: after, limit: limit },
|
|
61
|
+
max_response_bytes: MAX_RESPONSE_BYTES)
|
|
62
|
+
entries = document['events']
|
|
63
|
+
unless entries.is_a?(Array) && entries.size <= limit && document['next_cursor'].is_a?(Integer)
|
|
64
|
+
raise ProtocolError, 'invalid Hailer lifecycle page'
|
|
65
|
+
end
|
|
66
|
+
previous = after
|
|
67
|
+
events = entries.map do |entry|
|
|
68
|
+
unless entry.is_a?(Hash) && entry['sequence'].is_a?(Integer) && entry['sequence'] > previous
|
|
69
|
+
raise ProtocolError, 'lifecycle sequences must increase past the requested cursor'
|
|
70
|
+
end
|
|
71
|
+
event = entry['event']
|
|
72
|
+
unless event.is_a?(Hash) && event['hailer_message_id'].is_a?(String) &&
|
|
73
|
+
ID.match?(event['hailer_message_id']) && text?(event['kind']) && event['details'].is_a?(Hash)
|
|
74
|
+
raise ProtocolError, 'invalid Hailer lifecycle event'
|
|
75
|
+
end
|
|
76
|
+
previous = entry['sequence']
|
|
77
|
+
MessageEvent.new(
|
|
78
|
+
sequence: previous, hailer_message_id: event['hailer_message_id'].freeze,
|
|
79
|
+
kind: event['kind'].freeze, occurred_at: timestamp(event['occurred_at']),
|
|
80
|
+
details: freeze_json(event['details'])
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
raise ProtocolError, 'lifecycle cursor does not match its page' unless document['next_cursor'] == previous
|
|
84
|
+
|
|
85
|
+
MessageEventPage.new(events: events.freeze, next_cursor: previous)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def text?(value) = value.is_a?(String) && !value.strip.empty?
|
|
91
|
+
def optional_text?(value) = value.nil? || text?(value)
|
|
92
|
+
|
|
93
|
+
def status_records(document, name)
|
|
94
|
+
records = document.fetch(name, [])
|
|
95
|
+
unless records.is_a?(Array) && records.size <= 100 && records.all? { |record| record.is_a?(Hash) }
|
|
96
|
+
raise ProtocolError, 'invalid Hailer status observations'
|
|
97
|
+
end
|
|
98
|
+
freeze_json(records)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def truncation_flag(document, name)
|
|
102
|
+
flag = document.fetch(name, false)
|
|
103
|
+
raise ProtocolError, 'invalid Hailer status truncation flag' unless [true, false].include?(flag)
|
|
104
|
+
|
|
105
|
+
flag
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def timestamp(value)
|
|
109
|
+
raise ProtocolError, 'invalid Hailer lifecycle timestamp' unless text?(value)
|
|
110
|
+
|
|
111
|
+
Time.iso8601(value).freeze
|
|
112
|
+
rescue ArgumentError
|
|
113
|
+
raise ProtocolError, 'invalid Hailer lifecycle timestamp'
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def freeze_json(value)
|
|
117
|
+
case value
|
|
118
|
+
when Hash then value.each_value { |child| freeze_json(child) }
|
|
119
|
+
when Array then value.each { |child| freeze_json(child) }
|
|
120
|
+
end
|
|
121
|
+
value.freeze
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hailer
|
|
4
|
+
# Custom application delivery jobs can include this public callback module.
|
|
5
|
+
module DeliveryJobContext
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
around_perform do |job, perform|
|
|
10
|
+
DeliveryContext.set(job_id: job.job_id, ordinal: 0) { perform.call }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class MailDeliveryJob < ActionMailer::MailDeliveryJob
|
|
16
|
+
include DeliveryJobContext
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hailer
|
|
4
|
+
Acceptance = Data.define(:hailer_message_id)
|
|
5
|
+
|
|
6
|
+
# Acceptance means PostgreSQL owns the message, not that SES accepted it.
|
|
7
|
+
class QueuedClient
|
|
8
|
+
PATH = '/internal/mail/messages'
|
|
9
|
+
|
|
10
|
+
def initialize(intake_attempts: 3, **settings)
|
|
11
|
+
unless intake_attempts.is_a?(Integer) && (1..3).cover?(intake_attempts)
|
|
12
|
+
raise ConfigurationError, 'intake_attempts must be in 1..3'
|
|
13
|
+
end
|
|
14
|
+
@intake_attempts = intake_attempts
|
|
15
|
+
@transport = Transport.new(**settings)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def submit(payload, submission_key:, expires_at: nil)
|
|
19
|
+
unless submission_key.is_a?(String) && !submission_key.strip.empty? && submission_key.bytesize <= 256
|
|
20
|
+
raise ConfigurationError, 'submission_key must be nonempty text of at most 256 bytes'
|
|
21
|
+
end
|
|
22
|
+
unless payload.is_a?(Hash) && payload.keys.none? { |key| %w[submission_key expires_at].include?(key.to_s) }
|
|
23
|
+
raise ConfigurationError, 'pass submission_key and expires_at separately from the mail payload'
|
|
24
|
+
end
|
|
25
|
+
command = payload.merge(submission_key: submission_key)
|
|
26
|
+
command[:expires_at] = expires_at unless expires_at.nil?
|
|
27
|
+
# Freeze serialization before the first request: retries must not re-render
|
|
28
|
+
# MIME, regenerate keys, extend deadlines or observe caller mutations.
|
|
29
|
+
body = JSON.generate(command).freeze
|
|
30
|
+
document = submit_frozen(body)
|
|
31
|
+
id = document['hailer_message_id']
|
|
32
|
+
unless id.is_a?(String) && !id.strip.empty?
|
|
33
|
+
raise ProtocolError, 'Hailer accepted the request without a hailer_message_id'
|
|
34
|
+
end
|
|
35
|
+
Acceptance.new(hailer_message_id: id.freeze)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def submit_frozen(body)
|
|
41
|
+
attempts = 0
|
|
42
|
+
begin
|
|
43
|
+
attempts += 1
|
|
44
|
+
@transport.post_serialized(PATH, body, expected_status: 202)
|
|
45
|
+
rescue TransportError, UnavailableError, RateLimitedError
|
|
46
|
+
raise if attempts >= @intake_attempts
|
|
47
|
+
|
|
48
|
+
sleep(0.05 * (2**(attempts - 1)))
|
|
49
|
+
retry
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Hailer
|
|
6
|
+
class QueuedDeliveryMethod < DeliveryMethod
|
|
7
|
+
def deliver!(message)
|
|
8
|
+
job_key = DeliveryContext.next_submission_key
|
|
9
|
+
key = settings.fetch(:submission_key) { job_key || SecureRandom.uuid }
|
|
10
|
+
options = settings.except(:submission_key, :expires_at)
|
|
11
|
+
QueuedClient.new(**options).submit(
|
|
12
|
+
payload_for(message), submission_key: key, expires_at: settings[:expires_at]
|
|
13
|
+
).tap do |receipt|
|
|
14
|
+
message.header[:hailer_message_id] = receipt.hailer_message_id
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/hailer/railtie.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'action_mailer/railtie'
|
|
4
|
+
require_relative 'mail_delivery_job'
|
|
4
5
|
|
|
5
6
|
module Hailer
|
|
6
7
|
class Railtie < ::Rails::Railtie
|
|
@@ -10,9 +11,13 @@ module Hailer
|
|
|
10
11
|
config.hailer = ActiveSupport::OrderedOptions.new
|
|
11
12
|
config.hailer.feedback_handler = nil
|
|
12
13
|
|
|
13
|
-
initializer 'hailer.action_mailer', before: 'action_mailer.set_configs' do
|
|
14
|
+
initializer 'hailer.action_mailer', before: 'action_mailer.set_configs' do |app|
|
|
15
|
+
if app.config.action_mailer.delivery_method == :hailer_queued
|
|
16
|
+
app.config.action_mailer.delivery_job ||= 'Hailer::MailDeliveryJob'
|
|
17
|
+
end
|
|
14
18
|
ActiveSupport.on_load(:action_mailer) do
|
|
15
19
|
add_delivery_method :hailer, Hailer::DeliveryMethod
|
|
20
|
+
add_delivery_method :hailer_queued, Hailer::QueuedDeliveryMethod
|
|
16
21
|
end
|
|
17
22
|
end
|
|
18
23
|
end
|
data/lib/hailer/transport.rb
CHANGED
|
@@ -10,7 +10,7 @@ module Hailer
|
|
|
10
10
|
DEFAULTS = { open_timeout: 5, read_timeout: 30, write_timeout: 30 }.freeze
|
|
11
11
|
MAX_RESPONSE_BYTES = 65_536
|
|
12
12
|
HTTP_ERRORS = {
|
|
13
|
-
400 => ValidationError, 422 => ValidationError,
|
|
13
|
+
400 => ValidationError, 409 => SubmissionConflictError, 422 => ValidationError, 429 => RateLimitedError,
|
|
14
14
|
502 => RejectedError, 503 => UnavailableError
|
|
15
15
|
}.freeze
|
|
16
16
|
|
|
@@ -32,23 +32,37 @@ module Hailer
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def post(path, payload, max_response_bytes: MAX_RESPONSE_BYTES)
|
|
35
|
+
post_serialized(path, JSON.generate(payload), max_response_bytes: max_response_bytes)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Queued intake owns retries and passes the same serialized bytes each time.
|
|
39
|
+
# Synchronous sends continue to require HTTP 200 and never retry.
|
|
40
|
+
def post_serialized(path, body, expected_status: 200, max_response_bytes: MAX_RESPONSE_BYTES)
|
|
35
41
|
request = Net::HTTP::Post.new(path)
|
|
36
42
|
request.content_type = 'application/json'
|
|
43
|
+
request.body = body
|
|
44
|
+
exchange_document(request, expected_status, max_response_bytes)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def get(path, max_response_bytes: MAX_RESPONSE_BYTES)
|
|
48
|
+
exchange_document(Net::HTTP::Get.new(path), 200, max_response_bytes)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def exchange_document(request, expected_status, max_response_bytes)
|
|
37
54
|
request['Accept'] = 'application/json'
|
|
38
|
-
request.body = JSON.generate(payload)
|
|
39
55
|
http = Net::HTTP.new(@uri.hostname, @uri.port, nil)
|
|
40
56
|
http.use_ssl = @uri.scheme == 'https'
|
|
41
57
|
http.max_retries = 0
|
|
42
58
|
@timeouts.each { |name, value| http.public_send(:"#{name}=", value) }
|
|
43
59
|
status, body = exchange(http, request, max_response_bytes)
|
|
44
|
-
decode_response(status, body)
|
|
60
|
+
decode_response(status, body, expected_status)
|
|
45
61
|
rescue Timeout::Error, IOError, EOFError, SystemCallError, SocketError,
|
|
46
62
|
OpenSSL::SSL::SSLError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError => error
|
|
47
63
|
raise TransportError, "Hailer transport failed (#{error.class}); delivery outcome may be unknown"
|
|
48
64
|
end
|
|
49
65
|
|
|
50
|
-
private
|
|
51
|
-
|
|
52
66
|
def exchange(http, request, limit)
|
|
53
67
|
status = nil
|
|
54
68
|
body = +''
|
|
@@ -65,13 +79,16 @@ module Hailer
|
|
|
65
79
|
[status, body]
|
|
66
80
|
end
|
|
67
81
|
|
|
68
|
-
def decode_response(status, body)
|
|
82
|
+
def decode_response(status, body, expected_status)
|
|
69
83
|
document = JSON.parse(body)
|
|
70
84
|
raise ProtocolError, 'Hailer response must be a JSON object' unless document.is_a?(Hash)
|
|
71
85
|
|
|
72
|
-
unless status ==
|
|
86
|
+
unless status == expected_status
|
|
73
87
|
message = document['error']
|
|
74
88
|
message = "Hailer returned HTTP #{status}" unless message.is_a?(String) && !message.empty?
|
|
89
|
+
if status == 503 && document['code'] == 'outcome_unknown'
|
|
90
|
+
raise AmbiguousDeliveryError.new(message, status: status)
|
|
91
|
+
end
|
|
75
92
|
raise HTTP_ERRORS.fetch(status, DeliveryError).new(message, status: status)
|
|
76
93
|
end
|
|
77
94
|
document
|
data/lib/hailer/version.rb
CHANGED
data/lib/hailer.rb
CHANGED
|
@@ -5,6 +5,10 @@ require_relative 'hailer/errors'
|
|
|
5
5
|
require_relative 'hailer/transport'
|
|
6
6
|
require_relative 'hailer/client'
|
|
7
7
|
require_relative 'hailer/delivery_method'
|
|
8
|
+
require_relative 'hailer/queued_client'
|
|
9
|
+
require_relative 'hailer/delivery_context'
|
|
10
|
+
require_relative 'hailer/queued_delivery_method'
|
|
11
|
+
require_relative 'hailer/lifecycle_client'
|
|
8
12
|
require_relative 'hailer/feedback_client'
|
|
9
13
|
require_relative 'hailer/feedback_consumer'
|
|
10
14
|
require_relative 'hailer/railtie'
|
|
Binary file
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
2
|
+
"version": "0.3.0",
|
|
3
3
|
"platform": "aarch64-linux-gnu",
|
|
4
4
|
"target": "aarch64-unknown-linux-gnu",
|
|
5
|
-
"sha256": "
|
|
5
|
+
"sha256": "28854de9ed5989b7a7fdac0656fee71457b80ca645908fe884da301fcf30295f"
|
|
6
6
|
}
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hailer
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: aarch64-linux-gnu
|
|
6
6
|
authors:
|
|
7
7
|
- Abdelkader Boudih
|
|
@@ -149,15 +149,21 @@ files:
|
|
|
149
149
|
- CHANGELOG.md
|
|
150
150
|
- LICENSE.txt
|
|
151
151
|
- README.md
|
|
152
|
+
- docs/durable-delivery.md
|
|
152
153
|
- exe/hailer
|
|
153
154
|
- hailer.example.toml
|
|
154
155
|
- lib/hailer.rb
|
|
155
156
|
- lib/hailer/binary.rb
|
|
156
157
|
- lib/hailer/client.rb
|
|
158
|
+
- lib/hailer/delivery_context.rb
|
|
157
159
|
- lib/hailer/delivery_method.rb
|
|
158
160
|
- lib/hailer/errors.rb
|
|
159
161
|
- lib/hailer/feedback_client.rb
|
|
160
162
|
- lib/hailer/feedback_consumer.rb
|
|
163
|
+
- lib/hailer/lifecycle_client.rb
|
|
164
|
+
- lib/hailer/mail_delivery_job.rb
|
|
165
|
+
- lib/hailer/queued_client.rb
|
|
166
|
+
- lib/hailer/queued_delivery_method.rb
|
|
161
167
|
- lib/hailer/railtie.rb
|
|
162
168
|
- lib/hailer/transport.rb
|
|
163
169
|
- lib/hailer/version.rb
|