hailer 0.5.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 00f5d9ebd2dd587d6f2824487642a03c4d9bef5c6047d3f898975199b6c89745
4
- data.tar.gz: 6580ca42145825de17fa3096eabf5b13d76f863e62a27a6ed86dfe56c4e0e6a7
3
+ metadata.gz: c8cd926b627c51b8e8c96b95ad79bb68b8fbba9c4ca26ab466f03f41283cc35b
4
+ data.tar.gz: 7a4344f35eccd875fe2110aa10a35b0767d42c6acf025260c6813c732e644cb8
5
5
  SHA512:
6
- metadata.gz: 3c50b744278952684d7b5984eab32a927885c591202be50fd734a44adca9661a2119573888fd954d91a5174294616aa8b40aa4207332864c4c9e26baa0968314
7
- data.tar.gz: 73455d8ee3c97a0e948c6b9dab75a5f0ea2efdff910417c60f7996d84c197c4c2adc1ffd0ef51de21b3512c55aeab24c00772290f2c244f74e9280b9d191b406
6
+ metadata.gz: 5b9aeebb39e331fb930cfcce8a7063d28a33fedf2314881bab62e469b4674286e807bffdd14b532d1db47199fcf1eb55f2b3cd0612cb1fcbbea2028430faf055
7
+ data.tar.gz: 7622c206fdfccefbee24a7c05952500c8e83a4fca3d0a84bbe05da2f3ebdf9eec4db1a37d6398f04639ed240c023ca2442f88295d83bd11f6ea41dd537a7860d
data/README.md CHANGED
@@ -9,23 +9,38 @@ This repository is two things:
9
9
  - **`hailer` crate** — a standalone Rust mail ship, supervised by Mothership.
10
10
  SESv2 is the first provider.
11
11
 
12
- ```text
13
- ActionMailer → hailer gem → private Mothership route → hailer binary → SES
12
+ ```mermaid
13
+ flowchart LR
14
+ A[ActionMailer] --> B[hailer gem]
15
+ B --> C[private Mothership route]
16
+ C --> D[hailer binary]
17
+ D --> E[SES]
14
18
  ```
15
19
 
16
20
  An **opt-in durable queued adapter** sits alongside the default synchronous
17
- send path. See [durable delivery](docs/durable-delivery.md) for ownership,
18
- recovery, routing and operator controls.
21
+ send path (`[queued]` in `hailer.example.toml`).
19
22
 
20
- ## Rails
21
-
22
- Requires Ruby 4.0+ and Rails 8.1 or newer within Rails 8.x.
23
- Rails main (8.2 alpha) is also exercised in CommonShare. Older releases are not supported.
23
+ ## Install
24
24
 
25
25
  ```ruby
26
+ gem "guardship"
27
+ gem "shuttlebay"
26
28
  gem "hailer"
27
29
  ```
28
30
 
31
+ Mothership picks up the bay and starts sending mail. Keep using your
32
+ mailers, interceptors, observers, `deliver_now`, and `deliver_later` -
33
+ nothing else about how Rails sends mail changes.
34
+
35
+ Everything past this point is reference material: protocol internals,
36
+ TOML knobs, and operator commands for people running or debugging the
37
+ service, not required reading to use it.
38
+
39
+ ## Rails
40
+
41
+ Requires Ruby 4.0+ and Rails 8.1 or newer within Rails 8.x.
42
+ Rails main (8.2 alpha) is also exercised. Older releases are not supported.
43
+
29
44
  The Railtie registers and selects Hailer automatically. Set the private service
30
45
  URL in the Rails process's environment:
31
46
 
@@ -50,11 +65,9 @@ when omitted from `hailer_settings`. There is no default host or fallback to SES
50
65
  The app can explicitly override the default, for example
51
66
  `config.action_mailer.delivery_method = :test` in its test environment.
52
67
 
53
- Keep using your mailers, interceptors, observers, `deliver_now`, and
54
- `deliver_later`. Rails still owns job scheduling. The adapter sends rendered MIME
55
- and separate envelope recipients, preserving explicit `smtp_envelope_from` and
56
- `smtp_envelope_to` overrides. After SES accepts the message, it sets
57
- `message.header[:ses_message_id]`.
68
+ The adapter sends rendered MIME and separate envelope recipients, preserving
69
+ explicit `smtp_envelope_from` and `smtp_envelope_to` overrides. After SES
70
+ accepts the message, it sets `message.header[:ses_message_id]`.
58
71
 
59
72
  ## Rust service
60
73
 
@@ -120,8 +133,12 @@ shutdown timeout. PostgreSQL 18 is exercised locally and in CI.
120
133
 
121
134
  ## Bounces, complaints, and suppressions
122
135
 
123
- ```text
124
- SES → SNS → dedicated SQS queue → Hailer → durable events + suppressions
136
+ ```mermaid
137
+ flowchart LR
138
+ A[SES] --> B[SNS]
139
+ B --> C[dedicated SQS queue]
140
+ C --> D[Hailer]
141
+ D --> E[durable events + suppressions]
125
142
  ```
