hailer 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +10 -1
- data/README.md +182 -29
- data/hailer.example.toml +12 -2
- data/lib/hailer/errors.rb +3 -0
- data/lib/hailer/transport.rb +4 -1
- data/lib/hailer/version.rb +1 -1
- metadata +1 -3
- data/docs/delivery-contract.md +0 -59
- data/docs/feedback.md +0 -160
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8d815999c494e4d6cce4062c49b80845e092821c2315b37c47da7efe99477576
|
|
4
|
+
data.tar.gz: 293e1791a56d884ab573d9b419bf75a3beb53e60c7569bf6edefd30aa5f796fa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 39e2438a90ee7aec041a4b3f07d627f0ca9e41cb5df94d1c0e3a072cb36950800f3877fb424616acce0030dc5be699a5819bb4d54e0987c4f9d86e1183e08b64
|
|
7
|
+
data.tar.gz: e70a8a022144644593796e1ca377e2a213729e792a1b2c8a263baa293690f7cb5da8ce903b46719adc41e0f4f66fc5e0aa4e488383a69f5cba8b285442a65283
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
3
|
+
## 0.2.0 (unreleased)
|
|
4
|
+
|
|
5
|
+
- PostgreSQL-only live state, with atomic feedback and commit-safe cursors.
|
|
6
|
+
- Resumable, read-only AWS suppression reconciliation scoped by account/region.
|
|
7
|
+
- Pure behavior admission circuit, per-process throttle, and SES circuit breaker.
|
|
8
|
+
- Single-attempt sends, explicit ambiguous outcomes, and typed rate-limit errors.
|
|
9
|
+
- Private health/readiness sensors, supervised workers, and bounded graceful drain.
|
|
10
|
+
- Explicit read-only migration of stopped 0.1 redb state into an empty database.
|
|
11
|
+
|
|
12
|
+
## 0.1.0
|
|
4
13
|
|
|
5
14
|
- Rails 8.1+/Ruby 4.0+ ActionMailer delivery with automatic defaults.
|
|
6
15
|
- 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.
|
|
@@ -78,7 +78,7 @@ name = "hailer"
|
|
|
78
78
|
command = "hailer"
|
|
79
79
|
workers = 0
|
|
80
80
|
threads = 2
|
|
81
|
-
routes = [{ bind = "mail", pattern = "^/internal/mail/send$" }]
|
|
81
|
+
routes = [{ bind = "mail", pattern = "^/internal/mail/(send|events|health|ready)$" }]
|
|
82
82
|
```
|
|
83
83
|
|
|
84
84
|
Mothership exports `MS_PID`, `MS_SHIP`, and `MS_SOCKET_DIR`. Hailer binds its
|
|
@@ -90,6 +90,31 @@ Give the **service**, not the Rails adapter, AWS region and credentials through
|
|
|
90
90
|
the standard AWS credential chain. Its identity needs `ses:SendEmail`, and the
|
|
91
91
|
SES account/identities must be configured for sending.
|
|
92
92
|
|
|
93
|
+
### PostgreSQL
|
|
94
|
+
|
|
95
|
+
PostgreSQL is the **only live backend** in 0.2. Hailer owns a `hailer` schema,
|
|
96
|
+
not Rails tables. Use a separate database/role where practical:
|
|
97
|
+
|
|
98
|
+
```sh
|
|
99
|
+
export HAILER_DATABASE_URL='postgres://hailer:password@db/hailer?sslmode=verify-full'
|
|
100
|
+
hailer migrate
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Supply the database CA through the normal PostgreSQL connection options.
|
|
104
|
+
Use certificate verification in deployment; a private network is not TLS.
|
|
105
|
+
Keep the URL in a secret manager, not TOML or a committed initializer.
|
|
106
|
+
Startup also applies embedded, versioned migrations. The bootstrap identity
|
|
107
|
+
needs database `CREATE` and ownership of its schema/tables. Runtime startup
|
|
108
|
+
currently runs that same bootstrap/migrator, so provision the service role for
|
|
109
|
+
it too; a separate DML-only runtime mode is not implemented.
|
|
110
|
+
The runtime never needs Rails schema ownership. Back up the database with your
|
|
111
|
+
normal PostgreSQL backup/restore tooling.
|
|
112
|
+
|
|
113
|
+
Configuration defaults: pool size 5 (range 1–64), shutdown timeout 30 seconds
|
|
114
|
+
(range 1–300). Every replica shares PostgreSQL; each has its own pool and optional
|
|
115
|
+
rate budget. Set your supervisor/container stop grace longer than Hailer's
|
|
116
|
+
shutdown timeout. PostgreSQL 18 is exercised locally and in CI.
|
|
117
|
+
|
|
93
118
|
## Bounces, complaints, and suppressions
|
|
94
119
|
|
|
95
120
|
```text
|
|
@@ -97,7 +122,7 @@ SES → SNS → dedicated SQS queue → Hailer → durable events + suppressions
|
|
|
97
122
|
```
|
|
98
123
|
|
|
99
124
|
Hailer atomically commits each event, deduplication key, and suppression change
|
|
100
|
-
to
|
|
125
|
+
to PostgreSQL **before** acknowledging SQS. Failed acknowledgements
|
|
101
126
|
can be retried without duplicating stored events. Invalid/untrusted messages
|
|
102
127
|
are left for SQS's dead-letter policy.
|
|
103
128
|
|
|
@@ -113,14 +138,16 @@ are left for SQS's dead-letter policy.
|
|
|
113
138
|
Copy [hailer.example.toml](hailer.example.toml), set the real queue/topic, and
|
|
114
139
|
pass `--config /path/to/hailer.toml` or `HAILER_CONFIG`. Without `[feedback]`,
|
|
115
140
|
the consumer is disabled, but previously stored suppressions are still enforced.
|
|
116
|
-
`
|
|
117
|
-
|
|
141
|
+
`HAILER_DATABASE_URL` is required, even with feedback disabled.
|
|
142
|
+
The old `state_path`, `--state-path`, and `HAILER_STATE_PATH` are not supported.
|
|
143
|
+
Replicas may consume the same dedicated queue when they share the same Hailer
|
|
144
|
+
database. Commits serialize cursor allocation so a reader cannot skip an earlier
|
|
145
|
+
transaction that has not committed yet.
|
|
118
146
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
no automatic retention/pruning.
|
|
147
|
+
Event history and deduplication have **no automatic retention/pruning**.
|
|
148
|
+
Do not delete events casually: event IDs also carry the SQS deduplication
|
|
149
|
+
contract, and Rails consumers may still need their cursors. Plan retention
|
|
150
|
+
against consumer lag and redelivery windows before adding a pruning policy.
|
|
124
151
|
|
|
125
152
|
Hailer also exposes a provider-neutral, nondestructive event feed:
|
|
126
153
|
|
|
@@ -157,7 +184,59 @@ updates in one application database transaction. Retried pages require
|
|
|
157
184
|
idempotent handlers; no hidden poller, scheduling, or checkpoint database is
|
|
158
185
|
started by the gem. Reading does not acknowledge or erase events.
|
|
159
186
|
|
|
160
|
-
|
|
187
|
+
The SQS policy must allow only the intended SNS topic, using `aws:SourceArn`
|
|
188
|
+
and account restrictions. Subscribe with `RawMessageDelivery=false`, restrict
|
|
189
|
+
direct `sqs:SendMessage`, set a dead-letter queue, and monitor its depth.
|
|
190
|
+
The SNS topic allowlist in TOML is defense in depth, not signature verification.
|
|
191
|
+
Hailer needs `sqs:ReceiveMessage` and `sqs:DeleteMessage` on that queue, plus
|
|
192
|
+
applicable KMS decrypt permissions if customer-managed encryption is used.
|
|
193
|
+
|
|
194
|
+
### Import AWS suppressions
|
|
195
|
+
|
|
196
|
+
```sh
|
|
197
|
+
hailer sync-suppressions
|
|
198
|
+
# If AWS expires a saved pagination token:
|
|
199
|
+
hailer sync-suppressions --restart
|
|
200
|
+
# Existing application export, JSON array:
|
|
201
|
+
hailer import-suppressions --file suppressions.json
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The service identity needs `ses:ListSuppressedDestinations`; STS
|
|
205
|
+
`GetCallerIdentity` identifies the account (AWS does not require an explicit
|
|
206
|
+
allow for that operation). The configured region is part of the source identity.
|
|
207
|
+
These commands **read AWS only**; they never mutate the account suppression list.
|
|
208
|
+
Missing AWS entries do not unsuppress local entries, and a bounce never
|
|
209
|
+
downgrades a complaint. Imports do not fabricate delivery-history events.
|
|
210
|
+
Account-level AWS data does not include every possible global SES suppression.
|
|
211
|
+
|
|
212
|
+
Enable `[suppression_sync]` as in the example to reconcile on startup and hourly.
|
|
213
|
+
The first complete sync gates send readiness. Later errors mark health degraded,
|
|
214
|
+
retain committed pages, and retry safe reads with bounded backoff. A token is
|
|
215
|
+
stored with its page in one transaction. Replicas coordinate a source-scoped
|
|
216
|
+
advisory lock; they cannot advance the same checkpoint concurrently.
|
|
217
|
+
An expired token deliberately requires the operator's `--restart`. Omitting
|
|
218
|
+
the section disables scheduled sync, not existing suppression enforcement.
|
|
219
|
+
|
|
220
|
+
### Control plane
|
|
221
|
+
|
|
222
|
+
The behavior circuit evaluates **pure admission decisions**: input validity,
|
|
223
|
+
service/storage readiness, suppression, and local rate admission. PostgreSQL,
|
|
224
|
+
AWS calls, clocks, and token reservations remain owned by the async host.
|
|
225
|
+
The SES circuit breaker counts transient/ambiguous failures, not bad addresses
|
|
226
|
+
or permanent request rejections. An optional `[rate_limit]` budgets envelope
|
|
227
|
+
recipients **per process**, not per account; it does not discover SES quotas.
|
|
228
|
+
|
|
229
|
+
Private `GET /internal/mail/health` returns sanitized component states and
|
|
230
|
+
counters. `GET /internal/mail/ready` returns 503 during initial sync or storage
|
|
231
|
+
failure, otherwise 200. A provider circuit can be degraded while readiness
|
|
232
|
+
remains true so protected half-open probes can recover it. No credentials,
|
|
233
|
+
recipient addresses, message bodies, or pagination tokens appear in these sensors.
|
|
234
|
+
They are operational signals, not a guarantee SES will accept a message.
|
|
235
|
+
|
|
236
|
+
TERM/ctrl-c stops admission and SQS receives, then drains owned requests and
|
|
237
|
+
the current feedback commit/ack within the configured deadline. Idle links
|
|
238
|
+
close immediately. Deadline expiry aborts remaining tasks and exits nonzero;
|
|
239
|
+
an interrupted send can have an unknown outcome.
|
|
161
240
|
|
|
162
241
|
**Security:** the route currently has no application-level authentication.
|
|
163
242
|
`/internal/` is a name, not an access control. Keep the bind private. The client
|
|
@@ -172,6 +251,8 @@ it. A successful response must include a nonblank `message_id`.
|
|
|
172
251
|
| --- | --- |
|
|
173
252
|
| HTTP 400 / 422 | `Hailer::ValidationError` |
|
|
174
253
|
| HTTP 502 | `Hailer::RejectedError` |
|
|
254
|
+
| HTTP 429 | `Hailer::RateLimitedError` |
|
|
255
|
+
| HTTP 503, `code: "outcome_unknown"` | `Hailer::AmbiguousDeliveryError` |
|
|
175
256
|
| HTTP 503 | `Hailer::UnavailableError` |
|
|
176
257
|
| Other non-200 status | `Hailer::DeliveryError` |
|
|
177
258
|
| Invalid/oversized response, missing message ID | `Hailer::ProtocolError` |
|
|
@@ -182,18 +263,35 @@ The gem does not retry. A lost response can happen **after** SES accepted the
|
|
|
182
263
|
message; retrying may duplicate it. There is no durable **outbound** queue or
|
|
183
264
|
send-idempotency store yet; feedback deduplication does not deduplicate sends.
|
|
184
265
|
Rails jobs can also retry, so configure them deliberately.
|
|
185
|
-
The
|
|
186
|
-
|
|
266
|
+
The SES SDK makes **one send attempt**. AWS throttling/transient failures return
|
|
267
|
+
503, permanent service rejections 502, and disconnects/timeouts/missing message
|
|
268
|
+
IDs carry the explicit unknown-outcome code. None of these responses authorizes
|
|
269
|
+
an automatic retry of an ambiguous send.
|
|
187
270
|
|
|
188
271
|
## Status and migration
|
|
189
272
|
|
|
190
273
|
The delivery contract and Hailer-side feedback processing are implemented. CommonShare's
|
|
191
274
|
existing sender is the compatibility reference, not a runtime dependency.
|
|
192
|
-
|
|
275
|
+
|
|
276
|
+
### Upgrade from 0.1 redb
|
|
277
|
+
|
|
278
|
+
1. Stop all old Hailer processes cleanly. Keep the source file and a backup.
|
|
279
|
+
2. Provision an **empty** Hailer PostgreSQL database and set its URL.
|
|
280
|
+
3. Run `hailer migrate-redb --file /private/path/state.redb`.
|
|
281
|
+
4. Check reported counts, event cursors, and suppression enforcement before
|
|
282
|
+
starting 0.2. Keep the source backup; do not restart old writers after cutover.
|
|
283
|
+
|
|
284
|
+
Migration opens redb read-only, checks event/dedup mappings and suppression keys,
|
|
285
|
+
and copies bounded batches in **one destination transaction**. It preserves
|
|
286
|
+
event IDs, sequence values, suppression metadata, and next-cursor continuity.
|
|
287
|
+
It rejects missing/corrupt sources and nonempty targets without wiping anything.
|
|
288
|
+
An unclean redb file may require recovery with the old version against a backup
|
|
289
|
+
before migration. There is no implicit fallback and no dual-write period.
|
|
193
290
|
|
|
194
291
|
Before switching an existing app:
|
|
195
292
|
|
|
196
|
-
1.
|
|
293
|
+
1. Provision PostgreSQL, run Hailer on a private Mothership bind, check readiness,
|
|
294
|
+
and verify real SES delivery using a deliberate test.
|
|
197
295
|
2. Add the gem, set `HAILER_URL`, and remove the app's old delivery-method
|
|
198
296
|
override so Hailer's default takes effect.
|
|
199
297
|
3. Subscribe a dedicated SQS queue before exporting and importing existing
|
|
@@ -202,22 +300,22 @@ Before switching an existing app:
|
|
|
202
300
|
and any provider-neutral app history consumer have been verified.
|
|
203
301
|
5. Only then remove the old sender and AWS-specific feedback handling.
|
|
204
302
|
|
|
205
|
-
CommonShare's local integration is tested with its real application,
|
|
206
|
-
Mothership,
|
|
303
|
+
CommonShare's local 0.2 integration is tested with its real application,
|
|
304
|
+
test database, Mothership, the optimized binary from the macOS gem, its installed 0.1 Ruby
|
|
305
|
+
client, and a loopback SES fixture. Its existing AWS
|
|
207
306
|
receivers remain until the real subscription and suppression cutover is verified.
|
|
208
307
|
No deployed AWS routing has been changed.
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
classification are later work. No plugin API is promised yet.
|
|
308
|
+
Validation/behavior plugins, a durable outbound queue, distributed account-wide
|
|
309
|
+
quotas, and send idempotency remain later work. No plugin API is promised yet.
|
|
212
310
|
|
|
213
311
|
## Development
|
|
214
312
|
|
|
215
313
|
```sh
|
|
216
314
|
bundle install
|
|
217
315
|
bundle exec rake
|
|
218
|
-
cargo test --locked
|
|
316
|
+
HAILER_TEST_DATABASE_URL=postgres://test_user:password@127.0.0.1/hailer_test cargo test --locked
|
|
219
317
|
cargo fmt --all -- --check
|
|
220
|
-
gem build hailer.gemspec --output /tmp/hailer-0.
|
|
318
|
+
gem build hailer.gemspec --output /tmp/hailer-0.2.0.gem
|
|
221
319
|
bundle exec rake native:package
|
|
222
320
|
```
|
|
223
321
|
|
|
@@ -225,17 +323,72 @@ The Ruby suite exercises real local HTTP exchanges and a booted Rails app.
|
|
|
225
323
|
The Rust suite checks the same JSON fixture and the docking exchange with a
|
|
226
324
|
fake provider. Feedback tests cover redelivery, acknowledgement ordering,
|
|
227
325
|
atomic rollback, restart persistence, imports, and suppression enforcement.
|
|
228
|
-
|
|
326
|
+
Rust tests require an isolated PostgreSQL server and a test role with `CREATEDB`;
|
|
327
|
+
each test creates and drops its own database. Never point this variable at a
|
|
328
|
+
customer or Rails production database. Neither suite sends real mail or requires
|
|
329
|
+
AWS credentials.
|
|
330
|
+
|
|
331
|
+
For the opt-in CommonShare compatibility smoke, create a disposable Hailer
|
|
332
|
+
database, then run from that application's checkout:
|
|
333
|
+
|
|
334
|
+
```sh
|
|
335
|
+
RAILS_ENV=test HAILER_SMOKE_BINARY=/path/to/hailer/target/debug/hailer \
|
|
336
|
+
HAILER_DATABASE_URL=postgres://test_user:password@127.0.0.1/hailer_smoke \
|
|
337
|
+
bundle exec rails runner /path/to/hailer/test/commonshare_smoke.rb
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
It refuses a non-test app database, points the AWS SDK at a loopback fixture,
|
|
341
|
+
and rolls back application-history changes. It does not change the app's
|
|
342
|
+
dependency lockfile or send external mail.
|
|
229
343
|
|
|
230
344
|
`native:package` builds a locked release and writes
|
|
231
|
-
`pkg/hailer-0.
|
|
345
|
+
`pkg/hailer-0.2.0-<platform>.gem`. Each platform gem contains exactly its host
|
|
232
346
|
binary and a version/platform/SHA-256 manifest. The launcher verifies that
|
|
233
347
|
manifest before `exec`, preserving arguments, exit status, and signal handling.
|
|
234
348
|
`HAILER_BINARY` is an explicit, version-checked source-build override; there is
|
|
235
349
|
no download or PATH fallback.
|
|
236
350
|
|
|
237
|
-
Build on each supported release host before publishing its artifact.
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
351
|
+
Build on each supported release host before publishing its artifact. Nothing
|
|
352
|
+
in the packaging task publishes a gem.
|
|
353
|
+
|
|
354
|
+
## Linux releases and RubyGems
|
|
355
|
+
|
|
356
|
+
The **Linux binary gems** GitHub Actions workflow builds `x86_64-linux-gnu`
|
|
357
|
+
and `aarch64-linux-gnu` natively against Debian 12 (glibc 2.36). These artifacts
|
|
358
|
+
require glibc 2.36 or newer; they are not Alpine/musl builds.
|
|
359
|
+
|
|
360
|
+
```sh
|
|
361
|
+
gh workflow run linux-gems.yml --repo seuros/hailer -f tag=v0.2.0
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
The workflow checks out the requested release tag, runs the Ruby and Rust
|
|
365
|
+
suites against an isolated PostgreSQL 18 service, and installs each binary gem
|
|
366
|
+
in a separate slim Debian container. Pushes/PRs also verify the branch without
|
|
367
|
+
creating a release or publishing anything; only explicit workflow dispatch
|
|
368
|
+
can attach assets to an existing release.
|
|
369
|
+
It verifies the installed platform, bundled-binary manifest, executable version,
|
|
370
|
+
and PostgreSQL suppression import before attaching either Linux gem to the
|
|
371
|
+
existing release. Existing gem assets are never overwritten; `SHA256SUMS` is
|
|
372
|
+
updated to cover all release gems.
|
|
373
|
+
|
|
374
|
+
Publishing a GitHub release does **not** publish to RubyGems. Like Shuttlebay,
|
|
375
|
+
release assets can be fetched and published with Rake:
|
|
376
|
+
|
|
377
|
+
```sh
|
|
378
|
+
bundle exec rake gems:fetch
|
|
379
|
+
bundle exec rake gems:push_all
|
|
380
|
+
# Or select an explicit release:
|
|
381
|
+
bundle exec rake 'gems:push_all[v0.2.0]'
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
`gems:push_all` fetches fresh assets, checks every archive's SHA-256,
|
|
385
|
+
name/version/platform, and publishes only that verified set. It skips
|
|
386
|
+
already-published platforms only when RubyGems reports the identical archive
|
|
387
|
+
hash, so an interrupted publication can be resumed without replacing artifacts.
|
|
388
|
+
`gems:push` is an alias. No release task rebuilds or silently republishes gems.
|
|
389
|
+
|
|
390
|
+
RubyGems may require MFA or a suitably scoped API key. Keep credentials out of
|
|
391
|
+
the repository. Publishing makes the packaged Ruby code and binaries public,
|
|
392
|
+
even when the GitHub repository is private. Once published, Bundler selects
|
|
393
|
+
the matching native gem; do not set `force_ruby_platform` in deployment if
|
|
394
|
+
you want the bundled executable.
|
data/hailer.example.toml
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
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
|
+
# Opt in: initial AWS account/region sync gates send readiness.
|
|
7
|
+
[suppression_sync]
|
|
8
|
+
interval_seconds = 3600
|
|
9
|
+
|
|
10
|
+
# Optional PER-PROCESS budget, not an account-wide SES quota.
|
|
11
|
+
# [rate_limit]
|
|
12
|
+
# recipients_per_second = 10.0
|
|
13
|
+
# burst = 50
|
|
4
14
|
|
|
5
15
|
[feedback]
|
|
6
16
|
# Dedicated queue; subscribe the SNS topic with RawMessageDelivery=false.
|
data/lib/hailer/errors.rb
CHANGED
|
@@ -21,4 +21,7 @@ module Hailer
|
|
|
21
21
|
class ValidationError < DeliveryError; end
|
|
22
22
|
class RejectedError < DeliveryError; end
|
|
23
23
|
class UnavailableError < DeliveryError; end
|
|
24
|
+
class RateLimitedError < DeliveryError; end
|
|
25
|
+
# SES might have accepted the message. Automatic retries can duplicate it.
|
|
26
|
+
class AmbiguousDeliveryError < DeliveryError; end
|
|
24
27
|
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, 422 => ValidationError, 429 => RateLimitedError,
|
|
14
14
|
502 => RejectedError, 503 => UnavailableError
|
|
15
15
|
}.freeze
|
|
16
16
|
|
|
@@ -72,6 +72,9 @@ module Hailer
|
|
|
72
72
|
unless status == 200
|
|
73
73
|
message = document['error']
|
|
74
74
|
message = "Hailer returned HTTP #{status}" unless message.is_a?(String) && !message.empty?
|
|
75
|
+
if status == 503 && document['code'] == 'outcome_unknown'
|
|
76
|
+
raise AmbiguousDeliveryError.new(message, status: status)
|
|
77
|
+
end
|
|
75
78
|
raise HTTP_ERRORS.fetch(status, DeliveryError).new(message, status: status)
|
|
76
79
|
end
|
|
77
80
|
document
|
data/lib/hailer/version.rb
CHANGED
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.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Abdelkader Boudih
|
|
@@ -149,8 +149,6 @@ files:
|
|
|
149
149
|
- CHANGELOG.md
|
|
150
150
|
- LICENSE.txt
|
|
151
151
|
- README.md
|
|
152
|
-
- docs/delivery-contract.md
|
|
153
|
-
- docs/feedback.md
|
|
154
152
|
- exe/hailer
|
|
155
153
|
- hailer.example.toml
|
|
156
154
|
- lib/hailer.rb
|
data/docs/delivery-contract.md
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
# Delivery contract, v0.1
|
|
2
|
-
|
|
3
|
-
The gem and service are released from this repository. Keep their versions in
|
|
4
|
-
sync. Platform gems embed the compiled service, but it still runs as a
|
|
5
|
-
separate Mothership-supervised process, never inside Rails.
|
|
6
|
-
|
|
7
|
-
## Request
|
|
8
|
-
|
|
9
|
-
`POST /internal/mail/send`, `Content-Type: application/json`.
|
|
10
|
-
|
|
11
|
-
```json
|
|
12
|
-
{
|
|
13
|
-
"raw_mime": "From: Alice <alice@example.com>\r\nTo: bob@example.com\r\n\r\nHello\r\n",
|
|
14
|
-
"to_addresses": ["bob@example.com"],
|
|
15
|
-
"cc_addresses": [],
|
|
16
|
-
"bcc_addresses": []
|
|
17
|
-
}
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
- `raw_mime` is the full `Mail::Message#to_s`, not a rebuilt message.
|
|
21
|
-
- `from_email_address` is omitted unless the SMTP envelope sender was
|
|
22
|
-
explicitly assigned. SES otherwise reads the MIME From header, including
|
|
23
|
-
display names.
|
|
24
|
-
- `to_addresses` uses explicitly assigned `smtp_envelope_to`, otherwise `to`.
|
|
25
|
-
- `cc_addresses` and `bcc_addresses` preserve their original recipient groups,
|
|
26
|
-
including when `smtp_envelope_to` is overridden. This matches the existing
|
|
27
|
-
CommonShare SESv2 adapter.
|
|
28
|
-
- Absent recipient groups are arrays, never JSON `null`. The service also
|
|
29
|
-
defaults omitted recipient arrays to empty.
|
|
30
|
-
- The service validates addresses and requires at least one recipient.
|
|
31
|
-
|
|
32
|
-
Do not expose this endpoint to untrusted callers. Messages and addresses are
|
|
33
|
-
sensitive; do not log raw request bodies.
|
|
34
|
-
|
|
35
|
-
## Response
|
|
36
|
-
|
|
37
|
-
SES acceptance: HTTP **200**, `{"message_id":"SES-message-id"}`.
|
|
38
|
-
The adapter copies the ID to the original message's `ses_message_id` header
|
|
39
|
-
after acceptance, so Rails delivery observers can read it.
|
|
40
|
-
|
|
41
|
-
Failure: HTTP **400**, **422**, **502**, or **503**, `{"error":"description"}`.
|
|
42
|
-
Other paths/methods currently receive **404**. See the README for Ruby error
|
|
43
|
-
classes and the service's current AWS error-classification limitation.
|
|
44
|
-
|
|
45
|
-
There is no redirect handling, fire-and-forget acceptance, automatic client
|
|
46
|
-
retry, delivery guarantee, or idempotency key. `deliver_later` remains a Rails
|
|
47
|
-
job. A timeout or lost response is an ambiguous outcome.
|
|
48
|
-
|
|
49
|
-
## Compatibility tests
|
|
50
|
-
|
|
51
|
-
`test/fixtures/send_request.json` is consumed by the Ruby HTTP client tests and
|
|
52
|
-
Rust deserialization/exchange tests. Ruby tests separately render real
|
|
53
|
-
`Mail::Message` instances, including binary attachments, display names, Bcc,
|
|
54
|
-
and explicit envelope overrides. Rails boot tests check automatic configuration,
|
|
55
|
-
`deliver_now`, `deliver_later` job scheduling, observer timing, failure
|
|
56
|
-
propagation, and explicit `:test` delivery.
|
|
57
|
-
|
|
58
|
-
Provider acceptance tests use a fake provider, not SES. This establishes the
|
|
59
|
-
in-repository contract, not real AWS or Mothership interoperability.
|
data/docs/feedback.md
DELETED
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
# Feedback and the CommonShare cutover
|
|
2
|
-
|
|
3
|
-
## Ownership
|
|
4
|
-
|
|
5
|
-
Hailer owns SES/SNS parsing, SQS consumption, deduplication, suppression storage,
|
|
6
|
-
and filtering before delivery. It has **no public SNS webhook**.
|
|
7
|
-
|
|
8
|
-
Applications can consume normalized events to update business records. They
|
|
9
|
-
do not need to know about SNS subscriptions or SES notification schemas.
|
|
10
|
-
CommonShare's local generic history consumer has been wired and tested. Its
|
|
11
|
-
webhook receivers have **not** been removed: deployed AWS subscriptions and
|
|
12
|
-
the final suppression migration must still be verified before that is safe.
|
|
13
|
-
|
|
14
|
-
## AWS prerequisites
|
|
15
|
-
|
|
16
|
-
1. Configure SES identity notifications or configuration-set event publishing
|
|
17
|
-
to an SNS topic. At minimum, publish Bounce and Complaint; add Delivery and
|
|
18
|
-
other event types if application history needs them.
|
|
19
|
-
2. Subscribe a **dedicated SQS queue** with `RawMessageDelivery=false`. Hailer
|
|
20
|
-
requires the SNS envelope and checks its `TopicArn` against the configured
|
|
21
|
-
topic. Both SES's `notificationType` and `eventType` payloads are supported.
|
|
22
|
-
3. Restrict the queue policy to the intended SNS topic. Do not grant application
|
|
23
|
-
roles `sqs:SendMessage` on this queue: an authorized sender could forge an
|
|
24
|
-
SNS-looking envelope. Topic checking is not signature verification.
|
|
25
|
-
4. Configure a dead-letter queue, a finite receive count, suitable retention,
|
|
26
|
-
and alarms for oldest-message age, backlog, and dead-letter messages.
|
|
27
|
-
5. Give Hailer's role `sqs:ReceiveMessage` and `sqs:DeleteMessage` on that queue,
|
|
28
|
-
in addition to `ses:SendEmail`. For a customer-managed encrypted queue,
|
|
29
|
-
grant the necessary KMS decrypt permissions too.
|
|
30
|
-
6. Set the queue URL, SNS topic ARN, and persistent state path in
|
|
31
|
-
`hailer.toml` (see `hailer.example.toml`). Region/credentials come from the
|
|
32
|
-
shared standard AWS credential chain, not the Rails adapter.
|
|
33
|
-
|
|
34
|
-
SES identity feedback works automatically for those identities. If using an
|
|
35
|
-
SES configuration set, make sure the sent message actually selects it, for
|
|
36
|
-
example with SES's `X-SES-CONFIGURATION-SET` MIME header. Merely creating an SNS
|
|
37
|
-
subscription does not enable SES event publishing.
|
|
38
|
-
|
|
39
|
-
Hailer long-polls in batches of 10 with a 60-second visibility timeout. It
|
|
40
|
-
commits first, then deletes each SQS receipt. Failed parsing, state writes,
|
|
41
|
-
or deletes leave the message available for retry. Poison/unknown event types
|
|
42
|
-
are not silently discarded: use and monitor the DLQ.
|
|
43
|
-
|
|
44
|
-
## Suppression policy
|
|
45
|
-
|
|
46
|
-
New Permanent bounces and complaints suppress recipients. Transient and
|
|
47
|
-
Undetermined bounces are stored as events but do not permanently block mail.
|
|
48
|
-
`not-spam` feedback does not add a suppression and does not automatically
|
|
49
|
-
clear an existing one. There is no automatic unsuppression API yet.
|
|
50
|
-
|
|
51
|
-
Matching trims/downcases the address and strips the local part's `+alias`,
|
|
52
|
-
matching CommonShare's existing suppression policy. This is not a universal
|
|
53
|
-
email-equivalence rule; it is an explicit migration policy. Only matching keys
|
|
54
|
-
are normalized: allowed delivery addresses and the MIME message are unchanged.
|
|
55
|
-
|
|
56
|
-
Imported suppressions are retained, including any old records that were
|
|
57
|
-
originally created for transient bounces. Imports are atomic, repeatable, and
|
|
58
|
-
cannot downgrade a complaint to a bounce. New feedback also cannot downgrade
|
|
59
|
-
a complaint. The current service has one local state owner; separate replicas
|
|
60
|
-
must not share a queue while maintaining separate state.
|
|
61
|
-
|
|
62
|
-
## Import existing suppressions
|
|
63
|
-
|
|
64
|
-
Subscribe SQS **before** the final export so new feedback accumulates while
|
|
65
|
-
you migrate. Keep CommonShare's existing receiver active.
|
|
66
|
-
|
|
67
|
-
Export with read-only application code to a protected file (the data is PII):
|
|
68
|
-
|
|
69
|
-
```sh
|
|
70
|
-
umask 077
|
|
71
|
-
SUPPRESSION_EXPORT=/secure/suppressions.json bin/rails runner '
|
|
72
|
-
records = Platform::EmailSuppression.select(
|
|
73
|
-
:email_address, :reason, :suppressed_at, :originating_message_id, :metadata
|
|
74
|
-
)
|
|
75
|
-
File.write(ENV.fetch("SUPPRESSION_EXPORT"), records.map(&:attributes).to_json)
|
|
76
|
-
'
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
The file is a JSON array. Each record needs `email_address`, `reason` (`bounce`
|
|
80
|
-
or `complaint`), and an RFC 3339 `suppressed_at`. Message ID/metadata are optional.
|
|
81
|
-
Extra ActiveRecord fields are ignored. See `test/fixtures/suppressions.json`.
|
|
82
|
-
|
|
83
|
-
Stop Hailer before the import; redb permits one process to open its state file:
|
|
84
|
-
|
|
85
|
-
```sh
|
|
86
|
-
hailer --config /etc/hailer.toml import-suppressions --file /secure/suppressions.json
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
This command needs neither Mothership nor AWS credentials. An invalid address,
|
|
90
|
-
reason, or timestamp rejects the whole batch without partially importing.
|
|
91
|
-
Restart Hailer against the **same durable state path**, consume the backlog,
|
|
92
|
-
and verify known suppressed recipients cannot reach SES. Protect and delete
|
|
93
|
-
temporary exports according to your data-retention policy.
|
|
94
|
-
|
|
95
|
-
## Provider-neutral event feed
|
|
96
|
-
|
|
97
|
-
`POST /internal/mail/events` on Hailer's private Mothership bind:
|
|
98
|
-
|
|
99
|
-
```json
|
|
100
|
-
{"after":0,"limit":100}
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
`limit` is 1–100. A page looks like:
|
|
104
|
-
|
|
105
|
-
```json
|
|
106
|
-
{
|
|
107
|
-
"events": [{
|
|
108
|
-
"sequence": 1,
|
|
109
|
-
"event": {
|
|
110
|
-
"event_id": "arn:aws:sns:us-east-1:123456789012:mail-feedback:sns-id",
|
|
111
|
-
"kind": "bounced",
|
|
112
|
-
"provider": "ses",
|
|
113
|
-
"provider_message_id": "ses-id",
|
|
114
|
-
"message_id": "<original-rails-message-id@example.com>",
|
|
115
|
-
"occurred_at": "2026-10-20T12:00:00Z",
|
|
116
|
-
"recipients": ["recipient@example.com"],
|
|
117
|
-
"suppression": "bounce"
|
|
118
|
-
}
|
|
119
|
-
}],
|
|
120
|
-
"next_cursor": 1
|
|
121
|
-
}
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
Kinds: `sent`, `delivered`, `bounced`, `complained`, `rejected`,
|
|
125
|
-
`rendering_failed`, `delayed`, `opened`, `clicked`.
|
|
126
|
-
|
|
127
|
-
The sequence is **ingestion order**, not event time. Delivery events can arrive
|
|
128
|
-
late or out of order. Consumers should use `event_id` for deduplication, handle
|
|
129
|
-
`occurred_at` deliberately, and persist their cursor with their domain changes.
|
|
130
|
-
Retrying a page is safe only if the application handler is idempotent. There
|
|
131
|
-
is no global acknowledgement or destructive read; each consumer owns its cursor.
|
|
132
|
-
|
|
133
|
-
Use `message_id` to correlate the original RFC 5322 Message-ID; fall back to
|
|
134
|
-
`provider_message_id` if your app stores the SES ID. `message_id` may be null
|
|
135
|
-
when SES omitted headers. The feed contains recipient PII: keep it private.
|
|
136
|
-
`Hailer::FeedbackClient#poll(after:, limit:)` validates the entire page before
|
|
137
|
-
exposing typed events. `Hailer::FeedbackConsumer#call` dispatches them through
|
|
138
|
-
`config.hailer.feedback_handler` and returns a cursor only after every callback
|
|
139
|
-
succeeds. A missing handler fails before polling. Applications still own
|
|
140
|
-
scheduling, idempotency, business-record updates, and durable cursor storage.
|
|
141
|
-
|
|
142
|
-
## Safe removal checklist
|
|
143
|
-
|
|
144
|
-
Before deleting CommonShare's AWS-specific email handling:
|
|
145
|
-
|
|
146
|
-
- Verify a real SES mailbox-simulator bounce and complaint reach Hailer's queue.
|
|
147
|
-
- Verify suppression persists across a service restart and blocks later sends.
|
|
148
|
-
- Import the final old suppression snapshot with no gap in queue coverage.
|
|
149
|
-
- Verify a generic event consumer updates `OutgoingEmail` without SES/SNS parsing.
|
|
150
|
-
- Change the sender to Hailer and verify normal, CC/BCC, attachment, and
|
|
151
|
-
explicit-envelope delivery.
|
|
152
|
-
- Unsubscribe the old **email-specific** HTTP subscriptions, then remove the
|
|
153
|
-
SES controllers/routes, parsing jobs/operations, and Ruby bounce interceptor.
|
|
154
|
-
- Check other SNS consumers (including `sns_insights`) before removing shared
|
|
155
|
-
controllers, models, gems, admin history, or confirmation code.
|
|
156
|
-
- Keep historical database records unless a separate retention migration is
|
|
157
|
-
approved. Removing a receiver is not a reason to drop historical tables.
|
|
158
|
-
|
|
159
|
-
No AWS infrastructure was changed. CommonShare's local integration is on its
|
|
160
|
-
own feature branch; its existing SES/SNS receivers are retained for cutover.
|