bouncy 0.1.0 → 0.2.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.
@@ -0,0 +1,104 @@
1
+ # Scheduling, monitoring and operations
2
+
3
+ Bouncy supplies Active Job classes and Rails tasks. It does not install a scheduler, edit recurring-job files or start workers. Configure the same scope, credentials and dependencies in web and job processes.
4
+
5
+ ## Schedule the jobs
6
+
7
+ Run `Bouncy::SyncJob` hourly and `Bouncy::PruneJob` daily. Both use the `default` queue. While `config.scope` is unset, both return at once with one info line and touch neither AWS nor the database, so a schedule set up before the feature is on does not fill the failed-jobs list; `Bouncy.sync!` itself still raises. Ensure a worker consumes it; enqueuing a job is not evidence that it ran.
8
+
9
+ For an app already using [Solid Queue recurring tasks](https://github.com/rails/solid_queue#recurring-tasks):
10
+
11
+ ```yaml
12
+ # config/recurring.yml
13
+ production:
14
+ bouncy_sync:
15
+ class: Bouncy::SyncJob
16
+ schedule: every hour
17
+ bouncy_prune:
18
+ class: Bouncy::PruneJob
19
+ schedule: every day at 3am
20
+ ```
21
+
22
+ Merge these entries into your existing production section. Start the scheduler and workers using your app's normal deployment process. Solid Queue is optional. Other schedulers can enqueue the same classes:
23
+
24
+ ```ruby
25
+ Bouncy::SyncJob.perform_later
26
+ Bouncy::PruneJob.perform_later
27
+ ```
28
+
29
+ If you use cron instead, run the work synchronously in the app's configured environment:
30
+
31
+ ```cron
32
+ 0 * * * * cd /srv/myapp && RAILS_ENV=production bin/rails bouncy:sync
33
+ 0 3 * * * cd /srv/myapp && RAILS_ENV=production bin/rails runner 'Bouncy::PruneJob.perform_now'
34
+ ```
35
+
36
+ Adapt the path and Ruby/environment setup to your deployment. Choose one recurring mechanism. Configure failure alerts and retries through your existing job system; these jobs do not declare a custom retry policy.
37
+
38
+ A second sync in the same scope fails immediately with `Bouncy::ProviderError` if another owns the lock. Do not overlap scheduled runs unnecessarily. See [database lock requirements](compatibility.md#synchronization).
39
+
40
+ ## Available tasks
41
+
42
+ Run with `RAILS_ENV=production` in production:
43
+
44
+ | Command | Effect |
45
+ |---|---|
46
+ | `bin/rails bouncy:doctor` | Read AWS identity/policy/topic attributes and local sync times; print JSON. Does not mutate AWS or test write permission. |
47
+ | `bin/rails bouncy:ses:setup` | Run provider diagnostics and print a setup plan. Never provisions AWS. Any `APPLY` environment variable is rejected, including `APPLY=0`. |
48
+ | `bin/rails bouncy:sync` | Read the full provider list, reconcile local rows and print the sync event details. |
49
+ | `bin/rails bouncy:bootstrap` | Require a fresh, complete, policy-verified mirror, running a sync if necessary; raise on failure. |
50
+
51
+ Doctor reports `policy_verified`, `policy_reason`, `topic_allowlist`, per-topic readability and publisher-policy review requirements, plus `last_attempt` and `last_success`. Write permission remains unknown until an explicitly authorized operation is exercised. It does not verify public endpoint reachability, subscription activation, notification delivery or job scheduling.
52
+
53
+ A normal exit from doctor or sync can still describe an unverified policy. Inspect the JSON, not just the exit status. AWS read failures may raise before doctor/setup can print a result. Bootstrap is the strict freshness check for a planned cutover; do not put it on every container restart and make unrelated deployments depend on live AWS availability.
54
+
55
+ ## Monitor the mirror
56
+
57
+ ```ruby
58
+ Bouncy.sync_fresh?
59
+ Bouncy.last_sync&.details
60
+ Bouncy.last_successful_sync&.created_at
61
+ ```
62
+
63
+ A successful authoritative summary has `complete: true`, `enumerated: true`, `policy_verified: true`, and entry/change counts. An enumerated list with unsupported policy has `complete: false` and an explanation in `policy_reason`. A recorded provider error has `complete: false` and `error_class`.
64
+
65
+ `entries` counts exact provider entries; address counts are normalized. Metadata-only refreshes and unchanged restrictions do not emit per-address change events. Each completed reconciliation still emits a summary. A lock acquisition failure or some configuration/database failures can occur before a summary is recorded; monitor job failures as well as freshness.
66
+
67
+ Keep the sync interval shorter than `stale_after` (default two hours). If the mirror is stale or a newer sync reports unverified policy, the interceptor stops dropping based on provider/webhook evidence. An app that runs no sync sets `config.require_fresh_sync = false`: every recorded block then drops mail, and nothing clears a webhook block on its own. Independent manual and active soft holds remain effective. The status API can still show historical restrictions; it describes evidence, not the current transport decision.
68
+
69
+ A host health check can use `Bouncy.sync_fresh?` and rescue recognized database outages to report an unhealthy dependency. Decide in the host whether to expose this as a separate email-health indicator or affect overall readiness. Do not expose address history or provider diagnostics in an unauthenticated health response.
70
+
71
+ ## Hooks and instrumentation
72
+
73
+ ```ruby
74
+ Bouncy.configure do |config|
75
+ config.after_event = ->(event) { Rails.logger.info("Bouncy event #{event.id}: #{event.kind}") }
76
+ config.after_block = ->(event) { SupportNotificationJob.perform_later(event.id) }
77
+ config.after_release = ->(event) { RecoveryNotificationJob.perform_later(event.id) }
78
+ end
79
+ ```
80
+
81
+ Implement the example jobs in the host. Callbacks receive a committed `Bouncy::Event`. `after_event` runs for every newly created event, including sync summaries and interception observations. `after_block` runs when `details["became_blocked"]` is true, not for every repeated bounce. `after_release` runs for explicit `release` events, including local-only releases; it does not run for `sync_released`. Use `after_event` with a kind check if you need to observe reconciliation releases.
82
+
83
+ These hooks are best effort. Exceptions emit `hook_error.bouncy` with `hook` and `error_class`, and do not undo the event. They run after the commit of the outermost transaction, after Bouncy released every address lock of it, and, for a write inside `Bouncy.hold`, when the hold ends. A rollback drops them. Rails transactional tests keep 0.1's timing. Bouncy marks the fixture transaction of each pool that `ActiveRecord::TestFixtures` enrolls, as listed in its own `@fixture_connection_pools`: at `setup_transactional_fixtures`, and for a pool established later from a `!connection.active_record` subscriber that runs after Rails' own. It clears the marks in `teardown_transactional_fixtures` (Rails 7.2, 8.0 and 8.1). While a marked transaction is the only one open, hooks run when the app's own transaction commits. A database excluded with `skip_transactional_tests_for_database` (Rails 8.1), or a connection pinned in any other way, keeps the outer-commit rule. A process can stop after commit before notification completes. Use idempotent consumers and a host outbox/reconciliation mechanism for guaranteed external work. Jobs should tolerate an event being pruned before execution.
84
+
85
+ `config.on_notification` is different: it runs synchronously in the receiver, with every SES notification, and its failure is not swallowed. A raise emits `hook_error.bouncy` with `hook: :on_notification`, logs the error class and answers 503, so SNS delivers the notification again. Keep it short and idempotent; see [SES notifications](api.md#ses-notifications).
86
+
87
+ Other Active Support notifications are `unconfigured.bouncy` (operation attempted while inactive, including `hold`) and `unavailable.bouncy` (interception encounters a recognized database outage). Subscribe using your existing instrumentation:
88
+
89
+ ```ruby
90
+ ActiveSupport::Notifications.subscribe("hook_error.bouncy") do |*args|
91
+ notification = ActiveSupport::Notifications::Event.new(*args)
92
+ Rails.logger.error("Bouncy hook failed: #{notification.payload[:hook]} (#{notification.payload[:error_class]})")
93
+ end
94
+ ```
95
+
96
+ These are not delivery receipts. Avoid logging addresses, raw payloads or arbitrary exception messages.
97
+
98
+ ## Retention and operational history
99
+
100
+ Schedule pruning; setting retention alone deletes nothing. `PruneJob` deletes events whose local `created_at` is older than the configured duration. Current state, exact provider evidence and inactive release fences remain. See [privacy and erasure](privacy.md).
101
+
102
+ Use `Bouncy.events.for(address).recent` for occurrence-ordered address history. Operational events such as sync summaries may have no address. `skipped` events include `mode`, `would_drop`, `dropped` and `stale_provider_evidence`: in log mode, they preview the enforcement decision while delivery continues.
103
+
104
+ The gem does not send test messages or automatically resend after recovery. Exercise the real SES feedback and authorized recovery lifecycle in your own staging setup before relying on dropping in production. Automated SDK/signature tests are not a substitute for validating your credentials, topic and delivery path.
data/guides/privacy.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
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
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.
5
+ Default event retention is 90 days, measured from local event creation (`created_at`), not provider occurrence time. Schedule `Bouncy::PruneJob` daily; no pruning happens merely by configuring retention. Retention must cover `maximum_event_age`. Feedback older than that acceptance window can be recorded for diagnostics without applying a new restriction, then ages out under normal pruning. Current state and release ordering metadata survive event pruning.
6
6
 
7
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
8
 
@@ -30,7 +30,7 @@ Release never resends the original email. Regenerate time-sensitive links in an
30
30
  - If `Bouncy.status(address).knowledge` is `:unconfigured`, `config.scope` is blank and Bouncy is inactive in that environment.
31
31
  - Confirm the SNS HTTPS subscription is active and points at the actual mounted route.
32
32
  - Confirm SES routes the needed event types to that topic, and raw SNS message delivery is disabled.
33
- - Inspect bounded receiver status codes: 401 authorization/signature, 400 malformed input, 413 body limit, 503 retryable infrastructure failure.
33
+ - Inspect bounded receiver status codes: 401 authorization/signature, 400 malformed input, 413 body limit, 503 retryable infrastructure failure, a failed `SubscribeURL` visit or a raise in `config.on_notification`. Each refusal logs `[bouncy] refused an SNS message (<status>): <reason>; topic <ARN>`; a failed confirmation also names the `SubscribeURL` host.
34
34
  - Run a complete sync and verify the scheduled job actually runs in production.
35
35
 
36
36
  An hourly sync cannot reconstruct every lost delivery or soft-bounce event. SNS retries and operational monitoring still matter.
@@ -0,0 +1,321 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+
5
+ module Bouncy
6
+ # One lock per address, between the app's senders and Bouncy's own writes.
7
+ #
8
+ # A sender takes the shared side with Bouncy.hold, around its own check and its send. Every
9
+ # Bouncy write to an address (webhook, sync, block!, release!) takes the exclusive side first,
10
+ # until its transaction ends. A new block therefore commits before the sender's check or after
11
+ # its send, never between them. Inside a transaction, a hold lasts until that transaction ends.
12
+ #
13
+ # PostgreSQL holds transaction-level advisory locks, which it releases itself at the commit
14
+ # or the rollback. MySQL named locks and SQLite file locks belong to the connection, and three
15
+ # paths release them at the end of the outermost transaction:
16
+ #
17
+ # 1. the transaction.active_record event, which Rails sends right after the COMMIT or the
18
+ # ROLLBACK and before any callback, so a host callback that raises cannot keep a lock;
19
+ # 2. after_commit and after_rollback on the transaction itself, for a transaction that started
20
+ # before Bouncy subscribed to that event;
21
+ # 3. the connection's return to the pool, for anything both paths missed.
22
+ #
23
+ # Hooks never run inside a lock: they wait for the commit of the outermost transaction, which
24
+ # comes after the releases, and the hooks of a write inside a hold wait until the hold ends.
25
+ module AddressLock
26
+ FileHold = Struct.new(:guard, :file, :mode, :holders)
27
+ # Per connection, like the locks themselves: threads that share one connection, as Rails
28
+ # system tests do, share its holds and the hooks that wait for them.
29
+ Holds = Struct.new(:depth, :hooks)
30
+ FILE_LOCKS = ObjectSpace::WeakKeyMap.new
31
+ DEFERRED = ObjectSpace::WeakKeyMap.new
32
+ HOLDS = ObjectSpace::WeakKeyMap.new
33
+ FIXTURES = ObjectSpace::WeakKeyMap.new
34
+ GUARD = Mutex.new
35
+
36
+ module_function
37
+
38
+ def hold(email)
39
+ key = Identity.normalize(email)
40
+ Record.connection_pool.with_connection do |connection|
41
+ settle(connection)
42
+ require_read_committed(connection)
43
+ release = take(connection, key, exclusive: false)
44
+ holds = GUARD.synchronize { (HOLDS[connection] ||= Holds.new(0, [])).tap { |state| state.depth += 1 } }
45
+ begin
46
+ yield
47
+ ensure
48
+ hooks = GUARD.synchronize do
49
+ holds.depth -= 1
50
+ holds.depth.zero? ? holds.hooks.slice!(0..) : []
51
+ end
52
+ begin
53
+ until_transaction_end(connection, release)
54
+ ensure
55
+ hooks.each(&:call)
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ # Takes the exclusive side until the current transaction commits or rolls back. Store.change
62
+ # calls it first in its transaction, before it takes any row lock.
63
+ def claim!(email)
64
+ key = Identity.normalize(email)
65
+ Record.connection_pool.with_connection do |connection|
66
+ until_transaction_end(connection, take(connection, key, exclusive: true))
67
+ end
68
+ end
69
+
70
+ def holding?(connection = Record.connection_pool.active_connection)
71
+ connection && GUARD.synchronize { HOLDS[connection]&.depth.to_i.positive? }
72
+ end
73
+
74
+ # Runs a hook once its connection holds no Bouncy lock. A record's commit callbacks can run
75
+ # while an outer transaction is still open, under transaction(joinable: false); the hook then
76
+ # moves to that transaction, and so runs after the commit of the outermost one, or never if
77
+ # it rolls back. Inside a hold it waits for the hold.
78
+ #
79
+ # Rails transactional tests keep 0.1's timing: the hook runs at the commit inside the
80
+ # fixture transaction, which only ever rolls back. FixtureTransactions marks that transaction. With no transaction left open, releases
81
+ # that Rails did not report are settled first, because Rails runs record commit callbacks,
82
+ # such as the event's, before the transaction callbacks that would release them.
83
+ def after_locks(&hook)
84
+ connection = Record.connection_pool.active_connection
85
+ return hook.call unless connection
86
+
87
+ deferred = connection.transaction_open? && !only_fixture_transaction?(connection)
88
+ return connection.current_transaction.after_commit { after_locks(&hook) } if deferred
89
+
90
+ settle(connection)
91
+ queued = GUARD.synchronize do
92
+ holds = HOLDS[connection]
93
+ holds.hooks << hook if holds&.depth.to_i.positive?
94
+ end
95
+ hook.call unless queued
96
+ end
97
+
98
+ # True while the connection's only open transaction is the one Rails transactional fixtures
99
+ # began on it. FixtureTransactions records that transaction from the fixture lifecycle.
100
+ def only_fixture_transaction?(connection)
101
+ fixture = GUARD.synchronize { FIXTURES[connection] }
102
+ !fixture.nil? && connection.open_transactions == 1 && connection.current_transaction.equal?(fixture)
103
+ end
104
+
105
+ # Prepended into every class that includes ActiveRecord::TestFixtures. Rails 7.2, 8.0 and 8.1
106
+ # enroll a pool into the fixture transactions in two places: setup_transactional_fixtures,
107
+ # for the pools already connected, and its own "!connection.active_record" subscriber, for a
108
+ # pool established or replaced later in the test. Both pin the pool's connection, which
109
+ # begins the fixture transaction, lease that connection and add the pool to
110
+ # @fixture_connection_pools. Rails 8.1 leaves out the pools a test excludes with
111
+ # skip_transactional_tests_for_database. Bouncy marks the transactions of exactly the
112
+ # pools on that list, at setup and from a subscriber that runs after Rails' own, and clears
113
+ # the marks at teardown.
114
+ module FixtureTransactions
115
+ private
116
+
117
+ # A late pool's callback can still be running in another thread when teardown starts, and
118
+ # unsubscribing does not stop it. One lock per fixture covers the checks and the writes of
119
+ # every callback and teardown's clear, and teardown closes the registration under it
120
+ # before it unsubscribes: a callback that reaches the lock afterwards does nothing.
121
+ def setup_transactional_fixtures
122
+ super
123
+ @bouncy_fixture_guard = Mutex.new
124
+ @bouncy_fixture_closed = false
125
+ @bouncy_fixture_connections = []
126
+ mark_fixture_transactions
127
+ @bouncy_fixture_subscriber = ActiveSupport::Notifications.subscribe("!connection.active_record") { mark_fixture_transactions }
128
+ end
129
+
130
+ def teardown_transactional_fixtures
131
+ @bouncy_fixture_guard&.synchronize do
132
+ @bouncy_fixture_closed = true
133
+ @bouncy_fixture_connections.each { |connection| GUARD.synchronize { FIXTURES.delete(connection) } }
134
+ @bouncy_fixture_connections.clear
135
+ end
136
+ ActiveSupport::Notifications.unsubscribe(@bouncy_fixture_subscriber) if @bouncy_fixture_subscriber
137
+ @bouncy_fixture_subscriber = nil
138
+ super
139
+ end
140
+
141
+ def mark_fixture_transactions
142
+ @bouncy_fixture_guard.synchronize do
143
+ next if @bouncy_fixture_closed
144
+
145
+ Array(@fixture_connection_pools).each do |pool|
146
+ connection = pool.active_connection
147
+ next if connection.nil? || !connection.transaction_open? || @bouncy_fixture_connections.include?(connection)
148
+
149
+ record_fixture_transaction(connection)
150
+ end
151
+ end
152
+ end
153
+
154
+ # Runs under the fixture's lock, after the checks.
155
+ def record_fixture_transaction(connection)
156
+ GUARD.synchronize { FIXTURES[connection] = connection.current_transaction }
157
+ @bouncy_fixture_connections << connection
158
+ end
159
+ end
160
+
161
+ # Releases what an ended transaction left behind, in case Rails never reported its end.
162
+ def settle(connection)
163
+ return if connection.transaction_open?
164
+
165
+ release_deferred(connection)
166
+ end
167
+
168
+ # Inside a PostgreSQL transaction, a hold must not run at REPEATABLE READ or SERIALIZABLE:
169
+ # the lock statement fixes the snapshot before it waits, so a block committed during the wait
170
+ # stays invisible. Hold the address outside the transaction instead; the lock then lasts for
171
+ # the whole block, and the transaction's snapshot starts after it.
172
+ def require_read_committed(connection)
173
+ return unless connection.adapter_name == "PostgreSQL" && connection.transaction_open?
174
+
175
+ isolation = connection.uncached { connection.select_value("SHOW transaction_isolation") }
176
+ return if ["read committed", "read uncommitted"].include?(isolation)
177
+
178
+ raise ConfigurationError, "Bouncy.hold inside a PostgreSQL #{isolation} transaction cannot see a block committed while it waits; " \
179
+ "call Bouncy.hold outside the transaction, around it"
180
+ end
181
+
182
+ # Takes the lock now and returns what releases it, or nil when the database releases it.
183
+ def take(connection, key, exclusive:)
184
+ case connection.adapter_name
185
+ when "PostgreSQL" then take_advisory(connection, key, exclusive)
186
+ when /Mysql|Trilogy/i then take_named(connection, key)
187
+ when "SQLite" then take_file(connection, exclusive ? File::LOCK_EX : File::LOCK_SH)
188
+ else raise ConfigurationError, "Bouncy supports PostgreSQL, MySQL and SQLite"
189
+ end
190
+ end
191
+
192
+ # Inside a transaction: a transaction-level lock, released by PostgreSQL at the commit or
193
+ # the rollback, also when a failed statement aborted the transaction. Outside one: a session
194
+ # lock for the block. The key is a signed 64-bit hash of the scope and the address.
195
+ def take_advisory(connection, key, exclusive)
196
+ shared = exclusive ? "" : "_shared"
197
+ if connection.transaction_open?
198
+ connection.execute("SELECT pg_advisory_xact_lock#{shared}(#{advisory_key(key)})")
199
+ return
200
+ end
201
+
202
+ connection.execute("SELECT pg_advisory_lock#{shared}(#{advisory_key(key)})")
203
+ -> { connection.execute("SELECT pg_advisory_unlock#{shared}(#{advisory_key(key)})") }
204
+ end
205
+
206
+ # GET_LOCK waits as long as an InnoDB row lock would. A session can take the same name again;
207
+ # each take needs its own release. Both statements skip the query cache: a cached GET_LOCK
208
+ # would answer 1 without locking.
209
+ def take_named(connection, key)
210
+ name = connection.quote(Digest::SHA256.hexdigest("bouncy:address:#{connection.pool.db_config.database}:#{Bouncy.scope}:#{key}"))
211
+ acquired = connection.uncached { connection.select_value("SELECT GET_LOCK(#{name}, @@innodb_lock_wait_timeout)") }
212
+ raise ActiveRecord::LockWaitTimeout, "Bouncy could not lock an address in time" unless acquired.to_i == 1
213
+
214
+ released = false
215
+ lambda do
216
+ next if released
217
+
218
+ released = true
219
+ connection.uncached { connection.select_value("SELECT RELEASE_LOCK(#{name})") }
220
+ rescue ActiveRecord::ActiveRecordError
221
+ # A broken connection ends its session, and the server releases the session's locks.
222
+ nil
223
+ end
224
+ end
225
+
226
+ # One file per database, held per connection the way PostgreSQL and MySQL hold a lock per
227
+ # session. A connection that already holds the file reuses its handle: a write inside its
228
+ # own hold upgrades the lock to exclusive instead of waiting for itself. Threads that share
229
+ # one connection, as Rails tests do, share its lock too.
230
+ def take_file(connection, mode)
231
+ held = GUARD.synchronize { FILE_LOCKS[connection] ||= FileHold.new(Mutex.new, nil, nil, 0) }
232
+ held.guard.synchronize do
233
+ if held.file.nil?
234
+ # The handle stays open while the lock is held; the last release closes it.
235
+ file = File.open(lock_path(connection), File::RDWR | File::CREAT, 0o600) # rubocop:disable Style/FileOpen
236
+ file.flock(mode)
237
+ held.file = file
238
+ held.mode = mode
239
+ elsif mode == File::LOCK_EX && held.mode == File::LOCK_SH
240
+ held.file.flock(File::LOCK_EX)
241
+ held.mode = File::LOCK_EX
242
+ end
243
+ held.holders += 1
244
+ end
245
+ released = false
246
+ lambda do
247
+ held.guard.synchronize do
248
+ next if released
249
+
250
+ released = true
251
+ held.holders -= 1
252
+ next if held.holders.positive?
253
+
254
+ held.file.close
255
+ held.file = held.mode = nil
256
+ end
257
+ end
258
+ end
259
+
260
+ def lock_path(connection)
261
+ database = connection.pool.db_config.database
262
+ return File.join(Dir.tmpdir, "bouncy-#{Process.pid}-addresses.lock") if database == ":memory:"
263
+
264
+ "#{File.expand_path(database)}.bouncy-addresses.lock"
265
+ end
266
+
267
+ # Releases now outside a transaction. Inside one, keeps the release for the end of the
268
+ # outermost transaction; the transaction is materialized first, so Rails reports its end.
269
+ def until_transaction_end(connection, release)
270
+ return unless release
271
+ return release.call unless connection.transaction_open?
272
+
273
+ connection.materialize_transactions
274
+ GUARD.synchronize { (DEFERRED[connection] ||= []) << release }
275
+ watch(connection)
276
+ end
277
+
278
+ # The second path. A savepoint under a joinable parent hands both callbacks to the parent
279
+ # when it commits. One that rolls back, or commits under a transaction(joinable: false)
280
+ # parent, runs its callback while the parent continues, so the watch moves up a level. It
281
+ # uses the connection's transaction object: the public Record.current_transaction is a null
282
+ # transaction under transaction(joinable: false), and would run the block at once.
283
+ def watch(connection)
284
+ transaction = connection.current_transaction
285
+ ended = -> { connection.transaction_open? ? watch(connection) : settle(connection) }
286
+ transaction.after_commit(&ended)
287
+ transaction.after_rollback(&ended)
288
+ end
289
+
290
+ # Rails pops a committed transaction before it reports the commit, and reports a rollback
291
+ # before it pops the transaction. :restart reports a savepoint that continues.
292
+ def transaction_ended(payload)
293
+ connection = payload[:connection]
294
+ return unless connection && GUARD.synchronize { DEFERRED.key?(connection) }
295
+
296
+ outermost = case payload[:outcome]
297
+ when :commit then connection.open_transactions.zero?
298
+ when :rollback then connection.open_transactions <= 1
299
+ when :incomplete then true
300
+ else false
301
+ end
302
+ release_deferred(connection) if outermost
303
+ end
304
+
305
+ def release_deferred(connection)
306
+ releases = GUARD.synchronize { DEFERRED.delete(connection) }
307
+ releases&.each(&:call)
308
+ end
309
+
310
+ def advisory_key(key) = Digest::SHA256.digest("bouncy:address\n#{Bouncy.scope}\n#{key}").unpack1("q>")
311
+ end
312
+
313
+ # Subscribed when the gem loads: Rails gives each transaction the subscribers that exist when
314
+ # the transaction starts, so a later subscription misses it, and the second path covers that.
315
+ ActiveSupport::Notifications.subscribe("transaction.active_record") { |*, payload| AddressLock.transaction_ended(payload) }
316
+
317
+ ActiveSupport.on_load(:active_record_fixtures) { prepend AddressLock::FixtureTransactions }
318
+
319
+ # The third path: a connection goes back to the pool with nothing of Bouncy's still locked.
320
+ ActiveRecord::ConnectionAdapters::AbstractAdapter.set_callback(:checkin, :after) { AddressLock.release_deferred(self) }
321
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bouncy
4
+ # Rack middleware that cuts the body of a request to the receiver before Rails reads it.
5
+ #
6
+ # Rails reads a whole chunked body to learn its length, and parses a JSON or form body for
7
+ # its request log, before the route runs. The receiver's own limit then comes too late.
8
+ # SNS posts 256 KB at most. A longer body gets 413 after limit + 1 bytes are read; a shorter
9
+ # one goes on with a plain Content-Length.
10
+ #
11
+ # The limit applies to the paths the router sends to the receiver: the path itself, with
12
+ # repeated slashes squeezed, an optional trailing slash and an optional format suffix
13
+ # ("//bouncy/webhooks/ses/", "/bouncy/webhooks/ses.json"). It is compared with PATH_INFO and
14
+ # with SCRIPT_NAME + PATH_INFO, so it also holds when the app is mounted under a prefix. An
15
+ # adjacent route such as "/bouncy/webhooks/ses-admin" is not limited.
16
+ #
17
+ # # config/application.rb
18
+ # config.middleware.insert_before 0, Bouncy::BodyLimit, path: "/bouncy/webhooks/ses"
19
+ class BodyLimit
20
+ DEFAULT_LIMIT = 300.kilobytes
21
+
22
+ def initialize(app, path: "/bouncy/webhooks/ses", limit: DEFAULT_LIMIT)
23
+ @app = app
24
+ normalized = path.to_s.squeeze("/").chomp("/")
25
+ raise ArgumentError, "path must be the receiver's absolute path" unless normalized.start_with?("/") && normalized.size > 1
26
+ raise ArgumentError, "limit must be a positive number of bytes" unless limit.is_a?(Integer) && limit.positive?
27
+
28
+ @limit = limit
29
+ @route = %r{\A#{Regexp.escape(normalized)}(?:\.[^/.]+)?/?\z}
30
+ end
31
+
32
+ def call(environment)
33
+ return @app.call(environment) unless receiver?(environment)
34
+
35
+ input = environment["rack.input"]
36
+ input.rewind if input.respond_to?(:rewind)
37
+ body = input&.read(@limit + 1).to_s
38
+ return too_large if body.bytesize > @limit
39
+
40
+ environment["rack.input"] = StringIO.new(body)
41
+ environment["CONTENT_LENGTH"] = body.bytesize.to_s
42
+ environment.delete("HTTP_TRANSFER_ENCODING")
43
+ @app.call(environment)
44
+ end
45
+
46
+ private
47
+
48
+ def receiver?(environment)
49
+ path = environment["PATH_INFO"].to_s
50
+ [path, environment["SCRIPT_NAME"].to_s + path].any? { |candidate| candidate.squeeze("/").match?(@route) }
51
+ end
52
+
53
+ def too_large = [413, { "content-type" => "text/plain", "cache-control" => "no-store" }, [Rack::Utils::HTTP_STATUS_CODES.fetch(413)]]
54
+ end
55
+ end
@@ -7,8 +7,17 @@ module Bouncy
7
7
  # caches Amazon's signing certificate, so a host can test its mounted receiver offline.
8
8
  # Topic authorization and certificate-URL checks still run, which is the point: a test
9
9
  # seam that skipped them would hide exactly the hole they exist to close.
10
+ #
11
+ # configuration_set names the SES configuration set that the interceptor stamps on every
12
+ # message (X-SES-CONFIGURATION-SET), so SES publishes that mail's events to the set's topic.
13
+ # The set also joins the sending-policy check. A blank name stamps nothing.
14
+ #
15
+ # subscription_confirmation picks how the receiver confirms an SNS subscription: :api calls
16
+ # sns:ConfirmSubscription with the signed token; :subscribe_url visits the signed
17
+ # SubscribeURL instead, for credentials without that grant.
10
18
  attr_accessor :region, :credentials, :topic_arns, :client, :sns_client, :sts_client, :sns_message_verifier,
11
- :identities, :configuration_sets, :all_sending_paths_listed
19
+ :identities, :configuration_sets, :all_sending_paths_listed, :configuration_set,
20
+ :subscription_confirmation
12
21
 
13
22
  def initialize
14
23
  @topic_arns = []
@@ -18,6 +27,7 @@ module Bouncy
18
27
  # check needs every sending identity and configuration set listed. This flag is the
19
28
  # installer's statement that the lists above are complete.
20
29
  @all_sending_paths_listed = false
30
+ @subscription_confirmation = :api
21
31
  end
22
32
  end
23
33
 
@@ -26,10 +36,24 @@ module Bouncy
26
36
  # stop the sending. Leave the threshold nil to keep soft bounces record-only.
27
37
  SOFT_BOUNCE_OCCURRENCE_LIMIT = 50
28
38
 
39
+ # require_fresh_sync: true enforces provider and webhook evidence only while a complete sync
40
+ # is fresh. false enforces it always, for an app that runs no sync job: on an SES account
41
+ # that other apps share, their suppressions stay out of this app's tables.
42
+ #
43
+ # on_notification is called with every SES notification, after its state commits and before
44
+ # the receiver answers. Unlike the after_* hooks it may raise: the receiver then answers 503,
45
+ # and SNS delivers the notification again.
46
+ #
47
+ # webhook_blocking picks which notifications block an address. :cautious blocks General and
48
+ # NoEmail bounces and complaints that name one recipient, and leaves the rest to the sync,
49
+ # which reads the account list. :strict blocks every Permanent bounce, whatever its subtype,
50
+ # and every recipient a complaint names. It defaults to :cautious while a sync runs and to
51
+ # :strict when require_fresh_sync is false, because then nothing reads the account list.
52
+ attr_writer :webhook_blocking
29
53
  attr_accessor :provider, :scope, :interception, :record_deliveries,
30
54
  :retention, :maximum_event_age, :stale_after, :after_block, :after_release,
31
55
  :after_event, :adapter, :soft_bounce_threshold, :soft_bounce_window,
32
- :soft_bounce_block_for
56
+ :soft_bounce_block_for, :require_fresh_sync, :on_notification
33
57
  attr_reader :ses
34
58
 
35
59
  def initialize
@@ -42,8 +66,10 @@ module Bouncy
42
66
  @soft_bounce_threshold = nil
43
67
  @soft_bounce_window = 30.days
44
68
  @soft_bounce_block_for = 30.days
69
+ @require_fresh_sync = true
45
70
  @ses = Ses.new
46
71
  @after_block = @after_release = @after_event = ->(_event) {}
72
+ @on_notification = ->(_notification) {}
47
73
  end
48
74
 
49
75
  # Occurrence times retained per address so the window can roll. Bounded either way: a
@@ -56,6 +82,8 @@ module Bouncy
56
82
 
57
83
  def soft_bounce_escalation? = soft_bounce_threshold.to_i.positive?
58
84
 
85
+ def webhook_blocking = @webhook_blocking || (require_fresh_sync == false ? :strict : :cautious)
86
+
59
87
  def configured? = !scope.to_s.strip.empty?
60
88
 
61
89
  def validate!
@@ -63,6 +91,14 @@ module Bouncy
63
91
  raise ConfigurationError, "scope must be at most 191 characters" if scope.to_s.length > 191
64
92
  raise ConfigurationError, "Only the SES provider is supported" unless provider == :ses || adapter
65
93
  raise ConfigurationError, "interception must be :log, :drop or :off" unless %i[log drop off].include?(interception)
94
+ raise ConfigurationError, "require_fresh_sync must be true or false" unless [true, false].include?(require_fresh_sync)
95
+ raise ConfigurationError, "webhook_blocking must be :cautious or :strict" unless %i[cautious strict].include?(webhook_blocking)
96
+ unless %i[api subscribe_url].include?(ses.subscription_confirmation)
97
+ raise ConfigurationError, "ses.subscription_confirmation must be :api or :subscribe_url"
98
+ end
99
+ unless ses.configuration_set.nil? || ses.configuration_set.is_a?(String)
100
+ raise ConfigurationError, "ses.configuration_set must be the name of an SES configuration set, or nil"
101
+ end
66
102
  unless maximum_event_age.positive? && retention >= maximum_event_age
67
103
  raise ConfigurationError, "retention must cover maximum_event_age, and both must be positive"
68
104
  end
data/lib/bouncy/event.rb CHANGED
@@ -13,10 +13,15 @@ module Bouncy
13
13
 
14
14
  private
15
15
 
16
+ # Writes release their address locks at the commit of the outermost transaction, and the
17
+ # hooks wait for that commit and for the end of any Bouncy.hold, so no hook runs while
18
+ # Bouncy holds an address.
16
19
  def notify_host
17
- Bouncy.notify(:after_event, self)
18
- Bouncy.notify(:after_block, self) if details["became_blocked"]
19
- Bouncy.notify(:after_release, self) if kind == "release"
20
+ AddressLock.after_locks do
21
+ Bouncy.notify(:after_event, self)
22
+ Bouncy.notify(:after_block, self) if details["became_blocked"]
23
+ Bouncy.notify(:after_release, self) if kind == "release"
24
+ end
20
25
  end
21
26
  end
22
27
  end
@@ -44,9 +44,11 @@ module Bouncy
44
44
  details = attributes.fetch(:details).merge("ignored_for_policy" => [too_old, fenced, out_of_order].any?)
45
45
  if row && !details["ignored_for_policy"]
46
46
  was_blocked = row.blocked?
47
- # A complaint blocks locally only when the provider named exactly one recipient (SesParser
48
- # marks it "confirmed"); candidates wait for the provider's own list at the next sync.
49
- if attributes[:kind] == "hard_bounce" || (attributes[:kind] == "complaint" && details["certainty"] == "confirmed")
47
+ # Under :cautious a complaint blocks locally only when the provider named exactly one
48
+ # recipient (SesParser marks it "confirmed"); candidates wait for the provider's own list
49
+ # at the next sync. Under :strict every named recipient blocks.
50
+ complaint = attributes[:kind] == "complaint" && (details["certainty"] == "confirmed" || Bouncy.configuration.webhook_blocking == :strict)
51
+ if attributes[:kind] == "hard_bounce" || complaint
50
52
  row.event_blocked_at = time
51
53
  row.event_reason = attributes[:kind]
52
54
  row.last_event_at = [row.last_event_at, time].compact.max