hailer 0.1.0 → 0.3.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 +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
- metadata +7 -3
- data/docs/delivery-contract.md +0 -59
- data/docs/feedback.md +0 -160
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: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Abdelkader Boudih
|
|
@@ -149,17 +149,21 @@ files:
|
|
|
149
149
|
- CHANGELOG.md
|
|
150
150
|
- LICENSE.txt
|
|
151
151
|
- README.md
|
|
152
|
-
- docs/delivery
|
|
153
|
-
- docs/feedback.md
|
|
152
|
+
- docs/durable-delivery.md
|
|
154
153
|
- exe/hailer
|
|
155
154
|
- hailer.example.toml
|
|
156
155
|
- lib/hailer.rb
|
|
157
156
|
- lib/hailer/binary.rb
|
|
158
157
|
- lib/hailer/client.rb
|
|
158
|
+
- lib/hailer/delivery_context.rb
|
|
159
159
|
- lib/hailer/delivery_method.rb
|
|
160
160
|
- lib/hailer/errors.rb
|
|
161
161
|
- lib/hailer/feedback_client.rb
|
|
162
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
|
|
163
167
|
- lib/hailer/railtie.rb
|
|
164
168
|
- lib/hailer/transport.rb
|
|
165
169
|
- lib/hailer/version.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.
|