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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 53898594f15af4d949909e5c3c60f0c16a85120b56e8945d805f886317ffd60e
4
- data.tar.gz: 703257ecf36fa011180854ce6f163eda5e3a7d1a7590af6483807e0e878363fa
3
+ metadata.gz: 25620e7678f2db3d1aeae8f633654ae92205277b171eea5bddbbd3c3d4d898e1
4
+ data.tar.gz: f898b5f8180190136a031469ca2a6bd62f4930929378afedd0cae4d3f9a19f4a
5
5
  SHA512:
6
- metadata.gz: e02efcae835f67cd48aa70fd5e4ecb2234ad0d0ea86ae7cf189397f9e419f6ce2ec9c83886a413c0271580719e1f5ada7086338498024ef7911608db4b256a08
7
- data.tar.gz: 0e79f6c1ae9fa9752f08c33f395e64873dbe519a566a3458c900eac07390bef3e5dcd540a9c601ed349a5684085ef8cd3777c2cdfe6b5ac106047d166a375fea
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`: know when your app's emails bounce
1
+ # 📨 `bouncy` - Know when your app's emails bounce
2
2
 
3
- [![Tests](https://github.com/rameerez/bouncy/actions/workflows/test.yml/badge.svg)](https://github.com/rameerez/bouncy/actions/workflows/test.yml)
3
+ [![Gem Version](https://badge.fury.io/rb/bouncy.svg)](https://badge.fury.io/rb/bouncy) [![Build Status](https://github.com/rameerez/bouncy/actions/workflows/test.yml/badge.svg)](https://github.com/rameerez/bouncy/actions/workflows/test.yml)
4
4
 
5
- **Email bounce handling and suppression management for Rails.** Your email provider knows which addresses it has blocked. Now your app can know too.
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
- ```ruby
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.status("ada@example.com").reason
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
- Bouncy.release!("ada@example.com", note: "Corrected and verified with the customer")
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. Use the optional model macro when you have one:
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
- > [!TIP]
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
- ## Status
31
+ ## What you get
34
32
 
35
- **v0.1.0 is the first release.** Automated compatibility, packaged installation and host migration/rollback tests pass. Live SES lifecycle verification and independent installation validation remain outstanding. Start in observation mode and verify your sending setup before enabling interception.
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
- Initial support: **Amazon SES, including SES SMTP**, one account and region, PostgreSQL/MySQL/SQLite, Rails 7.2–8.1 and Ruby 3.3/3.4/4.0. Other providers and a hosted dashboard are outside this release.
41
+ ## Requirements
38
42
 
39
- ## What it does
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
- - Mirrors the complete SES account suppression list into two local tables.
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 the gem and the optional AWS SDKs used by the SES adapter:
51
+ Add these lines to your application's Gemfile:
53
52
 
54
53
  ```ruby
55
- # Gemfile
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 generator writes a migration and initializer. It prints the model line and scheduling instructions; your application owns its models, routes, scheduler and admin UI.
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
- Migrations follow the same conventions as `usage_credits`, `api_keys` and `nondisposable`: resolve the host's primary-key setting at migration time, use PostgreSQL JSONB or MySQL/SQLite JSON, and supply model defaults where MySQL requires them. UUID apps use native PostgreSQL UUIDs or application-generated UUID strings on MySQL/SQLite. See [database compatibility](guides/compatibility.md).
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 = ["transactional"]
80
- config.ses.all_sending_paths_listed = true # The two lists above are complete.
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
- Deploy the receiver with its allowlist before subscribing the topic. Follow the [Amazon SES setup guide](guides/amazon-ses.md), bootstrap with `bin/rails bouncy:sync`, and schedule `Bouncy::SyncJob` hourly and `Bouncy::PruneJob` daily using your existing job system.
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
- Until `config.scope` is set, Bouncy is inactive: mail is delivered untouched, every address reads as unrestricted, relations are empty, and one warning is logged. Sync, block and release raise `Bouncy::ConfigurationError`. That lets you add the gem before its environment variables exist without breaking mailer tests.
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
- Run `bin/rails bouncy:doctor` to check the sending policy, then sync to import restrictions. An unverified policy leaves new imports in observation mode; `policy_reason` explains why. If verification later fails, historical restrictions remain queryable, but the interceptor stops dropping recipients based on provider or webhook evidence until a fresh, complete, verified sync succeeds. Independent manual holds still apply. Every sync records a summary; unchanged addresses create no additional events or hooks.
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
- SMTP credentials are not AWS API credentials. The optional SDKs use the usual AWS credential chain, `config.ses.credentials`, or injected clients. Rails encrypted credentials must be passed explicitly. Requiring the gem does not query your database or call AWS.
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
- For a host migrating an existing suppression system, run `bin/rails bouncy:bootstrap` after importing legacy state and before starting mail workers. It requires a fresh complete sync with verified policy, performs one if needed, and raises on failure. `Bouncy.sync_fresh?` exposes the same health predicate the interceptor uses, so host health checks need no duplicated freshness logic. Run it explicitly at cutover, not on every application restart. Runtime fail-open behavior continues during later outages.
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
- ## Email status
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 # All effective reasons, including an independent manual hold
105
- status.knowledge # :observed, :no_known_block, :unavailable, :unconfigured
106
- status.provider_listed? # the provider lists this address in the configured scope
107
- status.policy_unverified? # listed, but the latest sync did not verify the sending policy
108
- status.policy_reason # why verification failed or is unknown; nil when verified or unlisted
109
- status.stale?
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
- # Many addresses in one query, for a list view or a bulk check:
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? # look up by any spelling
119
- statuses.blocked # only the blocked ones, as [email, status] pairs
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
- `blocked?` asks about known local restrictions. An unknown address is not certified deliverable. Recognized database outages return `false` from the boolean API and `:unavailable` from the richer status API. Programming errors still raise.
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
- Policy diagnostics use the latest sync in the address's scope, including failed checks. They do not erase historical evidence or replace the freshness check: `blocked?` can remain true while the interceptor lets mail through because verification failed or the sync is stale. Obtain a new status object after a sync to refresh its observations.
152
+ ### Stop repeated sends
126
153
 
127
- Model scopes expect the stored column to use Bouncy's trimmed lowercase comparison. For mixed-case display values, supply a persisted normalized column:
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
- bouncy :email, normalized_attribute: :canonical_email
131
- bouncy :billing_email
159
+ # An explicit exception for synchronous mail only:
160
+ Bouncy.unblocked { SupportMailer.recovery(address).deliver_now }
132
161
  ```
