bouncy 0.1.0 → 0.2.1
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 +37 -0
- data/README.md +195 -92
- data/guides/README.md +20 -0
- data/guides/admin.md +2 -2
- data/guides/amazon-ses.md +70 -9
- data/guides/api.md +148 -0
- data/guides/bounce-handling.md +38 -1
- data/guides/compatibility.md +16 -2
- data/guides/configuration.md +83 -0
- data/guides/delivery.md +46 -8
- data/guides/migrating.md +8 -7
- data/guides/operations.md +104 -0
- data/guides/privacy.md +1 -1
- data/guides/recovery.md +1 -1
- data/guides/troubleshooting.md +1 -1
- data/lib/bouncy/address_lock.rb +321 -0
- data/lib/bouncy/body_limit.rb +55 -0
- data/lib/bouncy/configuration.rb +38 -2
- data/lib/bouncy/event.rb +10 -4
- data/lib/bouncy/importer.rb +100 -0
- data/lib/bouncy/ingestor.rb +12 -3
- data/lib/bouncy/interceptor.rb +85 -16
- data/lib/bouncy/notification.rb +70 -0
- data/lib/bouncy/providers/ses.rb +57 -2
- data/lib/bouncy/providers/ses_parser.rb +20 -0
- data/lib/bouncy/providers/sns_verifier.rb +74 -6
- data/lib/bouncy/prune_job.rb +3 -0
- data/lib/bouncy/scope_lock.rb +3 -2
- data/lib/bouncy/status.rb +3 -0
- data/lib/bouncy/store.rb +9 -1
- data/lib/bouncy/sync_job.rb +4 -0
- data/lib/bouncy/version.rb +1 -1
- data/lib/bouncy/webhook.rb +60 -9
- data/lib/bouncy.rb +44 -3
- data/lib/generators/bouncy/templates/initializer.rb.tt +14 -0
- metadata +14 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 25620e7678f2db3d1aeae8f633654ae92205277b171eea5bddbbd3c3d4d898e1
|
|
4
|
+
data.tar.gz: f898b5f8180190136a031469ca2a6bd62f4930929378afedd0cae4d3f9a19f4a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d9ebee34cdc50548f939a80ab07cfc3e26cd0284d0c46f5b608f032dd8b41b9b92d7e5101a77b2d7b0107d8fd977aaa2f58da8e822b580362692900f1fd345d2
|
|
7
|
+
data.tar.gz: 16c591e6c5e1d51d08bd34d8273a2afbc9d08e605f30520635e509b5519104e3b1f13917a740378e88106e3890ef8743606756117a0338087b11a8ee48f5fff1
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,43 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.2.1] - 2026-10-25
|
|
6
|
+
|
|
7
|
+
Three gaps that the first app to move its own suppression table to Bouncy found. There is no new migration.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `Bouncy.import!(email, reason:, at:, note: nil, actor: nil)` records a block that another system recorded, with its reason (`:hard_bounce`, `:complaint` or `:manual`) and its time, as one `imported` event; it calls no hook, writes nothing when it repeats, never weakens a block in force and changes nothing after a later release, because an app that carried its old suppressions had to call the internal `Bouncy::Ingestor`, which treats each call as new webhook feedback. `Bouncy::Ingestor` keeps its 0.2.0 call shape and is now marked internal.
|
|
12
|
+
- `Status#blocked_at` is the time the block in force began (for an import, its `at:` time) and `nil` when the address is not blocked, because an app that shows "not deliverable since" had to read the suppression row itself; a block that follows an expired soft hold now starts at its own time, not at the soft hold's.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- The 90-day freshness rule (`config.maximum_event_age`) applies to webhook feedback only, not to imports or manual holds, because it exists to stop a late notification from blocking, while a block that another system kept for a year is still a block: an import dated a year ago drops mail, and a webhook bounce dated a year ago still does not.
|
|
17
|
+
|
|
18
|
+
## [0.2.0] - 2026-10-25
|
|
19
|
+
|
|
20
|
+
Eight features that apps need to replace a hand-rolled SES integration with Bouncy. There is no new migration: the address lock uses database locks, not a column.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- `config.ses.configuration_set` stamps `X-SES-CONFIGURATION-SET` on every message that names no set of its own, whatever the interception mode, and adds the set to the sending-policy check, so SES publishes each mail's events to the app's topic without a header in every mailer.
|
|
25
|
+
- `Bouncy.hold(email) { ... }` takes a shared lock per address around an app's own check and send, and every Bouncy write takes it exclusively until its transaction ends, so a new bounce or complaint cannot land between the check and the send; inside a PostgreSQL REPEATABLE READ or SERIALIZABLE transaction it raises, because that snapshot cannot see a block committed during the wait.
|
|
26
|
+
- `config.on_notification` receives every SES notification synchronously (`type`, `recipients`, `message_id`, `occurred_at`, `tags`, `raw`) after its state commits and before the receiver answers, so an app can track its own Send and Delivery events, and a raise answers 503 so that SNS delivers again; the `after_*` hooks are unchanged.
|
|
27
|
+
- `config.ses.subscription_confirmation = :subscribe_url` confirms an SNS subscription by visiting the signed `SubscribeURL` on the topic's own SNS host and answers 503 unless that visit gets a 2xx, so credentials without `sns:ConfirmSubscription` can confirm; the default `:api` is unchanged.
|
|
28
|
+
- `config.require_fresh_sync = false` lets a webhook block drop mail with no sync at all, so an app on an SES account that other apps share need not mirror their suppression lists; the default `true` keeps the 0.1 rule.
|
|
29
|
+
- `config.webhook_blocking = :strict` blocks every Permanent bounce, whatever its subtype, and every recipient a complaint names, because without a sync nothing else reads the account list and a complaint does not say who complained; it is the default when `require_fresh_sync` is `false`, and `:cautious`, the 0.1 rule, stays the default while a sync runs.
|
|
30
|
+
- The SNS signing certificate is cached for the process (at most 16 URLs, one hour each, cached only once it parses as X.509 with an RSA key, and fetched again once when a signature fails on a certificate older than a minute), because each request builds a new verifier and an app that subscribes to Send and Delivery events got one HTTPS download per mail.
|
|
31
|
+
- The receiver logs one warning per refused message or failed confirmation, with the status and the reason, plus the topic ARN and `SubscribeURL` host only when they pass strict SNS validation (`<invalid>` otherwise), so no raw field of an unauthenticated body reaches the log, because a silent 4xx or 503 left a dead subscription undiagnosable.
|
|
32
|
+
- `Bouncy::SyncJob` and `Bouncy::PruneJob` return early with one info line while `config.scope` is unset, because a scheduled job in an app with the feature off failed every run and filled the failed-jobs list; `Bouncy.sync!` still raises.
|
|
33
|
+
- `Bouncy::BodyLimit` is Rack middleware (300 KB by default, configurable) that cuts the receiver's body before Rails reads a whole chunked body or parses it for its log; the receiver's own 2 MiB limit stays.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- The recipient check moved to the delivery method that Mail calls after every interceptor and mailer callback, so a message that `:drop` left with no recipient reaches no transport, `deliver_now!` and `deliver_later!` included, and a recipient that a later callback or interceptor adds is checked too.
|
|
38
|
+
- The interceptor keeps each remaining To, Cc, Bcc and SMTP envelope entry as written, display name included, because it looks up only the entry's bare address.
|
|
39
|
+
- The `after_*` hooks run after the commit of the outermost transaction and after the enclosing `Bouncy.hold`, so a hook never runs while Bouncy holds an address; a rollback drops them, and Rails transactional tests keep 0.1's timing.
|
|
40
|
+
- The sync's scope lock reads its answer without the query cache, so a second sync in one cached request cannot take the lock without locking.
|
|
41
|
+
|
|
5
42
|
## [0.1.0] - 2026-09-09
|
|
6
43
|
|
|
7
44
|
Initial release of email bounce handling and suppression management for Rails, with Amazon SES support.
|
data/README.md
CHANGED
|
@@ -1,22 +1,21 @@
|
|
|
1
|
-
# 📨 `bouncy
|
|
1
|
+
# 📨 `bouncy` - Know when your app's emails bounce
|
|
2
2
|
|
|
3
|
-
[](https://badge.fury.io/rb/bouncy) [](https://github.com/rameerez/bouncy/actions/workflows/test.yml)
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
“I never got the email.” The job succeeded, your mailer ran, and SES refused an address on its suppression list. `bouncy` brings that information into your app so you can see the restriction, stop repeated attempts and help the person recover.
|
|
5
|
+
> [!TIP]
|
|
6
|
+
> **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com/?ref=bouncy)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks. Go [check it out](https://railsfast.com/?ref=bouncy)!
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
Bouncy.blocked?("ada@example.com")
|
|
11
|
-
# => true
|
|
8
|
+
`bouncy` brings **email bounce handling and suppression management** into your Rails app.
|
|
12
9
|
|
|
13
|
-
Bouncy.
|
|
14
|
-
# => :hard_bounce
|
|
10
|
+
“I never got the email.” Your mailer ran, the job succeeded, but Amazon SES refused an address on its suppression list. Bouncy keeps a local mirror so you can see what happened, avoid repeated attempts, and release the address from your own admin.
|
|
15
11
|
|
|
16
|
-
|
|
12
|
+
```ruby
|
|
13
|
+
Bouncy.blocked?("ada@example.com") # => true
|
|
14
|
+
Bouncy.status("ada@example.com").reason # => :hard_bounce
|
|
15
|
+
Bouncy.release!("ada@example.com", note: "Verified recovery with the customer")
|
|
17
16
|
```
|
|
18
17
|
|
|
19
|
-
Works for every address your app sends to: customers, invitees, buyers and contacts. No `User` record required.
|
|
18
|
+
Works for every address your app sends to: customers, invitees, buyers and contacts. No `User` record required. If you have one, add a single line:
|
|
20
19
|
|
|
21
20
|
```ruby
|
|
22
21
|
class User < ApplicationRecord
|
|
@@ -27,47 +26,48 @@ user.email_blocked?
|
|
|
27
26
|
User.email_blocked
|
|
28
27
|
```
|
|
29
28
|
|
|
30
|
-
|
|
31
|
-
> **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com/?ref=bouncy)**, a production-ready Rails boilerplate with authentication, payments, admin and the boring parts already wired up. It's the home of the gem ecosystem that Bouncy belongs to.
|
|
29
|
+
Check out my other 💎 Ruby gems: [`nondisposable`](https://github.com/rameerez/nondisposable) · [`api_keys`](https://github.com/rameerez/api_keys) · [`usage_credits`](https://github.com/rameerez/usage_credits) · [`pricing_plans`](https://github.com/rameerez/pricing_plans)
|
|
32
30
|
|
|
33
|
-
##
|
|
31
|
+
## What you get
|
|
34
32
|
|
|
35
|
-
|
|
33
|
+
- A local mirror of the SES account suppression list, including restrictions from before installation and other apps on the same account.
|
|
34
|
+
- Signed SNS webhooks for new bounces and complaints, with exact-topic authorization, per-recipient deduplication, a cached signing certificate and one log line per refused message.
|
|
35
|
+
- Address status, model predicates and scopes, and batch lookups for badges on your lists.
|
|
36
|
+
- Action Mailer interception that checks To, Cc, Bcc and the SMTP envelope, keeps display names, and stops bang deliveries too.
|
|
37
|
+
- `Bouncy.hold` for senders that check an address and send in one step, and a synchronous hook for every SES event.
|
|
38
|
+
- Audited support holds and provider-aware recovery that preserves exact address spelling.
|
|
39
|
+
- Sync and pruning jobs, hooks, and read-only setup diagnostics. Use your existing scheduler and admin framework.
|
|
36
40
|
|
|
37
|
-
|
|
41
|
+
## Requirements
|
|
38
42
|
|
|
39
|
-
|
|
43
|
+
- Ruby **3.3+** and Rails **7.2.3.2+**, below Rails 9. Tested on Ruby 3.3/3.4/4.0 and Rails 7.2/8.0/8.1.
|
|
44
|
+
- **PostgreSQL, MySQL or SQLite**, with bigint or UUID primary keys.
|
|
45
|
+
- **Amazon SES**, one account and region. SES SMTP is supported; suppression management needs AWS API credentials.
|
|
40
46
|
|
|
41
|
-
|
|
42
|
-
- Receives signed SNS feedback, authorizes the exact topic, and records each recipient independently.
|
|
43
|
-
- Exposes local address status, model scopes and a compact event history.
|
|
44
|
-
- Observes or drops blocked recipients through Action Mailer's normal delivery path, including Bcc and explicit SMTP envelopes.
|
|
45
|
-
- Releases exact provider address variants before clearing local evidence. Manual holds remain independent.
|
|
46
|
-
- Reconciles provider changes, retains release ordering information and reports incomplete or stale observations.
|
|
47
|
-
|
|
48
|
-
Soft bounces are recorded without blocking an address, unless you opt into a threshold (`config.soft_bounce_threshold`). An ordinary complaint blocks locally only when SES names exactly one recipient; a complaint that lists several possible recipients is recorded as candidates and enforced once the provider lists the address at the next sync. Suppression-list refusal notices are recorded separately and never become new complaint blocks. Delivery means the receiving server accepted a message; it does not prove inbox placement or reading.
|
|
47
|
+
Start in observation mode and verify your sending setup before enabling dropping. See [compatibility and validation](guides/compatibility.md) for the tested matrix and remaining validation limits.
|
|
49
48
|
|
|
50
49
|
## Installation
|
|
51
50
|
|
|
52
|
-
Add
|
|
51
|
+
Add these lines to your application's Gemfile:
|
|
53
52
|
|
|
54
53
|
```ruby
|
|
55
|
-
|
|
56
|
-
gem "bouncy", "~> 0.1.0"
|
|
54
|
+
gem "bouncy", "~> 0.2.0"
|
|
57
55
|
gem "aws-sdk-sesv2"
|
|
58
56
|
gem "aws-sdk-sns"
|
|
59
57
|
gem "aws-sdk-sts"
|
|
60
58
|
```
|
|
61
59
|
|
|
60
|
+
Then install the gem and generate its tables and initializer:
|
|
61
|
+
|
|
62
62
|
```sh
|
|
63
63
|
bundle install
|
|
64
64
|
bin/rails generate bouncy:install
|
|
65
65
|
bin/rails db:migrate
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
-
The
|
|
68
|
+
The migration follows your app's primary-key setting and uses PostgreSQL JSONB or MySQL/SQLite JSON. See [database compatibility](guides/compatibility.md#migrations) for UUID configuration.
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
Configure your actual account, region and sending paths:
|
|
71
71
|
|
|
72
72
|
```ruby
|
|
73
73
|
# config/initializers/bouncy.rb
|
|
@@ -76,137 +76,240 @@ Bouncy.configure do |config|
|
|
|
76
76
|
config.ses.region = "us-east-1"
|
|
77
77
|
config.ses.topic_arns = ["arn:aws:sns:us-east-1:123456789012:feedback"]
|
|
78
78
|
config.ses.identities = ["example.com"]
|
|
79
|
-
config.ses.configuration_sets = [
|
|
80
|
-
config.ses.all_sending_paths_listed = true #
|
|
79
|
+
config.ses.configuration_sets = [] # List every set you use, if any.
|
|
80
|
+
config.ses.all_sending_paths_listed = true # Confirm both lists are complete.
|
|
81
81
|
config.interception = :log
|
|
82
82
|
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The SDKs use the AWS credential chain. If your mailer uses Rails encrypted credentials, pass its AWS credential provider explicitly through `config.ses.credentials`. SMTP credentials cannot authenticate management calls. See [credentials and IAM](guides/amazon-ses.md#iam-and-sdk-dependencies).
|
|
83
86
|
|
|
87
|
+
Mount the receiver in your routes:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
84
90
|
# config/routes.rb
|
|
85
91
|
mount Bouncy::Engine => "/bouncy"
|
|
86
92
|
```
|
|
87
93
|
|
|
88
|
-
|
|
94
|
+
Cut oversized webhook bodies before Rails reads them. Rails reads a whole chunked body, and parses a JSON body for its log, before any route runs. SNS posts 256 KB at most:
|
|
89
95
|
|
|
90
|
-
|
|
96
|
+
```ruby
|
|
97
|
+
# config/application.rb
|
|
98
|
+
config.middleware.insert_before 0, Bouncy::BodyLimit, path: "/bouncy/webhooks/ses" # limit: 300.kilobytes by default
|
|
99
|
+
```
|
|
91
100
|
|
|
92
|
-
|
|
101
|
+
Use the path your app routes to the receiver. The limit covers that path with a trailing slash or a format suffix, and also the full path when the app runs under a prefix; adjacent routes are untouched. The receiver keeps its own 2 MiB limit as well.
|
|
93
102
|
|
|
94
|
-
|
|
103
|
+
The receiver logs one warning for each message it refuses, with the status, the reason and the topic ARN (or the `SubscribeURL` host of a failed confirmation). The topic and host appear only when they pass strict SNS validation, and as `<invalid>` otherwise, so no address, token or other raw field of the body reaches the log. That line is how a dead subscription shows up in production:
|
|
95
104
|
|
|
105
|
+
```text
|
|
106
|
+
[bouncy] refused an SNS message (401): SNS topic is not authorized; topic arn:aws:sns:us-east-1:999999999999:feedback
|
|
107
|
+
```
|
|
96
108
|
|
|
97
|
-
|
|
109
|
+
Follow the [SES setup guide](guides/amazon-ses.md) to connect bounce and complaint notifications to **`POST /bouncy/webhooks/ses`**. Deploy the allowlisted receiver before subscribing the SNS topic. Bouncy does not create or change your AWS configuration.
|
|
110
|
+
|
|
111
|
+
Check the policy and import the suppression list:
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
bin/rails bouncy:doctor
|
|
115
|
+
bin/rails bouncy:sync
|
|
116
|
+
```
|
|
98
117
|
|
|
99
|
-
|
|
118
|
+
Schedule `Bouncy::SyncJob` **hourly** and `Bouncy::PruneJob` **daily** with your existing job system. The [operations guide](guides/operations.md) includes scheduling examples and monitoring checks. After reviewing a complete, verified sync and testing delivery in staging, change `config.interception` to `:drop`.
|
|
119
|
+
|
|
120
|
+
Until `config.scope` is set, Bouncy is inactive: mail goes out untouched and status reports `:unconfigured`. Explicit sync, block, release and erasure operations raise. `Bouncy::SyncJob` and `Bouncy::PruneJob` skip with one info line instead, so a job you schedule before the feature is on does not fail every run. No database or AWS call runs just from loading the gem.
|
|
121
|
+
|
|
122
|
+
## Usage
|
|
123
|
+
|
|
124
|
+
### Check an address
|
|
100
125
|
|
|
101
126
|
```ruby
|
|
102
127
|
status = Bouncy.status("ada@example.com")
|
|
103
|
-
status.blocked?
|
|
104
|
-
status.reasons
|
|
105
|
-
status.knowledge
|
|
106
|
-
status.provider_listed?
|
|
107
|
-
status.
|
|
108
|
-
status.
|
|
109
|
-
status.
|
|
110
|
-
status.observed_at
|
|
111
|
-
status.last_event
|
|
128
|
+
status.blocked? # Any effective local restriction?
|
|
129
|
+
status.reasons # All reasons, including independent local holds
|
|
130
|
+
status.knowledge # :observed, :no_known_block, :unavailable, :unconfigured
|
|
131
|
+
status.provider_listed? # Exact provider identifiers are present locally
|
|
132
|
+
status.policy_reason # Why a listed address's sending policy is unverified
|
|
133
|
+
status.stale? # Address observation is missing or older than stale_after
|
|
134
|
+
status.blocked_at # When the block in force began, or nil when not blocked
|
|
112
135
|
|
|
113
136
|
Bouncy.blocked
|
|
114
|
-
Bouncy.events.for("ada@example.com").recent
|
|
137
|
+
Bouncy.events.for("ada@example.com").recent.limit(20)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`blocked?` describes known restrictions, not guaranteed deliverability or whether this particular message will be dropped. Provider-derived dropping requires a fresh, complete, policy-verified sync, unless you [run without a sync](#run-without-a-sync). Independent manual and active soft holds apply even when the mirror is stale. Recognized database outages fail open; the status API exposes `:unavailable`.
|
|
115
141
|
|
|
116
|
-
|
|
142
|
+
For a list page, load all addresses in one query:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
117
145
|
statuses = Bouncy.statuses(users.map(&:email))
|
|
118
|
-
statuses["Ada@Example.com"].blocked?
|
|
119
|
-
statuses.blocked
|
|
120
|
-
status.record # the Bouncy::Suppression row, for linking to your admin page
|
|
146
|
+
statuses["Ada@Example.com"].blocked?
|
|
147
|
+
statuses.blocked # Hash of normalized addresses to blocked statuses
|
|
121
148
|
```
|
|
122
149
|
|
|
123
|
-
|
|
150
|
+
See the [API reference](guides/api.md) for all status readers, batch semantics and model scopes. Model scopes expect a trimmed lowercase stored column; use `bouncy :email, normalized_attribute: :canonical_email` if you keep a separate normalized value.
|
|
124
151
|
|
|
125
|
-
|
|
152
|
+
### Stop repeated sends
|
|
126
153
|
|
|
127
|
-
|
|
154
|
+
Three modes: `:log` records what would be dropped while allowing delivery, `:drop` removes enforceable blocked recipients, and `:off` disables interception. The other recipients keep their entries as written, display names included. Bouncy decides where Mail hands the message to the transport, after every interceptor and every mailer callback, so it judges the recipients the transport would really get. A message with no recipient left reaches no transport, whether you call `deliver_now`, `deliver_now!`, `deliver_later` or `deliver_later!`.
|
|
155
|
+
|
|
156
|
+
Direct SDK sends and custom transports have [separate boundaries](guides/delivery.md).
|
|
128
157
|
|
|
129
158
|
```ruby
|
|
130
|
-
|
|
131
|
-
|
|
159
|
+
# An explicit exception for synchronous mail only:
|
|
160
|
+
Bouncy.unblocked { SupportMailer.recovery(address).deliver_now }
|
|
132
161
|
```
|
|
133
162
|
|
|
134
|
-
|
|
163
|
+
### Check and send in one step
|
|
135
164
|
|
|
136
|
-
|
|
165
|
+
A sender that records its own outcome checks the address itself, then sends. Hold the address around both, so no new bounce or complaint can land between the check and the send:
|
|
137
166
|
|
|
138
|
-
|
|
167
|
+
```ruby
|
|
168
|
+
Bouncy.hold(order.email) do
|
|
169
|
+
next order.receipt_skipped! if Bouncy.blocked?(order.email)
|
|
170
|
+
|
|
171
|
+
ReceiptMailer.with(order:).receipt.deliver_now
|
|
172
|
+
order.receipt_sent!
|
|
173
|
+
end
|
|
174
|
+
```
|
|
139
175
|
|
|
140
|
-
|
|
176
|
+
Every Bouncy write to that address waits for the block. Inside a transaction, the hold lasts until the transaction commits, so the record of the send commits first. Senders to one address do not wait for each other on PostgreSQL and SQLite; on MySQL they take turns. The lock needs no migration.
|
|
177
|
+
|
|
178
|
+
The check inside the hold must read the latest committed state. On MySQL, Bouncy's reads inside a hold are locking reads, which do that at any isolation level. PostgreSQL does it at READ COMMITTED, its default; inside a REPEATABLE READ or SERIALIZABLE transaction, `Bouncy.hold` raises `Bouncy::ConfigurationError`, because the transaction's snapshot is fixed before the lock is granted. For those, and on SQLite, hold the address outside the transaction, around it:
|
|
141
179
|
|
|
142
180
|
```ruby
|
|
143
|
-
|
|
144
|
-
|
|
181
|
+
Bouncy.hold(order.email) do
|
|
182
|
+
Order.transaction(isolation: :repeatable_read) do
|
|
183
|
+
# check, send and record here
|
|
184
|
+
end
|
|
185
|
+
end
|
|
145
186
|
```
|
|
146
187
|
|
|
147
|
-
|
|
188
|
+
The hold then lasts for the whole block, and the transaction's snapshot starts after it. Hooks of a write made inside a hold run when the hold ends.
|
|
148
189
|
|
|
149
|
-
|
|
190
|
+
### Help someone recover
|
|
150
191
|
|
|
151
192
|
```ruby
|
|
152
|
-
Bouncy.block!(
|
|
153
|
-
Bouncy.release!(
|
|
154
|
-
Bouncy.release!(
|
|
193
|
+
Bouncy.block!(address, note: "Support is investigating", actor: "support:42")
|
|
194
|
+
Bouncy.release!(address, at: :local) # Clear manual/soft holds; retain provider/webhook evidence
|
|
195
|
+
Bouncy.release!(address, note: "Verified recovery", actor: "support:42")
|
|
155
196
|
```
|
|
156
197
|
|
|
157
|
-
Default
|
|
198
|
+
Default release removes exact provider variants and verifies absence before clearing local state. Errors leave evidence available for review. Releasing an SES restriction can affect sister apps in that account and region; authorize the action in your app. Release does not resend mail or restore consent after a complaint. See [recovery and partial failures](guides/recovery.md).
|
|
199
|
+
|
|
200
|
+
### Carry blocks from another system
|
|
158
201
|
|
|
159
|
-
|
|
202
|
+
An app that moves its own suppression table to Bouncy carries each block with its reason and the time the old table recorded it:
|
|
160
203
|
|
|
161
|
-
|
|
204
|
+
```ruby
|
|
205
|
+
Bouncy.import!(address, reason: :hard_bounce, at: old_row.suppressed_at, note: "Carried from the old table", actor: "carry-task")
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`reason` is `:hard_bounce`, `:complaint` or `:manual`. A bounce or a complaint is the same evidence as a webhook block, and a manual block is a manual hold. `at` can be of any age: the 90-day window for late webhook feedback does not apply. The import calls no hook, writes nothing when it repeats, never weakens a block in force, and changes nothing when the address was released after `at`, so a carry task is safe to run again. See [migrating](guides/migrating.md).
|
|
162
209
|
|
|
163
|
-
Use any admin UI. Bouncy has **no Madmin dependency, generator or adapter**.
|
|
210
|
+
Use any admin UI. Bouncy has **no Madmin dependency, generator or adapter**. The [admin recipe](guides/admin.md) covers host-owned recovery actions, badges and notifications.
|
|
164
211
|
|
|
165
|
-
|
|
212
|
+
### Handle repeated mailbox-full bounces
|
|
166
213
|
|
|
167
|
-
Soft bounces are
|
|
214
|
+
Soft bounces are record-only by default. To add a temporary local hold after repeated SES `MailboxFull` bounces:
|
|
168
215
|
|
|
169
216
|
```ruby
|
|
170
217
|
Bouncy.configure do |config|
|
|
171
|
-
config.soft_bounce_threshold = 3
|
|
172
|
-
config.soft_bounce_window = 30.days
|
|
173
|
-
config.soft_bounce_block_for = 30.days
|
|
218
|
+
config.soft_bounce_threshold = 3
|
|
219
|
+
config.soft_bounce_window = 30.days
|
|
220
|
+
config.soft_bounce_block_for = 30.days
|
|
174
221
|
end
|
|
175
222
|
```
|
|
176
223
|
|
|
177
|
-
|
|
224
|
+
Only mailbox-full events count. Content, attachment-size and unknown failures never trigger this hold. See [bounce classification](guides/bounce-handling.md) and [configuration defaults](guides/configuration.md).
|
|
178
225
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
## Hooks and retention
|
|
226
|
+
### React to changes
|
|
182
227
|
|
|
183
228
|
```ruby
|
|
184
229
|
Bouncy.configure do |config|
|
|
185
230
|
config.after_block = ->(event) { SupportNotificationJob.perform_later(event.id) }
|
|
186
|
-
config.after_release = ->(event) { Rails.logger.info("Email recovery
|
|
187
|
-
|
|
231
|
+
config.after_release = ->(event) { Rails.logger.info("Email recovery: #{event.id}") }
|
|
232
|
+
end
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Your app defines the notification job. Hooks are best effort and run after the commit of the outermost transaction, once the connection holds no Bouncy lock: a rollback drops them, and inside `Bouncy.hold` they wait until the hold ends. One exception: in Rails transactional tests, whose fixture transaction only rolls back, hooks run when your app's own transaction commits, as in 0.1. Events default to 90-day retention; no raw message bodies or payloads are stored. See [hooks and monitoring](guides/operations.md#hooks-and-instrumentation) and [privacy](guides/privacy.md).
|
|
236
|
+
|
|
237
|
+
To follow your own mail (sent, delivered, bounced), take every SES notification synchronously:
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
Bouncy.configure do |config|
|
|
241
|
+
config.on_notification = lambda do |notification|
|
|
242
|
+
notification.type # => "Delivery"
|
|
243
|
+
notification.message_id # => SES message id
|
|
244
|
+
notification.recipients # => ["ada@example.com"]
|
|
245
|
+
notification.occurred_at # => Time of this event
|
|
246
|
+
notification.tags # => { "receipt" => ["42"] }
|
|
247
|
+
notification.raw # => the SES event as a Hash
|
|
248
|
+
end
|
|
188
249
|
end
|
|
189
250
|
```
|
|
190
251
|
|
|
191
|
-
|
|
252
|
+
It runs for every event type, after Bouncy committed what it records, and before the receiver answers SNS. If it raises, the receiver answers 503 and SNS delivers the notification again, so make it safe to repeat.
|
|
253
|
+
|
|
254
|
+
### Name the configuration set
|
|
255
|
+
|
|
256
|
+
To publish SES events (Send, Delivery and the rest) through a configuration set, name it once:
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
config.ses.configuration_set = "transactional"
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Bouncy stamps `X-SES-CONFIGURATION-SET` on every message that does not name its own set, whatever the interception mode, and checks the set's suppression options with the rest of your [sending policy](guides/amazon-ses.md#supported-sending-policy).
|
|
263
|
+
|
|
264
|
+
### Confirm the subscription without the SNS API
|
|
192
265
|
|
|
193
|
-
|
|
266
|
+
By default the receiver confirms an SNS subscription with `sns:ConfirmSubscription`. If the app's credentials lack that grant, visit the signed `SubscribeURL` instead:
|
|
267
|
+
|
|
268
|
+
```ruby
|
|
269
|
+
config.ses.subscription_confirmation = :subscribe_url
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Only an HTTPS URL on the topic's own SNS host is visited. Anything but a 2xx answer makes the receiver answer 503, so SNS sends the confirmation again.
|
|
273
|
+
|
|
274
|
+
### Run without a sync
|
|
275
|
+
|
|
276
|
+
By default a webhook block drops mail only while a recent sync verified the provider list. An app that never mirrors the account list, for example on an SES account that other apps share, can enforce what its own webhook records:
|
|
277
|
+
|
|
278
|
+
```ruby
|
|
279
|
+
config.require_fresh_sync = false
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Every recorded block then drops mail with no `Bouncy::SyncJob` at all. Nothing clears a webhook block on its own. `Bouncy.forget!` removes the local record; `Bouncy.release!` also reads and changes the account's suppression list, which other apps on a shared account rely on.
|
|
283
|
+
|
|
284
|
+
Without a sync, the webhook must also decide alone which notifications block, so `config.webhook_blocking` defaults to `:strict`:
|
|
285
|
+
|
|
286
|
+
| Notification | `:strict` (default without a sync) | `:cautious` (default with a sync) |
|
|
287
|
+
|---|---|---|
|
|
288
|
+
| Permanent bounce, `General` or `NoEmail` | Blocks | Blocks |
|
|
289
|
+
| Permanent bounce, any other subtype (`OnAccountSuppressionList`, `Suppressed`, `UnsubscribedRecipient`, ... and future ones) | Blocks | Recorded; the sync reads the account list |
|
|
290
|
+
| Transient or Undetermined bounce | Recorded only | Recorded only |
|
|
291
|
+
| Complaint naming one recipient | Blocks | Blocks |
|
|
292
|
+
| Complaint naming several recipients | Blocks every one | Recorded as candidates; the sync confirms |
|
|
293
|
+
| Complaint with a subtype such as `OnAccountSuppressionList` | Blocks | Recorded; the sync reads the account list |
|
|
294
|
+
| Complaint marked `not-spam` | Recorded only | Recorded only |
|
|
295
|
+
|
|
296
|
+
A complaint cannot tell you who complained. In [Amazon's words](https://docs.aws.amazon.com/ses/latest/dg/notification-contents.html#complained-recipients): "Most ISPs remove the email address of the recipient who submitted the complaint from their complaint notification. For this reason, this list contains information about recipients who might have sent the complaint, based on the recipients of the original message and the ISP from which we received the complaint." No field says which case a notification is, so `:strict` blocks every recipient it names: mailing someone who reported you costs more than missing one. Send one message per recipient, as Amazon advises, and a complaint names only that recipient. Set `config.webhook_blocking` yourself to choose either rule with any sync setting.
|
|
297
|
+
|
|
298
|
+
## Documentation
|
|
299
|
+
|
|
300
|
+
The [guide index](guides/README.md) covers setup, every configuration option and public API, scheduling, admin integration, recovery, migration and troubleshooting.
|
|
194
301
|
|
|
195
302
|
## Development
|
|
196
303
|
|
|
197
304
|
```sh
|
|
198
305
|
bin/setup
|
|
199
306
|
bundle exec rake test
|
|
307
|
+
bundle exec rubocop
|
|
200
308
|
bundle exec appraisal install
|
|
201
309
|
bundle exec appraisal rake test
|
|
202
|
-
DATABASE_URL=postgresql:///bouncy_test bundle exec rake test
|
|
203
310
|
```
|
|
204
311
|
|
|
205
|
-
Tests use Minitest, SimpleCov
|
|
206
|
-
|
|
207
|
-
## More Rails gems
|
|
208
|
-
|
|
209
|
-
Pair Bouncy with [`nondisposable`](https://github.com/rameerez/nondisposable) for disposable-address validation, [`api_keys`](https://github.com/rameerez/api_keys) for API authentication, [`usage_credits`](https://github.com/rameerez/usage_credits) for usage billing, and [`pricing_plans`](https://github.com/rameerez/pricing_plans) for subscription plans.
|
|
312
|
+
Tests use Minitest, SimpleCov with 90% line and branch minimums, generated migrations, SDK stubs and real RSA-signed SNS messages. See [contributing](CONTRIBUTING.md) for PostgreSQL/MySQL and UUID test runs. Report vulnerabilities through [private security reporting](SECURITY.md).
|
|
210
313
|
|
|
211
314
|
## License
|
|
212
315
|
|
data/guides/README.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Bouncy guides
|
|
2
|
+
|
|
3
|
+
These guides describe the **v0.2.0** API. Start with the [README installation steps](../README.md#installation), then connect SES and schedule the jobs.
|
|
4
|
+
|
|
5
|
+
| Guide | What it covers |
|
|
6
|
+
|---|---|
|
|
7
|
+
| [Amazon SES setup](amazon-ses.md) | Credentials, IAM, SNS authorization, feedback routing and offline receiver tests |
|
|
8
|
+
| [Configuration reference](configuration.md) | Every setting, its default and the supported scope |
|
|
9
|
+
| [API reference](api.md) | Individual/batch status, model integration, queries, operations and errors |
|
|
10
|
+
| [Operations](operations.md) | Jobs, scheduling, tasks, freshness, hooks and monitoring |
|
|
11
|
+
| [Bounce handling](bounce-handling.md) | Hard/soft bounces, complaints and opt-in mailbox-full escalation |
|
|
12
|
+
| [Action Mailer delivery](delivery.md) | Modes, SMTP envelopes, bang delivery, `Bouncy.hold`, the configuration set and unsupported send paths |
|
|
13
|
+
| [Admin integration](admin.md) | Host-owned badges, recovery actions, Madmin and tenant authorization |
|
|
14
|
+
| [Recovery](recovery.md) | SES suppression removal, local holds and partial failures |
|
|
15
|
+
| [Troubleshooting](troubleshooting.md) | “The mailer says sent, but the email never arrived” and sync failures |
|
|
16
|
+
| [Migrating](migrating.md) | Importing existing evidence, cutover and rollback |
|
|
17
|
+
| [Compatibility](compatibility.md) | Ruby/Rails versions, bigint/UUID, JSON/JSONB and database locks |
|
|
18
|
+
| [Privacy and retention](privacy.md) | Stored data, pruning and erasure |
|
|
19
|
+
|
|
20
|
+
See also [release history](../CHANGELOG.md), [contributing](../CONTRIBUTING.md) and [security reporting](../SECURITY.md).
|
data/guides/admin.md
CHANGED
|
@@ -26,7 +26,7 @@ The same approach works with ActiveAdmin, RailsAdmin or a custom Rails controlle
|
|
|
26
26
|
|
|
27
27
|
## A badge on your own lists
|
|
28
28
|
|
|
29
|
-
A user or customer list should show at a glance which addresses
|
|
29
|
+
A user or customer list should show at a glance which addresses have recorded delivery restrictions. Load the statuses for the page in one query and read each row's status by its address, in any spelling:
|
|
30
30
|
|
|
31
31
|
```ruby
|
|
32
32
|
# controller
|
|
@@ -68,4 +68,4 @@ end
|
|
|
68
68
|
|
|
69
69
|
Implement that job in the host. Load the event, resolve the responsible seller/operator through authorized host records, and use an idempotency key based on the event ID. Pick an in-app/admin notification or another suitable channel. Do not send an alert to the same blocked recipient, include another tenant's history, or create an alert-mail bounce loop. Handle a pruned/missing event gracefully.
|
|
70
70
|
|
|
71
|
-
Hooks run after commit but are best effort. For guaranteed notifications, reconcile pending work in a host outbox; an enqueue exception cannot roll back an acknowledged bounce. Test the job and authorization policy with the app's actual customer/seller models before enabling it.
|
|
71
|
+
Hooks run after the outermost commit but are best effort. For guaranteed notifications, reconcile pending work in a host outbox; an enqueue exception cannot roll back an acknowledged bounce. Test the job and authorization policy with the app's actual customer/seller models before enabling it.
|