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.
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.1.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-contract.md
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
@@ -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.