133
162
 
134
- The macro adds `email_blocked?`, `email_bounced?`, `email_complained?`, `email_status`, and class scopes `email_blocked`, `email_bounced`, `email_unblocked`. The last excludes blank values and makes no deliverability claim.
163
+ ### Check and send in one step
135
164
 
136
- ## Sending mail
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
- Start in `:log`. After reviewing a complete import and testing delivery in staging, set `config.interception = :drop`. `:off` disables interception.
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
- In drop mode, Bouncy checks both headers and the SMTP envelope, removes only blocked recipients, and prevents normal delivery if no recipients remain. Provider-derived dropping needs a fresh, complete sync; local administrative holds remain effective independently. Log mode applies the same rule, so its `skipped` events (`would_drop`, `stale_provider_evidence`) preview exactly what drop mode would do. A recognized database outage leaves the original message intact.
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
- # A deliberate exception for synchronous mail in this execution context:
144
- Bouncy.unblocked { SupportMailer.recovery(address).deliver_now }
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
- This bypass does not travel with an enqueued job. Bang delivery methods bypass Mail's `perform_deliveries` check and may attempt transport with an empty envelope, causing an error. Direct SDK sends and custom senders need their own integration. See [delivery boundaries](guides/delivery.md).
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
- ## Support and recovery
190
+ ### Help someone recover
150
191
 
151
192
  ```ruby
152
- Bouncy.block!("ada@example.com", note: "Hold while support investigates", actor: "support:42")
153
- Bouncy.release!("ada@example.com", at: :local) # Remove local policy; retain provider evidence
154
- Bouncy.release!("ada@example.com", note: "Verified recovery", actor: "support:42")
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 recovery enumerates exact provider variants and confirms removal before clearing local state. Failures raise and leave local evidence available for review. A concurrent newer change can raise `Bouncy::ReleaseConflict`; review the latest state before retrying. Remote calls and your database cannot be one atomic transaction.
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
- `Bouncy::ReleaseFailed#outcomes` identifies removed, already absent, failed and unattempted exact variants. The recovery audit records these outcomes too; a partial remote success never becomes a local success notice.
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
- Releasing an SES account restriction can affect sister apps in that account and region. Your host application must authorize the action and confirm appropriate permission before resuming contact after a complaint. Release does not resend a message or repair a mailbox.
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**. An [optional admin recipe](guides/admin.md) shows how host-owned glue uses these APIs.
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
- ## Repeated soft bounces
212
+ ### Handle repeated mailbox-full bounces
166
213
 
167
- Soft bounces are recorded by default. Opt in to a temporary local hold for repeated SES `MailboxFull` events. Content, size, attachment, general and unknown failures stay record-only because they do not establish a mailbox problem:
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 # nil (the default) keeps soft bounces record-only
172
- config.soft_bounce_window = 30.days # occurrences older than this stop counting
173
- config.soft_bounce_block_for = 30.days # how long the resulting hold lasts
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
- Thresholds must be between 1 and 50. The window really rolls: each occurrence time is retained, and one that ages out stops counting rather than accumulating forever. Redelivered notifications count once. The resulting hold reads as `:soft_bounces`, is local policy like a manual hold, and so applies even while a provider sync is stale — the provider never listed this address, your application did. `Bouncy.release!` clears the history and fences delayed pre-release soft feedback. Out-of-order events inside the current window still count; expired events cannot start a new hold.
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
- Hard bounces and complaints keep their own reason when soft evidence accumulates underneath them.
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 recorded: #{event.id}") }
187
- config.record_deliveries = true # Optional normalized server-acceptance events
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
- Hooks run after commit. A hook failure emits `hook_error.bouncy`; it does not undo ingestion. Hooks are best effort: use a host outbox for guaranteed external work.
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
- Events default to 90-day retention. Raw payloads, subjects and message bodies are not stored. Inactive state rows retain release ordering metadata. `Bouncy.forget!(email)` erases local state and history only; it does not release SES, and a later sync may import the restriction again.
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 (90% line and branch minimum), actual generated migrations, SDK stubs and real RSA-signed SNS messages. See [contributing](CONTRIBUTING.md) for the compatibility matrix and isolated database setup.
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 cannot be reached. Load the statuses for the page in one query and read each row's status by its address, in any spelling:
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.