126
143
 
127
144
  Hailer atomically commits each event, deduplication key, and suppression change
@@ -133,7 +150,7 @@ are left for SQS's dead-letter policy.
133
150
  - Transient/undetermined bounces are recorded without permanent suppression.
134
151
  - An explicit `not-spam` complaint does not create a new suppression.
135
152
  - Suppression checks cover the actual To/Cc/Bcc envelope, including overrides.
136
- Matching preserves CommonShare's case/plus-alias policy.
153
+ Matching is case-insensitive and ignores `+alias` tags.
137
154
  - Mixed messages keep allowed recipients and their original addresses; MIME
138
155
  is unchanged. If every recipient is suppressed, Hailer returns HTTP 422
139
156
  without calling SES. A suppression lookup failure fails closed with HTTP 503.
@@ -223,11 +240,16 @@ the section disables scheduled sync, not existing suppression enforcement.
223
240
  ### Control plane
224
241
 
225
242
  The behavior circuit evaluates **pure admission decisions**: input validity,
226
- service/storage readiness, suppression, and local rate admission. PostgreSQL,
243
+ service/storage readiness, suppression, and rate admission. PostgreSQL,
227
244
  AWS calls, clocks, and token reservations remain owned by the async host.
228
245
  The SES circuit breaker counts transient/ambiguous failures, not bad addresses
229
246
  or permanent request rejections. An optional `[rate_limit]` budgets envelope
230
- recipients **per process**, not per account; it does not discover SES quotas.
247
+ recipients through a single shared Postgres-backed token bucket - every
248
+ replica draws down the same account-wide budget, not an independent
249
+ per-process one. `discover_from_aws = true` replaces the configured rate
250
+ with the SES account's real `SendQuota.MaxSendRate`, re-checked
251
+ periodically; a rate-state read/write failure fails closed (denies
252
+ admission) rather than risk exceeding the real AWS quota blind.
231
253
 
232
254
  Private `GET /internal/mail/health` returns sanitized component states and
233
255
  counters. `GET /internal/mail/ready` returns 503 during initial sync or storage
@@ -258,8 +280,13 @@ turn off.
258
280
 
259
281
  ## Domain blocklist
260
282
 
261
- ```text
262
- SendRequest -> impossible-address check -> domain blocklist (free, local) -> suppression (free, local) -> verification (paid, external) -> SES
283
+ ```mermaid
284
+ flowchart LR
285
+ A[SendRequest] --> B[impossible-address check]
286
+ B --> C["domain blocklist<br/>(free, local)"]
287
+ C --> D["suppression<br/>(free, local)"]
288
+ D --> E["verification<br/>(paid, external)"]
289
+ E --> F[SES]
263
290
  ```
264
291
 
265
292
  Always active - an empty `hailer.domain_blocks` table is a no-op, there is
@@ -280,8 +307,11 @@ cannot be removed this way, by design.
280
307
 
281
308
  ## Address verification
282
309
 
283
- ```text
284
- SendRequest -> suppression check (cheap, local) -> address verification (paid, external) -> SES
310
+ ```mermaid
311
+ flowchart LR
312
+ A[SendRequest] --> B["suppression check<br/>(cheap, local)"]
313
+ B --> C["address verification<br/>(paid, external)"]
314
+ C --> D[SES]
285
315
  ```
286
316
 
287
317
  Opt-in pre-send deliverability check, off by default. Enable with
@@ -408,19 +438,6 @@ each test creates and drops its own database. Never point this variable at a
408
438
  customer or Rails production database. Neither suite sends real mail or requires
409
439
  AWS credentials.
410
440
 
411
- For the opt-in CommonShare compatibility smoke, create a disposable Hailer
412
- database, then run from that application's checkout:
413
-
414
- ```sh
415
- RAILS_ENV=test HAILER_SMOKE_BINARY=/path/to/hailer/target/debug/hailer \
416
- HAILER_DATABASE_URL=postgres://test_user:password@127.0.0.1/hailer_smoke \
417
- bundle exec rails runner /path/to/hailer/test/commonshare_smoke.rb
418
- ```
419
-
420
- It refuses a non-test app database, points the AWS SDK at a loopback fixture,
421
- and rolls back application-history changes. It does not change the app's
422
- dependency lockfile or send external mail.
423
-
424
441
  `HAILER_BINARY` is an explicit, version-checked source-build override; there is
