bouncy 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -0
- data/README.md +195 -92
- data/guides/README.md +20 -0
- data/guides/admin.md +2 -2
- data/guides/amazon-ses.md +70 -9
- data/guides/api.md +148 -0
- data/guides/bounce-handling.md +38 -1
- data/guides/compatibility.md +16 -2
- data/guides/configuration.md +83 -0
- data/guides/delivery.md +46 -8
- data/guides/migrating.md +8 -7
- data/guides/operations.md +104 -0
- data/guides/privacy.md +1 -1
- data/guides/recovery.md +1 -1
- data/guides/troubleshooting.md +1 -1
- data/lib/bouncy/address_lock.rb +321 -0
- data/lib/bouncy/body_limit.rb +55 -0
- data/lib/bouncy/configuration.rb +38 -2
- data/lib/bouncy/event.rb +10 -4
- data/lib/bouncy/importer.rb +100 -0
- data/lib/bouncy/ingestor.rb +12 -3
- data/lib/bouncy/interceptor.rb +85 -16
- data/lib/bouncy/notification.rb +70 -0
- data/lib/bouncy/providers/ses.rb +57 -2
- data/lib/bouncy/providers/ses_parser.rb +20 -0
- data/lib/bouncy/providers/sns_verifier.rb +74 -6
- data/lib/bouncy/prune_job.rb +3 -0
- data/lib/bouncy/scope_lock.rb +3 -2
- data/lib/bouncy/status.rb +3 -0
- data/lib/bouncy/store.rb +9 -1
- data/lib/bouncy/sync_job.rb +4 -0
- data/lib/bouncy/version.rb +1 -1
- data/lib/bouncy/webhook.rb +60 -9
- data/lib/bouncy.rb +44 -3
- data/lib/generators/bouncy/templates/initializer.rb.tt +14 -0
- metadata +14 -3
data/lib/bouncy/webhook.rb
CHANGED
|
@@ -3,17 +3,24 @@
|
|
|
3
3
|
module Bouncy
|
|
4
4
|
class Webhook
|
|
5
5
|
BODY_LIMIT = 2 * 1024 * 1024
|
|
6
|
+
METHODS = %w[GET HEAD PUT PATCH DELETE OPTIONS TRACE CONNECT].freeze
|
|
7
|
+
INVALID = "<invalid>"
|
|
8
|
+
TOPIC_ARN = /\Aarn:aws[a-z-]*:sns:[a-z]{2}(?:-[a-z]+)+-\d+:\d{12}:[A-Za-z0-9_-]{1,256}\z/
|
|
6
9
|
|
|
7
10
|
def call(environment)
|
|
8
|
-
|
|
11
|
+
method = environment["REQUEST_METHOD"]
|
|
12
|
+
unless method == "POST"
|
|
13
|
+
shown = METHODS.include?(method) ? method : INVALID
|
|
14
|
+
return refuse(405, "method #{shown} is not POST")
|
|
15
|
+
end
|
|
9
16
|
|
|
10
17
|
# An unconfigured receiver answers 503 so the provider retries later instead of
|
|
11
18
|
# treating the endpoint as broken or, worse, as having accepted the notification.
|
|
12
19
|
Bouncy.configuration.validate!
|
|
13
20
|
body = environment.fetch("rack.input").read(BODY_LIMIT + 1).to_s
|
|
14
|
-
return
|
|
21
|
+
return refuse(400, "empty body") if body.empty?
|
|
15
22
|
|
|
16
|
-
return
|
|
23
|
+
return refuse(413, "body over #{BODY_LIMIT} bytes") if body.bytesize > BODY_LIMIT
|
|
17
24
|
|
|
18
25
|
adapter = Bouncy.adapter
|
|
19
26
|
envelope = adapter.authenticate(raw_body: body, headers: environment)
|
|
@@ -21,18 +28,62 @@ module Bouncy
|
|
|
21
28
|
raise MalformedMessage, "Unsupported SNS control type" unless envelope["Type"] == "Notification"
|
|
22
29
|
|
|
23
30
|
adapter.parse(envelope).each { |event| Ingestor.new.call(event) }
|
|
31
|
+
return response(503) unless notified?(adapter, envelope)
|
|
24
32
|
end
|
|
25
33
|
response(200)
|
|
26
|
-
rescue AuthenticationError
|
|
27
|
-
|
|
28
|
-
rescue MalformedMessage
|
|
29
|
-
|
|
30
|
-
rescue ProviderError, ConfigurationError
|
|
31
|
-
|
|
34
|
+
rescue AuthenticationError => e
|
|
35
|
+
refuse(401, e.message, body)
|
|
36
|
+
rescue MalformedMessage => e
|
|
37
|
+
refuse(400, e.message, body)
|
|
38
|
+
rescue ProviderError, ConfigurationError => e
|
|
39
|
+
refuse(503, e.message, body)
|
|
40
|
+
rescue ActiveRecord::ActiveRecordError => e
|
|
41
|
+
# A database message can quote a statement and its values, so only the class is logged.
|
|
42
|
+
refuse(503, e.class.name, body)
|
|
32
43
|
end
|
|
33
44
|
|
|
34
45
|
private
|
|
35
46
|
|
|
47
|
+
# One warning per refused message: how a dead subscription is found in production. It names
|
|
48
|
+
# the reason and where the message claims to come from. The message is not authenticated, so
|
|
49
|
+
# a topic is logged only when it has the strict SNS ARN syntax and a SubscribeURL host only
|
|
50
|
+
# when it passes the SNS host check; anything else is written as <invalid>. No raw field of
|
|
51
|
+
# the body is ever copied into the log.
|
|
52
|
+
def refuse(status, reason, body = nil)
|
|
53
|
+
Rails.logger&.warn("[bouncy] refused an SNS message (#{status}): #{reason}#{origin(body)}")
|
|
54
|
+
response(status)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def origin(body)
|
|
58
|
+
envelope = JSON.parse(body.to_s, max_nesting: 30)
|
|
59
|
+
return "" unless envelope.is_a?(Hash)
|
|
60
|
+
|
|
61
|
+
parts = []
|
|
62
|
+
topic = envelope["TopicArn"] if envelope["TopicArn"].is_a?(String) && envelope["TopicArn"].match?(TOPIC_ARN)
|
|
63
|
+
parts << "topic #{topic || INVALID}" if envelope.key?("TopicArn")
|
|
64
|
+
if envelope["Type"] == "SubscriptionConfirmation" && envelope.key?("SubscribeURL")
|
|
65
|
+
uri = Providers::Ses.subscribe_uri(envelope, Bouncy.configuration.ses.region) if topic
|
|
66
|
+
parts << "SubscribeURL host #{uri&.host || INVALID}"
|
|
67
|
+
end
|
|
68
|
+
parts.map { |part| "; #{part}" }.join
|
|
69
|
+
rescue JSON::ParserError
|
|
70
|
+
""
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# config.on_notification runs after the state committed, outside Bouncy's locks, and before
|
|
74
|
+
# the answer. A failure answers 503, so SNS delivers the notification again; the repeat
|
|
75
|
+
# deduplicates and calls the hook again.
|
|
76
|
+
def notified?(adapter, envelope)
|
|
77
|
+
return true unless adapter.respond_to?(:notification)
|
|
78
|
+
|
|
79
|
+
Bouncy.configuration.on_notification.call(adapter.notification(envelope))
|
|
80
|
+
true
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
ActiveSupport::Notifications.instrument("hook_error.bouncy", hook: :on_notification, error_class: e.class.name)
|
|
83
|
+
Rails.logger&.error("[bouncy] config.on_notification raised #{e.class.name}; answering 503 so SNS retries")
|
|
84
|
+
false
|
|
85
|
+
end
|
|
86
|
+
|
|
36
87
|
def response(status)
|
|
37
88
|
[status, { "content-type" => "text/plain", "cache-control" => "no-store" }, [Rack::Utils::HTTP_STATUS_CODES.fetch(status)]]
|
|
38
89
|
end
|
data/lib/bouncy.rb
CHANGED
|
@@ -48,7 +48,7 @@ module Bouncy
|
|
|
48
48
|
# True when config.scope is set. An unconfigured Bouncy is inactive: it intercepts nothing,
|
|
49
49
|
# reports no restrictions and returns empty relations, so a host can install the gem before
|
|
50
50
|
# its environment variables exist without breaking mail delivery. Explicit operations
|
|
51
|
-
# (sync!, block!, release!, forget!) still raise ConfigurationError.
|
|
51
|
+
# (sync!, block!, import!, release!, forget!) still raise ConfigurationError.
|
|
52
52
|
def configured? = configuration.configured?
|
|
53
53
|
|
|
54
54
|
def unconfigured!(operation)
|
|
@@ -84,7 +84,7 @@ module Bouncy
|
|
|
84
84
|
return Status.new(nil, knowledge: :unconfigured)
|
|
85
85
|
end
|
|
86
86
|
|
|
87
|
-
Status.new(Suppression.find_by(scope: scope, email: Identity.normalize(email)))
|
|
87
|
+
Status.new(current_state(Suppression).find_by(scope: scope, email: Identity.normalize(email)))
|
|
88
88
|
rescue ActiveRecord::ActiveRecordError => e
|
|
89
89
|
raise unless database_unavailable?(e)
|
|
90
90
|
|
|
@@ -106,7 +106,7 @@ module Bouncy
|
|
|
106
106
|
return StatusSet.new(keys.index_with { Status.new(nil, knowledge: :unconfigured) }, knowledge: :unconfigured)
|
|
107
107
|
end
|
|
108
108
|
|
|
109
|
-
rows = Suppression.where(scope: scope, email: keys).index_by(&:email)
|
|
109
|
+
rows = current_state(Suppression).where(scope: scope, email: keys).index_by(&:email)
|
|
110
110
|
StatusSet.new(keys.to_h { |key| [key, Status.new(rows[key])] })
|
|
111
111
|
rescue ActiveRecord::ActiveRecordError => e
|
|
112
112
|
raise unless database_unavailable?(e)
|
|
@@ -132,6 +132,14 @@ module Bouncy
|
|
|
132
132
|
end
|
|
133
133
|
end
|
|
134
134
|
|
|
135
|
+
# Records a block that another system recorded at the time at, for an app that moves its own
|
|
136
|
+
# suppression table to Bouncy. reason is :hard_bounce, :complaint or :manual. A bounce or a
|
|
137
|
+
# complaint is the same evidence as a webhook block; a manual block is a manual hold. The
|
|
138
|
+
# time can be of any age. The import calls no hook, writes nothing when it repeats, never
|
|
139
|
+
# weakens a block in force, and changes nothing when a release came at or after its time.
|
|
140
|
+
# Returns the suppression row. Does not call the provider.
|
|
141
|
+
def import!(email, reason:, at:, note: nil, actor: nil) = Importer.new.call(email, reason: reason, at: at, note: note, actor: actor)
|
|
142
|
+
|
|
135
143
|
def forget!(email)
|
|
136
144
|
current_scope = scope
|
|
137
145
|
key = Identity.normalize(email)
|
|
@@ -141,6 +149,35 @@ module Bouncy
|
|
|
141
149
|
end
|
|
142
150
|
end
|
|
143
151
|
|
|
152
|
+
# Holds the address while the block runs, so no new block for it can commit between the
|
|
153
|
+
# app's own check and its send. Inside a transaction the hold lasts until the transaction
|
|
154
|
+
# ends. Returns the block's value. Unconfigured, it only runs the block. The check needs the
|
|
155
|
+
# latest committed state: see current_state. Inside a PostgreSQL REPEATABLE READ or
|
|
156
|
+
# SERIALIZABLE transaction it raises; hold around such a transaction, and around SQLite ones.
|
|
157
|
+
#
|
|
158
|
+
# Bouncy.hold(order.email) do
|
|
159
|
+
# ReceiptMailer.with(order:).receipt.deliver_now unless Bouncy.blocked?(order.email)
|
|
160
|
+
# end
|
|
161
|
+
def hold(email, &)
|
|
162
|
+
raise ArgumentError, "Bouncy.hold needs a block" unless block_given?
|
|
163
|
+
|
|
164
|
+
unless configured?
|
|
165
|
+
unconfigured!("hold")
|
|
166
|
+
return yield
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
AddressLock.hold(email, &)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Inside Bouncy.hold, MySQL reads restrictions with a locking read, which sees the latest
|
|
173
|
+
# committed state: a REPEATABLE READ snapshot from before the hold cannot hide a newer block
|
|
174
|
+
# or release. PostgreSQL at READ COMMITTED reads the latest state with every statement.
|
|
175
|
+
def current_state(relation)
|
|
176
|
+
return relation unless AddressLock.holding? && Record.connection_pool.with_connection(&:adapter_name).match?(/Mysql|Trilogy/i)
|
|
177
|
+
|
|
178
|
+
relation.lock("LOCK IN SHARE MODE")
|
|
179
|
+
end
|
|
180
|
+
|
|
144
181
|
def unblocked
|
|
145
182
|
previous = ActiveSupport::IsolatedExecutionState[:bouncy_unblocked]
|
|
146
183
|
ActiveSupport::IsolatedExecutionState[:bouncy_unblocked] = true
|
|
@@ -169,17 +206,21 @@ require_relative "bouncy/suppression"
|
|
|
169
206
|
require_relative "bouncy/event"
|
|
170
207
|
require_relative "bouncy/status"
|
|
171
208
|
require_relative "bouncy/status_set"
|
|
209
|
+
require_relative "bouncy/address_lock"
|
|
172
210
|
require_relative "bouncy/store"
|
|
173
211
|
require_relative "bouncy/providers/base"
|
|
174
212
|
require_relative "bouncy/providers/ses"
|
|
175
213
|
require_relative "bouncy/providers/ses_parser"
|
|
214
|
+
require_relative "bouncy/notification"
|
|
176
215
|
require_relative "bouncy/ingestor"
|
|
216
|
+
require_relative "bouncy/importer"
|
|
177
217
|
require_relative "bouncy/recovery"
|
|
178
218
|
require_relative "bouncy/scope_lock"
|
|
179
219
|
require_relative "bouncy/reconciler"
|
|
180
220
|
require_relative "bouncy/model"
|
|
181
221
|
require_relative "bouncy/interceptor"
|
|
182
222
|
require_relative "bouncy/webhook"
|
|
223
|
+
require_relative "bouncy/body_limit"
|
|
183
224
|
require_relative "bouncy/sync_job"
|
|
184
225
|
require_relative "bouncy/prune_job"
|
|
185
226
|
require_relative "bouncy/engine"
|
|
@@ -19,5 +19,19 @@ Bouncy.configure do |config|
|
|
|
19
19
|
# config.soft_bounce_window = 30.days
|
|
20
20
|
# config.soft_bounce_block_for = 30.days
|
|
21
21
|
|
|
22
|
+
# Stamp one SES configuration set on every message, so SES publishes its events to the topic.
|
|
23
|
+
# config.ses.configuration_set = "transactional"
|
|
24
|
+
|
|
25
|
+
# Confirm the SNS subscription by visiting its signed SubscribeURL instead of calling
|
|
26
|
+
# sns:ConfirmSubscription.
|
|
27
|
+
# config.ses.subscription_confirmation = :subscribe_url
|
|
28
|
+
|
|
29
|
+
# An app that runs no sync job enforces its webhook blocks alone. webhook_blocking then
|
|
30
|
+
# defaults to :strict: every Permanent bounce and every complained recipient blocks.
|
|
31
|
+
# config.require_fresh_sync = false
|
|
32
|
+
|
|
33
|
+
# Every SES notification, after Bouncy recorded it. A raise makes SNS deliver it again.
|
|
34
|
+
# config.on_notification = ->(notification) { }
|
|
35
|
+
|
|
22
36
|
config.interception = :log # Review a complete sync before enabling :drop.
|
|
23
37
|
end
|
metadata
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: bouncy
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
|
+
autorequire:
|
|
8
9
|
bindir: bin
|
|
9
10
|
cert_chain: []
|
|
10
|
-
date:
|
|
11
|
+
date: 2026-10-25 00:00:00.000000000 Z
|
|
11
12
|
dependencies:
|
|
12
13
|
- !ruby/object:Gem::Dependency
|
|
13
14
|
name: rails
|
|
@@ -61,23 +62,31 @@ files:
|
|
|
61
62
|
- LICENSE.txt
|
|
62
63
|
- README.md
|
|
63
64
|
- config/routes.rb
|
|
65
|
+
- guides/README.md
|
|
64
66
|
- guides/admin.md
|
|
65
67
|
- guides/amazon-ses.md
|
|
68
|
+
- guides/api.md
|
|
66
69
|
- guides/bounce-handling.md
|
|
67
70
|
- guides/compatibility.md
|
|
71
|
+
- guides/configuration.md
|
|
68
72
|
- guides/delivery.md
|
|
69
73
|
- guides/migrating.md
|
|
74
|
+
- guides/operations.md
|
|
70
75
|
- guides/privacy.md
|
|
71
76
|
- guides/recovery.md
|
|
72
77
|
- guides/troubleshooting.md
|
|
73
78
|
- lib/bouncy.rb
|
|
79
|
+
- lib/bouncy/address_lock.rb
|
|
80
|
+
- lib/bouncy/body_limit.rb
|
|
74
81
|
- lib/bouncy/configuration.rb
|
|
75
82
|
- lib/bouncy/engine.rb
|
|
76
83
|
- lib/bouncy/event.rb
|
|
77
84
|
- lib/bouncy/identity.rb
|
|
85
|
+
- lib/bouncy/importer.rb
|
|
78
86
|
- lib/bouncy/ingestor.rb
|
|
79
87
|
- lib/bouncy/interceptor.rb
|
|
80
88
|
- lib/bouncy/model.rb
|
|
89
|
+
- lib/bouncy/notification.rb
|
|
81
90
|
- lib/bouncy/providers/base.rb
|
|
82
91
|
- lib/bouncy/providers/ses.rb
|
|
83
92
|
- lib/bouncy/providers/ses_parser.rb
|
|
@@ -106,6 +115,7 @@ metadata:
|
|
|
106
115
|
source_code_uri: https://github.com/rameerez/bouncy/tree/main
|
|
107
116
|
changelog_uri: https://github.com/rameerez/bouncy/blob/main/CHANGELOG.md
|
|
108
117
|
rubygems_mfa_required: 'true'
|
|
118
|
+
post_install_message:
|
|
109
119
|
rdoc_options: []
|
|
110
120
|
require_paths:
|
|
111
121
|
- lib
|
|
@@ -120,7 +130,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
120
130
|
- !ruby/object:Gem::Version
|
|
121
131
|
version: '0'
|
|
122
132
|
requirements: []
|
|
123
|
-
rubygems_version: 3.
|
|
133
|
+
rubygems_version: 3.5.22
|
|
134
|
+
signing_key:
|
|
124
135
|
specification_version: 4
|
|
125
136
|
summary: Email bounce handling and suppression management for Rails.
|
|
126
137
|
test_files: []
|