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.
@@ -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
- return response(405) unless environment["REQUEST_METHOD"] == "POST"
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 response(400) if body.empty?
21
+ return refuse(400, "empty body") if body.empty?
15
22
 
16
- return response(413) if body.bytesize > BODY_LIMIT
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
- response(401)
28
- rescue MalformedMessage
29
- response(400)
30
- rescue ProviderError, ConfigurationError, ActiveRecord::ActiveRecordError
31
- response(503)
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.0
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: 1980-01-02 00:00:00.000000000 Z
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.6.9
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: []