bouncy 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +32 -0
- data/LICENSE.txt +21 -0
- data/README.md +213 -0
- data/config/routes.rb +5 -0
- data/guides/admin.md +71 -0
- data/guides/amazon-ses.md +63 -0
- data/guides/bounce-handling.md +27 -0
- data/guides/compatibility.md +42 -0
- data/guides/delivery.md +21 -0
- data/guides/migrating.md +15 -0
- data/guides/privacy.md +13 -0
- data/guides/recovery.md +28 -0
- data/guides/troubleshooting.md +46 -0
- data/lib/bouncy/configuration.rb +80 -0
- data/lib/bouncy/engine.rb +15 -0
- data/lib/bouncy/event.rb +22 -0
- data/lib/bouncy/identity.rb +18 -0
- data/lib/bouncy/ingestor.rb +94 -0
- data/lib/bouncy/interceptor.rb +57 -0
- data/lib/bouncy/model.rb +30 -0
- data/lib/bouncy/providers/base.rb +24 -0
- data/lib/bouncy/providers/ses.rb +197 -0
- data/lib/bouncy/providers/ses_parser.rb +112 -0
- data/lib/bouncy/providers/sns_verifier.rb +97 -0
- data/lib/bouncy/prune_job.rb +13 -0
- data/lib/bouncy/reconciler.rb +150 -0
- data/lib/bouncy/record.rb +16 -0
- data/lib/bouncy/recovery.rb +56 -0
- data/lib/bouncy/scope_lock.rb +52 -0
- data/lib/bouncy/status.rb +59 -0
- data/lib/bouncy/status_set.rb +31 -0
- data/lib/bouncy/store.rb +30 -0
- data/lib/bouncy/suppression.rb +33 -0
- data/lib/bouncy/sync_job.rb +13 -0
- data/lib/bouncy/version.rb +5 -0
- data/lib/bouncy/webhook.rb +40 -0
- data/lib/bouncy.rb +185 -0
- data/lib/generators/bouncy/install_generator.rb +37 -0
- data/lib/generators/bouncy/templates/create_bouncy_tables.rb.erb +97 -0
- data/lib/generators/bouncy/templates/initializer.rb.tt +23 -0
- data/lib/tasks/bouncy.rake +36 -0
- metadata +126 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 53898594f15af4d949909e5c3c60f0c16a85120b56e8945d805f886317ffd60e
|
|
4
|
+
data.tar.gz: 703257ecf36fa011180854ce6f163eda5e3a7d1a7590af6483807e0e878363fa
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e02efcae835f67cd48aa70fd5e4ecb2234ad0d0ea86ae7cf189397f9e419f6ce2ec9c83886a413c0271580719e1f5ada7086338498024ef7911608db4b256a08
|
|
7
|
+
data.tar.gz: 0e79f6c1ae9fa9752f08c33f395e64873dbe519a566a3458c900eac07390bef3e5dcd540a9c601ed349a5684085ef8cd3777c2cdfe6b5ac106047d166a375fea
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [0.1.0] - 2026-09-09
|
|
6
|
+
|
|
7
|
+
Initial release of email bounce handling and suppression management for Rails, with Amazon SES support.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Address-keyed local restrictions and event history, including recipients without a User record.
|
|
12
|
+
- Complete SES suppression-list reconciliation with account/region policy checks, exact-case provider identifiers and stale-mirror diagnostics.
|
|
13
|
+
- Signed SNS feedback ingestion with exact-topic authorization, bounded certificate fetching, per-recipient deduplication and replay protection.
|
|
14
|
+
- Audited local manual holds and provider-aware recovery. Provider release checks exact address variants before clearing local evidence; local holds remain independent of provider policy.
|
|
15
|
+
- Normal Action Mailer interception, including Bcc and explicit SMTP envelopes. New installs default to observation mode; provider-derived dropping requires a fresh verified mirror.
|
|
16
|
+
- Optional `bouncy :email` model predicates/scopes, individual status, and batch status lookup for lists. Unavailable batches retain requested addresses with explicit knowledge.
|
|
17
|
+
- Opt-in repeated MailboxFull escalation with a bounded rolling window and temporary hold. Other soft failures remain record-only.
|
|
18
|
+
- Shared AWS credential configuration, explicit bootstrap, read-only setup/doctor tasks, synchronization and retention jobs, and after-commit hooks.
|
|
19
|
+
- Adaptive install migrations for bigint/UUID and PostgreSQL JSONB or MySQL/SQLite JSON. Madmin and other admin presentation remain host-owned.
|
|
20
|
+
|
|
21
|
+
### Compatibility and boundaries
|
|
22
|
+
|
|
23
|
+
- Ruby 3.3, 3.4 and 4.0; Rails 7.2–8.1; PostgreSQL, MySQL and SQLite.
|
|
24
|
+
- Core requires Rails. The SES adapter uses optional AWS SDK gems installed by the host.
|
|
25
|
+
- A known restriction is not a deliverability verdict. Provider outages and stale mirrors can suspend interception; direct SDK sends and bang delivery methods have separate boundaries documented in the guides.
|
|
26
|
+
- No raw message payload storage, automatic AWS provisioning or built-in admin UI.
|
|
27
|
+
|
|
28
|
+
### Fixed during release preparation
|
|
29
|
+
|
|
30
|
+
- Empty or already consumed webhook streams return 400 instead of raising a server error.
|
|
31
|
+
|
|
32
|
+
Validation: the automated compatibility matrix and packaged installation smoke pass. Live provider acceptance and independent installation validation remain outstanding.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 rameerez
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# 📨 `bouncy`: know when your app's emails bounce
|
|
2
|
+
|
|
3
|
+
[](https://github.com/rameerez/bouncy/actions/workflows/test.yml)
|
|
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.
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
Bouncy.blocked?("ada@example.com")
|
|
11
|
+
# => true
|
|
12
|
+
|
|
13
|
+
Bouncy.status("ada@example.com").reason
|
|
14
|
+
# => :hard_bounce
|
|
15
|
+
|
|
16
|
+
Bouncy.release!("ada@example.com", note: "Corrected and verified with the customer")
|
|
17
|
+
```
|
|
18
|
+
|
|
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:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
class User < ApplicationRecord
|
|
23
|
+
bouncy :email
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
user.email_blocked?
|
|
27
|
+
User.email_blocked
|
|
28
|
+
```
|
|
29
|
+
|
|
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.
|
|
32
|
+
|
|
33
|
+
## Status
|
|
34
|
+
|
|
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.
|
|
36
|
+
|
|
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.
|
|
38
|
+
|
|
39
|
+
## What it does
|
|
40
|
+
|
|
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.
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
Add the gem and the optional AWS SDKs used by the SES adapter:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
# Gemfile
|
|
56
|
+
gem "bouncy", "~> 0.1.0"
|
|
57
|
+
gem "aws-sdk-sesv2"
|
|
58
|
+
gem "aws-sdk-sns"
|
|
59
|
+
gem "aws-sdk-sts"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
bundle install
|
|
64
|
+
bin/rails generate bouncy:install
|
|
65
|
+
bin/rails db:migrate
|
|
66
|
+
```
|
|
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.
|
|
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).
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
# config/initializers/bouncy.rb
|
|
74
|
+
Bouncy.configure do |config|
|
|
75
|
+
config.scope = "ses:123456789012:us-east-1:account"
|
|
76
|
+
config.ses.region = "us-east-1"
|
|
77
|
+
config.ses.topic_arns = ["arn:aws:sns:us-east-1:123456789012:feedback"]
|
|
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.
|
|
81
|
+
config.interception = :log
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# config/routes.rb
|
|
85
|
+
mount Bouncy::Engine => "/bouncy"
|
|
86
|
+
```
|
|
87
|
+
|
|
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.
|
|
89
|
+
|
|
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.
|
|
91
|
+
|
|
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.
|
|
93
|
+
|
|
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.
|
|
95
|
+
|
|
96
|
+
|
|
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.
|
|
98
|
+
|
|
99
|
+
## Email status
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
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
|
|
112
|
+
|
|
113
|
+
Bouncy.blocked
|
|
114
|
+
Bouncy.events.for("ada@example.com").recent
|
|
115
|
+
|
|
116
|
+
# Many addresses in one query, for a list view or a bulk check:
|
|
117
|
+
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
|
|
121
|
+
```
|
|
122
|
+
|
|
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.
|
|
124
|
+
|
|
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.
|
|
126
|
+
|
|
127
|
+
Model scopes expect the stored column to use Bouncy's trimmed lowercase comparison. For mixed-case display values, supply a persisted normalized column:
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
bouncy :email, normalized_attribute: :canonical_email
|
|
131
|
+
bouncy :billing_email
|
|
132
|
+
```
|
|
133
|
+
|
|
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.
|
|
135
|
+
|
|
136
|
+
## Sending mail
|
|
137
|
+
|
|
138
|
+
Start in `:log`. After reviewing a complete import and testing delivery in staging, set `config.interception = :drop`. `:off` disables interception.
|
|
139
|
+
|
|
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.
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
# A deliberate exception for synchronous mail in this execution context:
|
|
144
|
+
Bouncy.unblocked { SupportMailer.recovery(address).deliver_now }
|
|
145
|
+
```
|
|
146
|
+
|
|
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).
|
|
148
|
+
|
|
149
|
+
## Support and recovery
|
|
150
|
+
|
|
151
|
+
```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")
|
|
155
|
+
```
|
|
156
|
+
|
|
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.
|
|
158
|
+
|
|
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.
|
|
160
|
+
|
|
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.
|
|
162
|
+
|
|
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.
|
|
164
|
+
|
|
165
|
+
## Repeated soft bounces
|
|
166
|
+
|
|
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:
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
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
|
|
174
|
+
end
|
|
175
|
+
```
|
|
176
|
+
|
|
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.
|
|
178
|
+
|
|
179
|
+
Hard bounces and complaints keep their own reason when soft evidence accumulates underneath them.
|
|
180
|
+
|
|
181
|
+
## Hooks and retention
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
Bouncy.configure do |config|
|
|
185
|
+
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
|
|
188
|
+
end
|
|
189
|
+
```
|
|
190
|
+
|
|
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.
|
|
192
|
+
|
|
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.
|
|
194
|
+
|
|
195
|
+
## Development
|
|
196
|
+
|
|
197
|
+
```sh
|
|
198
|
+
bin/setup
|
|
199
|
+
bundle exec rake test
|
|
200
|
+
bundle exec appraisal install
|
|
201
|
+
bundle exec appraisal rake test
|
|
202
|
+
DATABASE_URL=postgresql:///bouncy_test bundle exec rake test
|
|
203
|
+
```
|
|
204
|
+
|
|
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.
|
|
210
|
+
|
|
211
|
+
## License
|
|
212
|
+
|
|
213
|
+
Available as open source under the [MIT License](LICENSE.txt).
|
data/config/routes.rb
ADDED
data/guides/admin.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Host-owned admin integration
|
|
2
|
+
|
|
3
|
+
Bouncy exposes ordinary ActiveRecord models and service methods. Your application owns the admin framework, authentication, tenant filtering, routes, presentation and tests. There is no Madmin code in the gem.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
@restrictions = Bouncy.blocked.order(blocked_at: :desc)
|
|
7
|
+
@events = Bouncy.events.for(authorized_address).recent.limit(50)
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
This is an account scope, not tenant authorization. Resolve and authorize the address in your app before displaying history. An email match alone does not authorize disclosure of unrelated messages. Escape provider diagnostics as untrusted text.
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
# Inside an authorized host recovery action:
|
|
14
|
+
Bouncy.release!(authorized_address, note: params.require(:note), actor: "support:#{current_support_user.id}")
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Show the provider account/region and reason before submission. Require explicit confirmation before complaint recovery. Display typed failures; do not report success after a refused or conflicting release. Recovery changes a shared restriction and does not resend mail.
|
|
18
|
+
|
|
19
|
+
## Madmin recipe
|
|
20
|
+
|
|
21
|
+
Write or generate the resource **in the host app**, using its installed Madmin version. Point it at `Bouncy::Suppression`, apply the proper scope, and show email, effective reasons, observation age and recent event. A host controller action calls the recovery API after authorization.
|
|
22
|
+
|
|
23
|
+
Disable the stock destructive action: deleting a row loses release metadata and does not release SES. Keep local erasure (`Bouncy.forget!`) and recovery separate, with distinct explanations. Test unauthorized access, shared-account effects and provider failures in the host.
|
|
24
|
+
|
|
25
|
+
The same approach works with ActiveAdmin, RailsAdmin or a custom Rails controller. No gem framework detection or admin generator is needed.
|
|
26
|
+
|
|
27
|
+
## A badge on your own lists
|
|
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:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
# controller
|
|
33
|
+
@statuses = Bouncy.statuses(@users.map(&:email))
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```erb
|
|
37
|
+
<%# view %>
|
|
38
|
+
<% status = @statuses[user.email] %>
|
|
39
|
+
<% if status.blocked? %>
|
|
40
|
+
<span class="badge" title="<%= status.reasons.join(", ") %>"><%= status.reason.to_s.humanize %></span>
|
|
41
|
+
<% end %>
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
On a detail page, `Bouncy.status(email).record` is the `Bouncy::Suppression` row, which is what your admin's release page is keyed by.
|
|
45
|
+
|
|
46
|
+
Describe recorded restrictions, not guaranteed delivery or interception: provider-derived
|
|
47
|
+
restrictions may remain visible while stale synchronization makes interception fail open.
|
|
48
|
+
Keep recovery links and account-wide history behind your host's admin authorization.
|
|
49
|
+
Unconfigured or unavailable batch results retain each requested valid address with that
|
|
50
|
+
knowledge, so an empty enumeration cannot be mistaken for an empty input. Addresses not
|
|
51
|
+
requested in the batch return a no-known-block status without a database lookup; include
|
|
52
|
+
every address you intend to check. Lazy event and policy diagnostics can issue additional
|
|
53
|
+
queries; the single-query guarantee covers the batch's restriction rows and predicates.
|
|
54
|
+
|
|
55
|
+
## Address correction in the application
|
|
56
|
+
|
|
57
|
+
Show a short status notice only after the host has authenticated and authorized the contact. For example: “We couldn't deliver email to this address. Check it in your contact settings.” Link to the host's existing verified address-change flow. Do not expose suppression lookup on a public password-reset form or reveal whether an unrelated address exists.
|
|
58
|
+
|
|
59
|
+
Changing a contact's email is separate from releasing the old address's shared provider restriction. Update the authorized host record and use its usual verification policy; do not automatically release a complaint or another tenant's address.
|
|
60
|
+
|
|
61
|
+
## Notify the responsible operator
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
Bouncy.configure do |config|
|
|
65
|
+
config.after_block = ->(event) { EmailRestrictionNoticeJob.perform_later(event.id) }
|
|
66
|
+
end
|
|
67
|
+
```
|
|
68
|
+
|
|
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
|
+
|
|
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.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Amazon SES bounce handling in Rails
|
|
2
|
+
|
|
3
|
+
Use this when a Rails mailer succeeds but SES refuses delivery because an address is on its suppression list. Bouncy works with SES SMTP and SDK-based Action Mailer delivery; management calls use separate AWS API credentials.
|
|
4
|
+
|
|
5
|
+
## Supported sending policy
|
|
6
|
+
|
|
7
|
+
The first release supports one AWS account, one region and account-level suppression of both bounces and complaints. Configure `ses:<account>:<region>:account`. The adapter verifies the AWS caller account and SES client region, reads the enabled account reasons, and checks suppression overrides on supplied configuration sets, including defaults found on configured identities.
|
|
8
|
+
|
|
9
|
+
List all sending identities and configuration sets, then set `all_sending_paths_listed = true` to confirm the lists are complete. Dynamic per-message overrides, SES tenants and other accounts are not inferred from a default mailer. Unknown or conflicting policy leaves sync in observation mode: the list is mirrored and provider-derived interception is suspended. Historical restrictions and independent manual holds remain. Read `policy_reason` in doctor, the sync summary or `Bouncy.status(address)` for the explanation. Bouncy cannot discover every future sending decision.
|
|
10
|
+
|
|
11
|
+
## Connect an existing SNS topic
|
|
12
|
+
|
|
13
|
+
1. Configure the exact topic allowlist and mount `/bouncy`. Deploy `POST /bouncy/webhooks/ses` before subscribing.
|
|
14
|
+
2. Review the topic's publisher policy. Allow the SES service principal only with the intended `AWS:SourceAccount` and `AWS:SourceArn` conditions. Preserve existing statements and destinations. A signature alone is insufficient if unrelated publishers can write to the authorized topic.
|
|
15
|
+
3. Subscribe the public HTTPS URL. Use normal SNS envelopes, not raw message delivery. Bouncy verifies the signature and confirms with the authenticated topic and token.
|
|
16
|
+
4. Connect bounce and complaint feedback through existing identity notification settings or configuration-set destinations. Inspect existing forwarding/defaults before changing them. Delivery events are optional.
|
|
17
|
+
5. Confirm the subscription is active and test feedback. Configure suitable SNS retries and optionally a dead-letter queue. Sync cannot reconstruct lost delivery or soft-bounce history.
|
|
18
|
+
6. Run `bouncy:doctor` and `bouncy:sync`. Schedule `Bouncy::SyncJob` hourly and `Bouncy::PruneJob` daily. Check that workers actually execute them.
|
|
19
|
+
7. Test a mixed-recipient message in staging. Enable `:drop` after reviewing scope and imported restrictions.
|
|
20
|
+
|
|
21
|
+
`bouncy:ses:setup` prints a read-only plan and diagnostic reads. It never creates topics, alters policies, overwrites settings, sends test mail or edits schedules. `APPLY` is rejected. Doctor reports publisher-policy review and write authorization as manual/unknown checks, rather than trying a mutation.
|
|
22
|
+
|
|
23
|
+
## IAM and SDK dependencies
|
|
24
|
+
|
|
25
|
+
Add `aws-sdk-sesv2`, `aws-sdk-sns`, and `aws-sdk-sts` to the host bundle. These load lazily.
|
|
26
|
+
|
|
27
|
+
Runtime reads use `ses:ListSuppressedDestinations`, `ses:GetSuppressedDestination`, `ses:GetAccount`, `sts:GetCallerIdentity`, plus `ses:GetEmailIdentity` and `ses:GetConfigurationSet` for configured policies. Recovery adds `ses:DeleteSuppressedDestination`. Confirmation uses `sns:ConfirmSubscription`; doctor uses `sns:GetTopicAttributes`. Restrict resource-scoped actions to intended resources where AWS supports it. This release never calls `PutSuppressedDestination` or provisioning APIs.
|
|
28
|
+
|
|
29
|
+
Set `config.ses.credentials` to an AWS credential provider to share the same credentials across SES, SNS and STS. For `aws-actionmailer-ses`, pass the credential object and region from the mailer's `ses_settings`; the AWS default chain does not read Rails encrypted credentials. [AWS credential-provider documentation](https://docs.aws.amazon.com/sdk-for-ruby/v3/developer-guide/credential-providers.html).
|
|
30
|
+
|
|
31
|
+
Inject SDK clients through `config.ses.client`, `sns_client`, and `sts_client` if needed. Keep account and region consistent. Default clients use bounded connection/read timeouts and retries. Never print credentials in setup output.
|
|
32
|
+
|
|
33
|
+
## Testing your mounted receiver
|
|
34
|
+
|
|
35
|
+
A host test that posts to the mounted route would otherwise download Amazon's signing certificate over the network. `config.ses.sns_message_verifier` injects the signature verifier. The following test subclass changes only certificate retrieval while retaining RSA verification:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
class LocalCertificate < Aws::SNS::MessageVerifier
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def https_get(*) = OpenSSL::X509::Certificate.new(File.read("test/fixtures/sns.pem")).to_pem
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
Bouncy.configuration.ses.sns_message_verifier = LocalCertificate.new
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Topic authorization, the certificate-URL check and the real RSA signature check all still run against your configured allowlist and region, so this seam cannot hide a receiver that would accept a foreign topic in production. Sign fixtures with a locally generated key and assert that a valid signature on an unlisted `TopicArn` is rejected.
|
|
48
|
+
|
|
49
|
+
## Failure and ordering
|
|
50
|
+
|
|
51
|
+
The webhook commits each recipient's event and address transition before acknowledging. Retries deduplicate independently. Authentication failure returns 401, malformed input 400, oversized bodies 413 and recognized transient provider/database failures 503.
|
|
52
|
+
|
|
53
|
+
Bodies are bounded to 2 MiB before parsing. Set a corresponding proxy limit. Certificate retrieval permits strict regional SNS HTTPS URLs, verifies TLS, rejects redirects and caps responses. Transport outages remain retryable.
|
|
54
|
+
|
|
55
|
+
Sync enumerates all pages without a time filter. Failure or unsupported policy is not a successful empty list. Clearing missing restrictions requires complete enumeration, exact identifier checks and unchanged local state. Provider pagination is not an atomic snapshot; remote writers can change state after any observation. Inspect freshness and failures.
|
|
56
|
+
|
|
57
|
+
SES management addresses are case-sensitive. Bouncy retains exact spelling independently of lowercase local lookup keys and releases all observed variants. A lowercase NotFound does not establish that another case variant is absent.
|
|
58
|
+
|
|
59
|
+
Ordinary complaint notifications can name candidate recipients when the mailbox provider redacts the complainer. Bouncy blocks locally only when exactly one recipient is named; otherwise it records candidates and lets the next complete sync establish the restriction from the provider's own list. Account-list refusal events are hints rather than new mailbox failures. Complaint subtypes `OnAccountSuppressionList` and `OnTenantSuppressionList` describe existing suppression, not a new complaint: the former is a provider-list hint, the latter an unsupported-scope diagnostic. Unknown complaint subtypes remain diagnostics. These distinctions follow the [SES notification contract](https://docs.aws.amazon.com/ses/latest/dg/notification-contents.html). Allow up to the next scheduled sync for account-list hints in the initial implementation.
|
|
60
|
+
|
|
61
|
+
Reconciliation is idempotent: an identical address observation refreshes its check time without address events, hooks or version bumps. Changed provider timestamps are saved and advance the row version so concurrent recovery cannot clear newer evidence; they create no restriction event when the address and reason are unchanged. Every run still records a sync summary. Partial lists can refresh listed variants but cannot erase missing ones. Rows with neither provider evidence nor a webhook-derived block are never looked up at the provider. Only one sync runs per scope at a time; a second one fails immediately with `Bouncy::ProviderError` instead of queueing behind a run that may be waiting on AWS.
|
|
62
|
+
|
|
63
|
+
Only inject a trusted verifier that performs signature verification. Bouncy still checks topic authorization and the certificate URL, but a custom verifier controls signature checking; keep offline substitutes in tests.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Handling bounced emails in Rails
|
|
2
|
+
|
|
3
|
+
Your sending provider can refuse a known restricted address while your application keeps treating it as a normal contact. Bouncy embeds that provider state in Rails so a support person can inspect the reason, the app can avoid repeated attempts, and authorized recovery can reach the provider before clearing local evidence.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
Bouncy.blocked?("invitee@example.com")
|
|
7
|
+
Bouncy.status("invitee@example.com").reasons
|
|
8
|
+
Bouncy.events.for("invitee@example.com").recent
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
No User record is needed. Optional `bouncy :email` model integration adds predicates and scopes when you do have a customer/contact model. Keep its stored comparison column normalized consistently with the API.
|
|
12
|
+
|
|
13
|
+
Start with [SES setup](amazon-ses.md). Both SES SMTP and Action Mailer SDK delivery can use the same management API mirror. API credentials are separate from SMTP credentials. Import historical restrictions before enabling delivery interception.
|
|
14
|
+
|
|
15
|
+
Webhooks handle new events promptly; recurring full sync covers prior history, shared-account changes, missed notifications and console releases. Neither mechanism proves every mailbox is reachable. Soft bounces are record-only unless you configure a threshold, an ordinary complaint blocks locally only when it names a single recipient, and message-size failures never globally invalidate an address. Suppression-list refusal notices are recorded separately from new complaints.
|
|
16
|
+
|
|
17
|
+
## Repeated soft bounces
|
|
18
|
+
|
|
19
|
+
`config.soft_bounce_threshold` (default `nil`) turns repeated SES `MailboxFull` bounces into a local hold once that many fall inside `config.soft_bounce_window`, lasting `config.soft_bounce_block_for`. Occurrence times are retained per address and bounded, so the window rolls instead of accumulating a total that only grows, and redelivered notifications count once because event deduplication runs first.
|
|
20
|
+
|
|
21
|
+
This is your policy, not the provider's: the address is not on the provider's suppression list, so the hold applies regardless of sync freshness, exactly like a manual hold, and `Bouncy.release!` clears the counters along with the hold. Choose an integer threshold from 1 to 50. Content, size, attachment and unknown failures never count toward it. The adapter marks eligible observations explicitly; the core does not infer eligibility from an arbitrary negative event.
|
|
22
|
+
|
|
23
|
+
Migrating from an existing threshold of your own? Preserve timed holds with their original expiry; preserve indefinite holds as explicit manual holds with a migration note. Review differences between the old rule and mailbox-only escalation instead of inventing an expiry at cutover. See [migrating](migrating.md).
|
|
24
|
+
|
|
25
|
+
For the support workflow, see [admin integration](admin.md) and [recovery](recovery.md). For someone who says the email never arrived, follow [troubleshooting](troubleshooting.md) rather than assuming suppression is always the cause.
|
|
26
|
+
|
|
27
|
+
Bouncy does not manage marketing subscriptions, transport retries, open/click tracking or a complete outbound-message archive. Keep the tools you use for those jobs. A provider console may be enough when you do not need app-visible state or embedded recovery. There is no requirement to adopt another dependency merely to receive a bounce notification.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Ruby, Rails and database compatibility
|
|
2
|
+
|
|
3
|
+
Bouncy's compatibility suite covers Ruby 3.3, 3.4 and 4.0 with Rails 7.2, 8.0 and 8.1. Each combination runs on SQLite, PostgreSQL and MySQL with bigint and UUID keys. Rails 7.2 uses Minitest 5 because Active Support constrains that dependency; Rails 8 uses Minitest 6.
|
|
4
|
+
|
|
5
|
+
The September 8, 2026 local run passed all 54 combinations using Ruby 3.3.5/3.4.7/4.0.5, Rails 7.2.3.2/8.0.5.1/8.1.3.1, PostgreSQL 18.3 and MySQL 8.4.11. Every combination passed the 90% line and branch coverage gates. CI is configured to repeat this matrix; a local pass is not a claim that a hosted CI run or a live SES lifecycle has occurred.
|
|
6
|
+
|
|
7
|
+
The runtime JSON gem is constrained below version 3 because the supported Rails releases still pass a positional options hash to `JSON.parse`. Optional AWS SDKs load lazily and are host dependencies. There is no database or AWS lookup during boot.
|
|
8
|
+
|
|
9
|
+
## Migrations
|
|
10
|
+
|
|
11
|
+
The install generator uses `templates/create_bouncy_tables.rb.erb`, the house `primary_and_foreign_key_types` helper and adapter-specific JSON helpers. The generated migration is self-contained; it calls no Bouncy model or provider API.
|
|
12
|
+
|
|
13
|
+
The host setting is resolved when the migration runs:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# config/application.rb
|
|
17
|
+
config.generators do |generator|
|
|
18
|
+
generator.orm :active_record, primary_key_type: :uuid
|
|
19
|
+
end
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Without a setting, Rails chooses its standard primary key. Explicit bigint/integer settings remain intact. With UUIDs, PostgreSQL uses native UUID columns and its standard generated default. SQLite and MySQL use 36-character strings; the gem supplies UUIDs at record creation. Provided IDs are preserved. There are no User foreign keys.
|
|
23
|
+
|
|
24
|
+
| Database | JSON storage | JSON defaults | UUID storage |
|
|
25
|
+
|---|---|---|---|
|
|
26
|
+
| PostgreSQL | JSONB | Database and model defaults | Native UUID |
|
|
27
|
+
| MySQL 8.4 | JSON | Model defaults; no literal SQL JSON default | 36-character string |
|
|
28
|
+
| SQLite | JSON | Database and model defaults | 36-character string |
|
|
29
|
+
|
|
30
|
+
The gem's JSON attributes inherit the database's native type. Every new record gets an independent hash/array default. No adapter probe or attribute-schema query is performed at boot. Unsupported database adapters raise before reconciliation.
|
|
31
|
+
|
|
32
|
+
MySQL identity columns explicitly use `utf8mb4_bin`. Case/accent-insensitive equality must not collapse addresses beyond Bouncy's trimmed lowercase policy. Scope and address limits keep compound indexes within supported database key limits. Use an application-normalized host column for model scopes.
|
|
33
|
+
|
|
34
|
+
The tests execute the generated migration, persist JSON and UUID records, and reverse it. Tests also run the full lifecycle using UUID primary keys, including event ordering, recovery and concurrency. Existing released migrations must remain immutable; later schema changes require additive migrations.
|
|
35
|
+
|
|
36
|
+
## Synchronization
|
|
37
|
+
|
|
38
|
+
PostgreSQL uses a session advisory lock; MySQL uses a named connection lock. Both are held on a checked-out connection across the provider scan and local reconciliation, with cleanup on failure. Every adapter tries the lock without waiting: a second sync for the same scope raises `Bouncy::ProviderError` immediately rather than queueing behind a run that may be waiting on the provider. Tests verify exclusion from an independent process as well as separate connections.
|
|
39
|
+
|
|
40
|
+
SQLite uses an OS file lock alongside the database file. All workers must access the same database file and lock path on a filesystem that supports `flock`. In-memory SQLite only exists inside its process and uses a process-specific lock file. Network filesystems and replicated SQLite arrangements need separate validation.
|
|
41
|
+
|
|
42
|
+
Background scheduling remains host-owned. Normal Action Mailer delivery and a correctly configured job worker are tested; custom senders and bang delivery methods have [explicit boundaries](delivery.md).
|
data/guides/delivery.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Action Mailer delivery boundaries
|
|
2
|
+
|
|
3
|
+
Supported paths are normal `deliver_now` and `deliver_later` when the queued job executes in a configured worker. Bouncy registers its interceptor through the Rails engine.
|
|
4
|
+
|
|
5
|
+
The interceptor calculates the recipient decision before changing the message. It checks To/Cc/Bcc and the actual SMTP envelope, preserves explicit subsets, persists skip evidence, then removes blocked recipients in `:drop` mode. Recognized database outages preserve original headers, envelope and delivery flag.
|
|
6
|
+
|
|
7
|
+
`:log` observes while allowing delivery. `:off` disables interception. Manual holds apply independently; provider-derived dropping requires a recent complete sync. An interrupted worker or unsupported policy must not enforce an old mirror forever. Log mode applies the same freshness rule, so each `skipped` event's `would_drop` and `stale_provider_evidence` flags show exactly what drop mode would have done. An unconfigured scope disables interception entirely and logs one warning.
|
|
8
|
+
|
|
9
|
+
`Bouncy.unblocked { ... }` is an execution-context exception with nested/exception-safe cleanup. It only wraps synchronous delivery; it does not serialize into jobs or apply to another process.
|
|
10
|
+
|
|
11
|
+
## Bang methods and other senders
|
|
12
|
+
|
|
13
|
+
Mail's `deliver!` invokes interceptors but bypasses `perform_deliveries`. Therefore `deliver_now!` and `deliver_later!` can attempt transport after all recipients have been removed. Built-in Mail transports reject the empty envelope. These methods are outside the supported guarantee.
|
|
14
|
+
|
|
15
|
+
Direct SDK sends, custom network clients and sister apps do not run this interceptor. Their account restrictions can still appear in a later sync. Custom transports must honor `smtp_envelope_to`; reconstructing recipients needs host integration and tests.
|
|
16
|
+
|
|
17
|
+
Review ordering if another host interceptor rewrites or adds recipients after Bouncy. An app that changes recipients later must apply the check at its final recipient decision and test the transport path.
|
|
18
|
+
|
|
19
|
+
## Host tests
|
|
20
|
+
|
|
21
|
+
Use synthetic blocked and allowed recipients in one message. Capture the actual transport envelope, not just rendered headers. Exercise Bcc, an explicit envelope different from headers, an all-blocked message and queued delivery. Simulate a database outage after lookup to prove no partial mutation occurs.
|
data/guides/migrating.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Migrating an existing suppression table
|
|
2
|
+
|
|
3
|
+
Migration and cutover belong in the implementing app. Bouncy's install migration creates its own two tables; it does not inspect application models or silently replace an existing webhook/interceptor.
|
|
4
|
+
|
|
5
|
+
1. Inventory every existing writer: webhook controllers, jobs, support actions, interceptors and console operations. Record the source account, region, reason mapping, timestamps and existing soft-hold policy.
|
|
6
|
+
2. Install the additive schema. Write a host data migration using migration-local ActiveRecord classes or explicit SQL pinned to that schema. Never call live Bouncy/host models, callbacks, jobs or AWS from a historical migration.
|
|
7
|
+
3. Preserve manual holds independently. Map complaints as complaints and hard bounces as hard bounces. Account-list refusal is evidence of an existing restriction. Soft trackers are history; active legacy soft holds require an explicit preserved policy, not an accidental new threshold engine. Preserve original expiries for timed soft holds. Preserve indefinite holds as explicit manual holds with a migration note; do not invent a 30-day expiry. New escalation applies only to mailbox-specific evidence. Document that change if the old rule included content failures.
|
|
8
|
+
4. Use stable source IDs to make imported events idempotent. Preserve known provider feedback IDs using Bouncy's recipient-level dedupe identity so retried historical feedback does not count again. Preserve distinct occurrences, exact provider addresses when available, and relevant event times. Missing historical case variants are resolved by complete provider enumeration before recovery.
|
|
9
|
+
5. Rehearse the import against isolated synthetic fixtures, then run `bouncy:bootstrap` before starting mail workers. Unlike observation-only sync, bootstrap raises unless the mirror is fresh, complete and policy-verified. Compare every predicate, support action and customer-facing status that depended on the old table.
|
|
10
|
+
6. Quiesce old writers for cutover, or implement and test dual-write delivery with deterministic identity. Do not replace readers while another process continues writing exclusively to the old table.
|
|
11
|
+
7. Rehearse rollback, including restrictions and releases created after cutover. Keeping an old table is not a rollback plan if it missed later changes. Keep old schema until the agreed window ends. Do not delete imported rows in a schema rollback: they may now contain newer state. Fail conflicting imports and stop writers before copying in either direction.
|
|
12
|
+
|
|
13
|
+
Use the app's existing public webhook URL where possible. Test routing alongside unrelated webhooks, and preserve the existing interception mode deliberately. Host admin resources call Bouncy's recovery API; stock row deletion cannot stand in for provider release.
|
|
14
|
+
|
|
15
|
+
The development gem has automated schema and lifecycle coverage. An actual application data migration/cutover/rollback rehearsal remains an integration requirement; this guide does not claim one has occurred.
|
data/guides/privacy.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Stored data and retention
|
|
2
|
+
|
|
3
|
+
Bouncy stores the normalized address, exact provider identifiers, reasons, event IDs/times, bounded provider diagnostics, support notes and actor references. Those fields can contain personal information. Raw payloads, subjects, bodies and attachments are not stored by this release.
|
|
4
|
+
|
|
5
|
+
Default event retention is 90 days. Schedule `Bouncy::PruneJob` daily. Retention must cover the maximum accepted event age; old events are retained as diagnostic observations without applying stale policy. Current state and release ordering metadata survive event pruning.
|
|
6
|
+
|
|
7
|
+
`Bouncy.forget!(address)` erases local state and events in the configured scope. It does not change the provider list. A subsequent sync or eligible notification can create state again, and erasure also removes local release fences. It is not a complete legal/compliance workflow or a provider recovery action.
|
|
8
|
+
|
|
9
|
+
Restrict global mirror access to authorized support operators. An address can appear in several tenants or sister apps; a matching address does not authorize disclosure of all associated history. Tenant-facing views must join through host-authorized records and expose only the necessary status.
|
|
10
|
+
|
|
11
|
+
Treat diagnostics and notes as untrusted text when rendering. Do not log raw notifications, tokens, certificate response bodies or customer email contents. Hook error instrumentation records the hook and exception class, not arbitrary exception messages.
|
|
12
|
+
|
|
13
|
+
Bouncy sends no telemetry. The optional SDKs contact the configured provider for verification, synchronization, diagnostics and explicitly requested recovery.
|
data/guides/recovery.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Remove an email from the SES suppression list from Rails
|
|
2
|
+
|
|
3
|
+
Support recovery is a deliberate action on a shared provider restriction. The host app must authorize the operator, identify the intended address/account and verify that further contact is appropriate, especially after a complaint.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
Bouncy.release!(address, note: "Address corrected and verified", actor: "support:42")
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Bouncy obtains a complete supported-scope snapshot, finds the exact provider variants corresponding to the normalized local address, deletes those exact identifiers and checks absence. Only then does it conditionally clear local state. A newer local change causes `Bouncy::ReleaseConflict`, preserving the latest evidence.
|
|
10
|
+
|
|
11
|
+
A partial provider operation raises `Bouncy::ReleaseFailed`. Inspect `error.outcomes` or the `release_failed` event: some variants may have been removed while another failed. Local provider evidence remains blocked until a successful recovery/reconciliation. Remote calls cannot be rolled back with your database transaction.
|
|
12
|
+
|
|
13
|
+
Inactive rows retain a release-time fence. Older feedback and stale snapshots cannot simply reapply the released evidence. A genuinely newer failure can block the address again. Provider changes after a completed observation remain possible; this is not a permanent deliverability guarantee.
|
|
14
|
+
|
|
15
|
+
## Local holds
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
Bouncy.block!(address, note: "Support hold")
|
|
19
|
+
Bouncy.release!(address, at: :local)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
A manual hold is application-local. Sync never deletes it because SES does not list the address. Local-only release clears that local policy while preserving provider/event evidence. Default provider-aware recovery is broader and requires a note.
|
|
23
|
+
|
|
24
|
+
## What recovery does not do
|
|
25
|
+
|
|
26
|
+
It does not repair a mailbox, reverse list consent, restore permission after a complaint, guarantee inbox arrival or resend anything. A host resend action must authorize the request, regenerate expiring content and prevent duplicate business effects.
|
|
27
|
+
|
|
28
|
+
`Bouncy.forget!(address)` is local erasure, not recovery. It removes history and ordering metadata without changing SES; a future sync may import the provider restriction again.
|