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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +185 -93
- data/guides/README.md +20 -0
- data/guides/admin.md +2 -2
- data/guides/amazon-ses.md +70 -9
- data/guides/api.md +129 -0
- data/guides/bounce-handling.md +38 -1
- data/guides/compatibility.md +16 -2
- data/guides/configuration.md +83 -0
- data/guides/delivery.md +46 -8
- data/guides/migrating.md +3 -3
- data/guides/operations.md +104 -0
- data/guides/privacy.md +1 -1
- data/guides/troubleshooting.md +1 -1
- data/lib/bouncy/address_lock.rb +321 -0
- data/lib/bouncy/body_limit.rb +55 -0
- data/lib/bouncy/configuration.rb +38 -2
- data/lib/bouncy/event.rb +8 -3
- data/lib/bouncy/ingestor.rb +5 -3
- data/lib/bouncy/interceptor.rb +85 -16
- data/lib/bouncy/notification.rb +70 -0
- data/lib/bouncy/providers/ses.rb +57 -2
- data/lib/bouncy/providers/ses_parser.rb +20 -0
- data/lib/bouncy/providers/sns_verifier.rb +74 -6
- data/lib/bouncy/prune_job.rb +3 -0
- data/lib/bouncy/scope_lock.rb +3 -2
- data/lib/bouncy/store.rb +4 -0
- data/lib/bouncy/sync_job.rb +4 -0
- data/lib/bouncy/version.rb +1 -1
- data/lib/bouncy/webhook.rb +60 -9
- data/lib/bouncy.rb +34 -2
- data/lib/generators/bouncy/templates/initializer.rb.tt +14 -0
- metadata +13 -3
|
@@ -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
|
|
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
|
|
data/guides/troubleshooting.md
CHANGED
|
@@ -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
|
data/lib/bouncy/configuration.rb
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
data/lib/bouncy/ingestor.rb
CHANGED
|
@@ -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
|
-
#
|
|
48
|
-
# marks it "confirmed"); candidates wait for the provider's own list
|
|
49
|
-
|
|
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
|