425
442
  no download or PATH fallback.
426
443
 
data/hailer.example.toml CHANGED
@@ -12,10 +12,16 @@ shutdown_timeout_seconds = 30
12
12
  [suppression_sync]
13
13
  interval_seconds = 3600
14
14
 
15
- # Optional PER-PROCESS budget, not an account-wide SES quota.
15
+ # Optional. Shared across every replica (one Postgres-backed token bucket,
16
+ # not a per-process budget) - a second replica cannot double the admitted rate.
16
17
  # [rate_limit]
17
18
  # recipients_per_second = 10.0
18
19
  # burst = 50
20
+ # Replace recipients_per_second with the SES account's real
21
+ # SendQuota.MaxSendRate, re-checked periodically. recipients_per_second/burst
22
+ # above become just the seed used until the first successful discovery.
23
+ # discover_from_aws = true
24
+ # discovery_interval_seconds = 900
19
25
 
20
26
  # Durable intake requires explicit logical payload budgets.
21
27
  # [queued]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hailer
4
- VERSION = '0.5.0'
4
+ VERSION = '0.6.0'
5
5
  end
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.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -123,6 +123,20 @@ dependencies:
123
123
  - - "~>"
124
124
  - !ruby/object:Gem::Version
125
125
  version: '6.0'
126
+ - !ruby/object:Gem::Dependency
127
+ name: padded_cell
128
+ requirement: !ruby/object:Gem::Requirement
129
+ requirements:
130
+ - - ">="
131
+ - !ruby/object:Gem::Version
132
+ version: '0'
133
+ type: :development
134
+ prerelease: false
135
+ version_requirements: !ruby/object:Gem::Requirement
136
+ requirements:
137
+ - - ">="
138
+ - !ruby/object:Gem::Version
139
+ version: '0'
126
140
  - !ruby/object:Gem::Dependency
127
141
  name: rake
128
142
  requirement: !ruby/object:Gem::Requirement
@@ -149,7 +163,6 @@ files:
149
163
  - CHANGELOG.md
150
164
  - LICENSE.txt
151
165
  - README.md
152
- - docs/durable-delivery.md
153
166
  - exe/hailer
154
167
  - hailer.example.toml
155
168
  - lib/hailer.rb
@@ -1,214 +0,0 @@
1
- # Durable delivery
2
-
3
- The queued Ruby interface, PostgreSQL spool and supervised dispatcher are
4
- implemented and shipped (0.3.0+). It is opt-in: absent `[queued]` config,
5
- Hailer stays on the synchronous send path and none of this runs.
6
-
7
- ## Opt-in Rails configuration
8
-
9
- Keep `HAILER_URL` pointing to the existing private `/internal/mail/send` endpoint.
10
- The queued client uses the same validated origin with `/internal/mail/messages`.
11
-
12
- ```ruby
13
- # config/environments/production.rb
14
- config.action_mailer.delivery_method = :hailer_queued
15
- ```
16
-
17
- The initializer registers the adapter and selects `Hailer::MailDeliveryJob` for
18
- this opt-in configuration. The default remains the synchronous `:hailer`
19
- adapter, whose HTTP requests never retry.
20
-
21
- For a per-mailer opt-in, select both:
22
-
23
- ```ruby
24
- self.delivery_method = :hailer_queued
25
- self.delivery_job = Hailer::MailDeliveryJob
26
- ```
27
-
28
- An explicitly configured application delivery job is not replaced. It must
29
- inherit `Hailer::MailDeliveryJob` or include `Hailer::DeliveryJobContext` to
30
- obtain stable submission keys across job retries. This uses public ActiveJob
31
- callbacks, not Rails monkey patches.
32
-
33
- ## Identities and retries
34
-
35
- - Standard queued jobs use job ID plus delivery ordinal. Different jobs are
36
- different intents unless the application supplies a business key.
37
- - Direct `deliver_now` calls generate a fresh key per invocation.
38
- - Supply a business key through `delivery_method_options` when separate calls
39
- represent the same intended message:
40
-
41
- ```ruby
42
- mail(
43
- to: recipient,
44
- subject: 'Invoice',
45
- delivery_method_options: { submission_key: "invoice:#{invoice.id}:issued" }
46
- )
47
- ```
48
-
49
- - Supply an optional ISO 8601 `expires_at` through those same options. The
50
- service contract defaults to 24 hours and allows shorter deadlines.
51
- - A queued intake call serializes its command once. Up to three HTTP attempts
52
- reuse those exact bytes, key and deadline after transport loss or retryable
53
- admission errors (429/503). Set `intake_attempts: 1` to disable these retries.
54
- - Validation, submission conflicts, malformed responses and provider-unknown
55
- errors do not retry.
56
- - Rails re-renders mail when a job is performed again. If the bytes or envelope
57
- change under an accepted key, the service must return a conflict; the adapter
58
- raises `Hailer::SubmissionConflictError`. Job keys alone do not provide
59
- transparent exactly-once business delivery.
60
-
61
- Submission keys are command metadata, not MIME headers.
62
-
63
- ## Private status and lifecycle feed
64
-
65
- ```ruby
66
- client = Hailer::LifecycleClient.new # same HAILER_URL origin
67
- status = client.status(hailer_message_id)
68
- page = client.poll(after: saved_cursor, limit: 100)
69
- ```
70
-
71
- Status distinguishes the original RFC Message-ID, Hailer ID and provider ID,
72
- and never returns MIME. `status.attempts` and `status.recipient_outcomes` contain
73
- the latest 100 records each, in chronological order. Their corresponding
74
- `*_truncated` flags report older records; use the lifecycle feed for complete
75
- observations. An accepted message can still have an uncertain historical
76
- attempt after an operator-authorized retry. Lifecycle events have their own cursor space; do not
77
- reuse an existing `FeedbackClient` cursor. Persist `page.next_cursor` only after
78
- processing the page successfully. Re-reading a cursor does not delete events.
79
-
80
- The service's `[queued]` configuration requires explicit `max_bytes` and
81
- `max_messages`. Bytes measure canonical logical command payload, not PostgreSQL
82
- table/index/WAL/backups or a physical disk quota. All replicas of a deployment
83
- must use the same budgets. Admission is transactional; a replay does not consume
84
- another slot, and accepted messages are never evicted to admit new ones.
85
-
86
- ## Acceptance is not delivery
87
-
88
- HTTP 202 means the service durably owns the message. The adapter exposes its ID
89
- through `message.header[:hailer_message_id]` before Rails observers run. It does
90
- not fabricate `ses_message_id`; provider acceptance and recipient delivery
91
- are later observations.
92
-
93
- Application observers must distinguish durable ownership from provider
94
- acceptance: persist the Hailer ID and an owned/queued state, then consume
95
- lifecycle facts separately. CommonShare's existing observer still writes
96
- `status: "sent"` and only the SES ID for every delivered callback. The isolated
97
- queued smoke verifies transport compatibility, not that observer's readiness
98
- for production opt-in. Keep its synchronous configuration until this application
99
- cutover work is done.
100
-
101
- Unknown provider outcomes are held without an automatic resend. Seven-day
102
- unknown-body retention (measured from durable intake) does not extend the
103
- 24-hour send-start deadline. Body purge retains receipts and uncertainty.
104
- Only positive evidence of no acceptance permits automatic retry, with jittered
105
- backoff capped at 600 seconds and at most 255 automatic attempts per intent.
106
- Expiry stops new attempts; it never proves a previous attempt was unsent.
107
-
108
- Live bodies are purged after confirmed acceptance, terminal rejection or
109
- suppression, cancellation, or safe queued expiry. Purge releases logical
110
- capacity, not PostgreSQL allocated disk, backups or copies already in-flight.
111
-
112
- ## Feedback and private routing
113
-
114
- Set root `ses_configuration_set = "hailer-feedback"` in Hailer's TOML, or use
115
- an equivalent default configuration set on the SES sending identity. Configure
116
- SES event publishing through the dedicated SNS/SQS feedback route, preserving
117
- `mail.tags.hailer_message_id` and `mail.tags.hailer_attempt_id`. These unique tags
118
- allow feedback to resolve an unknown SDK response. A provider ID already
119
- captured from an accepted response also correlates later untagged feedback.
120
- Without tags and without a captured provider ID, feedback cannot resolve
121
- uncertainty. Hailer never guesses from recipient, timestamps or RFC Message-ID.
122
- Feedback received before an untagged provider ID is captured is not retroactively
123
- correlated; retain the tags for reliable recovery.
124
-
125
- Mothership must route the new private paths as well as the old synchronous
126
- ones. A suitable bind pattern is:
127
-
128
- ```toml
129
- routes = [{ bind = "mail", pattern = "^/internal/mail/(send|events|health|ready|intake-ready|dispatch-ready|messages|messages/events|messages/[0-9a-f-]{36})$" }]
130
- ```
131
-
132
- Keep this bind private. Intake readiness depends on durable writable capacity,
133
- not SES health. `/internal/mail/dispatch-ready` separately reports worker,
134
- bootstrap and control eligibility. Neither readiness response guarantees that
135
- a later send will succeed.
136
-
137
- ## Operator control
138
-
139
- Run against the intended database using the same `HAILER_DATABASE_URL`:
140
-
141
- ```sh
142
- hailer message-status --id MESSAGE_UUID
143
- hailer cancel-message --id MESSAGE_UUID --reason "intent withdrawn"
144
- hailer resolve-message --id MESSAGE_UUID --outcome accepted \
145
- --provider-message-id VERIFIED_SES_ID --reason "verified provider evidence"
146
- hailer resolve-message --id MESSAGE_UUID --outcome not-accepted \
147
- --reason "verified no provider acceptance"
148
- hailer resolve-message --id MESSAGE_UUID --outcome retry \
149
- --acknowledge-duplicate-risk --reason "investigated; accept duplicate risk"
150
- ```
151
-
152
- Cancellation is only possible before dispatch and cannot unsend mail.
153
- Resolution defaults to the current held-unknown attempt. Use `--attempt-id
154
- ATTEMPT_UUID` to resolve an older unknown journal entry without overwriting a
155
- later attempt. Retry is allowed only for the current held intent, with an
156
- available body, an unexpired deadline, no acceptance latch and explicit
157
- duplicate-risk acknowledgement. A new attempt never erases the old uncertainty.
158
- All decisions require an audit reason. After expiry or body purge, sending
159
- requires a new intent/key.
160
-
161
- ## Quarantine
162
-
163
- Opt-in, queued pipeline only - a synchronous `:hailer` send has no way to
164
- hold a message for later review, so a non-ASCII domain or a newly
165
- registered one makes the synchronous adapter reject outright. The queued
166
- adapter holds instead:
167
-
168
- ```toml
169
- [domain_intel]
170
- provider = "rdap"
171
- new_domain_threshold_days = 30
172
- ```
173
-
174
- A recipient domain that is not plain ASCII (a malformed/unencoded IDN, or a
175
- homograph-spoofing attempt - legitimate IDNs are transmitted as ASCII
176
- punycode) is always checked, regardless of this block. `[domain_intel]`
177
- additionally checks the domain's RDAP registration date; newer than the
178
- threshold holds the message as `quarantined` instead of admitting it as
179
- `queued`. The whole message is quarantined, not just the flagged recipient -
180
- this is an operator review gate on intent, not a per-recipient filter like
181
- suppression or the domain blocklist (`hailer ban-domain`). RDAP's bootstrap
182
- registry is public; no credential is needed.
183
-
184
- ```sh
185
- hailer release-quarantine --id MESSAGE_UUID --reason "reviewed, legitimate"
186
- hailer cancel-message --id MESSAGE_UUID --reason "rejected on review"
187
- ```
188
-
189
- Releasing resets `next_attempt_at` to now, so the message re-enters the
190
- normal dispatch queue immediately. `cancel-message` also accepts a
191
- quarantined message. An unactioned quarantine still expires on the same
192
- deadline as a normal `queued` message.
193
-
194
- ## Local application verification
195
-
196
- `test/commonshare_smoke.rb` runs inside CommonShare's test environment with a
197
- real Mothership bay and loopback SES, never real AWS credentials. Use a fresh
198
- loopback PostgreSQL database named `hailer_smoke_*` for each queued run.
199
- The script intentionally reuses one business key to check replay after purge.
200
-
201
- ```sh
202
- RAILS_ENV=test DISABLE_BOOTSNAP=1 \
203
- HAILER_SMOKE_QUEUED=1 \
204
- HAILER_SMOKE_BINARY=/absolute/path/to/current/hailer \
205
- HAILER_DATABASE_URL=postgres://...@127.0.0.1:5432/hailer_smoke_queued \
206
- bundle exec rails runner /absolute/path/to/hailer/test/commonshare_smoke.rb
207
- ```
208
-
209
- An isolated temporary `BUNDLE_GEMFILE` can select the working-tree gem or locally
210
- installed verification gem without editing CommonShare's Gemfile or lock.
211
- Set `HAILER_SMOKE_EXPECTED_PLATFORM=arm64-darwin` to assert native-gem selection.
212
- Omit `HAILER_SMOKE_QUEUED` for the unchanged synchronous contract. The test holds
213
- the provider response until Rails has its receipt, then checks the journal,
214
- correlation tags, envelope/attachment, body purge, replay, conflict and feeds.