bouncy 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +32 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +213 -0
  5. data/config/routes.rb +5 -0
  6. data/guides/admin.md +71 -0
  7. data/guides/amazon-ses.md +63 -0
  8. data/guides/bounce-handling.md +27 -0
  9. data/guides/compatibility.md +42 -0
  10. data/guides/delivery.md +21 -0
  11. data/guides/migrating.md +15 -0
  12. data/guides/privacy.md +13 -0
  13. data/guides/recovery.md +28 -0
  14. data/guides/troubleshooting.md +46 -0
  15. data/lib/bouncy/configuration.rb +80 -0
  16. data/lib/bouncy/engine.rb +15 -0
  17. data/lib/bouncy/event.rb +22 -0
  18. data/lib/bouncy/identity.rb +18 -0
  19. data/lib/bouncy/ingestor.rb +94 -0
  20. data/lib/bouncy/interceptor.rb +57 -0
  21. data/lib/bouncy/model.rb +30 -0
  22. data/lib/bouncy/providers/base.rb +24 -0
  23. data/lib/bouncy/providers/ses.rb +197 -0
  24. data/lib/bouncy/providers/ses_parser.rb +112 -0
  25. data/lib/bouncy/providers/sns_verifier.rb +97 -0
  26. data/lib/bouncy/prune_job.rb +13 -0
  27. data/lib/bouncy/reconciler.rb +150 -0
  28. data/lib/bouncy/record.rb +16 -0
  29. data/lib/bouncy/recovery.rb +56 -0
  30. data/lib/bouncy/scope_lock.rb +52 -0
  31. data/lib/bouncy/status.rb +59 -0
  32. data/lib/bouncy/status_set.rb +31 -0
  33. data/lib/bouncy/store.rb +30 -0
  34. data/lib/bouncy/suppression.rb +33 -0
  35. data/lib/bouncy/sync_job.rb +13 -0
  36. data/lib/bouncy/version.rb +5 -0
  37. data/lib/bouncy/webhook.rb +40 -0
  38. data/lib/bouncy.rb +185 -0
  39. data/lib/generators/bouncy/install_generator.rb +37 -0
  40. data/lib/generators/bouncy/templates/create_bouncy_tables.rb.erb +97 -0
  41. data/lib/generators/bouncy/templates/initializer.rb.tt +23 -0
  42. data/lib/tasks/bouncy.rake +36 -0
  43. metadata +126 -0
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bouncy
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bouncy
4
+ class Webhook
5
+ BODY_LIMIT = 2 * 1024 * 1024
6
+
7
+ def call(environment)
8
+ return response(405) unless environment["REQUEST_METHOD"] == "POST"
9
+
10
+ # An unconfigured receiver answers 503 so the provider retries later instead of
11
+ # treating the endpoint as broken or, worse, as having accepted the notification.
12
+ Bouncy.configuration.validate!
13
+ body = environment.fetch("rack.input").read(BODY_LIMIT + 1).to_s
14
+ return response(400) if body.empty?
15
+
16
+ return response(413) if body.bytesize > BODY_LIMIT
17
+
18
+ adapter = Bouncy.adapter
19
+ envelope = adapter.authenticate(raw_body: body, headers: environment)
20
+ unless adapter.control(envelope)
21
+ raise MalformedMessage, "Unsupported SNS control type" unless envelope["Type"] == "Notification"
22
+
23
+ adapter.parse(envelope).each { |event| Ingestor.new.call(event) }
24
+ end
25
+ response(200)
26
+ rescue AuthenticationError
27
+ response(401)
28
+ rescue MalformedMessage
29
+ response(400)
30
+ rescue ProviderError, ConfigurationError, ActiveRecord::ActiveRecordError
31
+ response(503)
32
+ end
33
+
34
+ private
35
+
36
+ def response(status)
37
+ [status, { "content-type" => "text/plain", "cache-control" => "no-store" }, [Rack::Utils::HTTP_STATUS_CODES.fetch(status)]]
38
+ end
39
+ end
40
+ end
data/lib/bouncy.rb ADDED
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+ require "active_record"
5
+ require "active_support/all"
6
+ require "digest"
7
+ require "json"
8
+ require "time"
9
+ require_relative "bouncy/version"
10
+
11
+ module Bouncy
12
+ class Error < StandardError; end
13
+ class InvalidAddress < Error; end
14
+ class ConfigurationError < Error; end
15
+ class ProviderError < Error; end
16
+
17
+ class ReleaseFailed < ProviderError
18
+ attr_reader :outcomes
19
+
20
+ def initialize(message, outcomes:)
21
+ @outcomes = outcomes
22
+ super(message)
23
+ end
24
+ end
25
+
26
+ class UnsafeSnapshot < ProviderError; end
27
+ class ReleaseConflict < Error; end
28
+ class AuthenticationError < Error; end
29
+ class MalformedMessage < Error; end
30
+
31
+ DATABASE_UNAVAILABLE = [ActiveRecord::ConnectionNotEstablished, ActiveRecord::ConnectionTimeoutError].freeze
32
+
33
+ class << self
34
+ def configuration = (@configuration ||= Configuration.new)
35
+
36
+ def configure
37
+ yield configuration
38
+ end
39
+
40
+ def reset_configuration!
41
+ @warned_unconfigured = false
42
+ @configuration = Configuration.new
43
+ end
44
+
45
+ def scope = configuration.validate!.scope
46
+ def adapter = configuration.adapter || Providers::Ses.new(configuration)
47
+
48
+ # True when config.scope is set. An unconfigured Bouncy is inactive: it intercepts nothing,
49
+ # reports no restrictions and returns empty relations, so a host can install the gem before
50
+ # its environment variables exist without breaking mail delivery. Explicit operations
51
+ # (sync!, block!, release!, forget!) still raise ConfigurationError.
52
+ def configured? = configuration.configured?
53
+
54
+ def unconfigured!(operation)
55
+ ActiveSupport::Notifications.instrument("unconfigured.bouncy", operation: operation)
56
+ return if @warned_unconfigured
57
+
58
+ @warned_unconfigured = true
59
+ Rails.logger&.warn("[bouncy] config.scope is not set, so Bouncy is inactive: no interception, no restrictions, no sync. " \
60
+ "Set it in config/initializers/bouncy.rb.")
61
+ nil
62
+ end
63
+
64
+ def blocked = configured? ? Suppression.where(scope: scope).blocked : Suppression.none
65
+ def events = configured? ? Event.where(scope: scope) : Event.none
66
+ def last_sync = events.where(kind: "sync").order(created_at: :desc, id: :desc).first
67
+
68
+ def sync_fresh?(sync = last_sync)
69
+ !!(configured? && sync && sync.scope == scope && sync.details["complete"] && sync.created_at >= configuration.stale_after.ago)
70
+ end
71
+
72
+ def bootstrap!
73
+ sync! unless sync_fresh?
74
+ raise UnsafeSnapshot, "Bootstrap requires a fresh complete sync with verified sending policy; run bouncy:doctor" unless sync_fresh?
75
+
76
+ last_sync
77
+ end
78
+
79
+ def last_successful_sync = events.where(kind: "sync").order(created_at: :desc, id: :desc).detect { |event| event.details["complete"] }
80
+
81
+ def status(email)
82
+ unless configured?
83
+ unconfigured!("status")
84
+ return Status.new(nil, knowledge: :unconfigured)
85
+ end
86
+
87
+ Status.new(Suppression.find_by(scope: scope, email: Identity.normalize(email)))
88
+ rescue ActiveRecord::ActiveRecordError => e
89
+ raise unless database_unavailable?(e)
90
+
91
+ Status.new(nil, knowledge: :unavailable)
92
+ end
93
+
94
+ def blocked?(email) = status(email).blocked?
95
+
96
+ # Statuses for many addresses in one query, for list views and bulk checks. Returns a
97
+ # StatusSet; look addresses up by any spelling. Invalid addresses are skipped.
98
+ def statuses(emails)
99
+ keys = Array(emails).filter_map do |email|
100
+ Identity.normalize(email)
101
+ rescue InvalidAddress
102
+ nil
103
+ end.uniq
104
+ unless configured?
105
+ unconfigured!("statuses")
106
+ return StatusSet.new(keys.index_with { Status.new(nil, knowledge: :unconfigured) }, knowledge: :unconfigured)
107
+ end
108
+
109
+ rows = Suppression.where(scope: scope, email: keys).index_by(&:email)
110
+ StatusSet.new(keys.to_h { |key| [key, Status.new(rows[key])] })
111
+ rescue ActiveRecord::ActiveRecordError => e
112
+ raise unless database_unavailable?(e)
113
+
114
+ StatusSet.new(keys.index_with { Status.new(nil, knowledge: :unavailable) }, knowledge: :unavailable)
115
+ end
116
+
117
+ def sync! = Reconciler.new(adapter).call
118
+ def release!(email, note: nil, actor: nil, at: :provider) = Recovery.new(adapter).call(email, note: note, actor: actor, at: at)
119
+
120
+ def block!(email, note:, actor: nil)
121
+ raise ArgumentError, "A support note is required" if note.to_s.strip.empty?
122
+
123
+ Store.change(email) do |row|
124
+ was_blocked = row.blocked?
125
+ row.manual_blocked_at ||= Time.current
126
+ row.manual_note = note.to_s.truncate(1000)
127
+ row.summarize!
128
+ Store.event!("manual_block", email: row.email, details: {
129
+ "note" => row.manual_note, "actor" => actor.to_s.truncate(200), "became_blocked" => !was_blocked
130
+ })
131
+ row
132
+ end
133
+ end
134
+
135
+ def forget!(email)
136
+ current_scope = scope
137
+ key = Identity.normalize(email)
138
+ Suppression.transaction do
139
+ Event.where(scope: current_scope, email: key).delete_all
140
+ Suppression.where(scope: current_scope, email: key).delete_all
141
+ end
142
+ end
143
+
144
+ def unblocked
145
+ previous = ActiveSupport::IsolatedExecutionState[:bouncy_unblocked]
146
+ ActiveSupport::IsolatedExecutionState[:bouncy_unblocked] = true
147
+ yield
148
+ ensure
149
+ ActiveSupport::IsolatedExecutionState[:bouncy_unblocked] = previous
150
+ end
151
+
152
+ def notify(hook, event)
153
+ configuration.public_send(hook).call(event)
154
+ rescue StandardError => e
155
+ ActiveSupport::Notifications.instrument("hook_error.bouncy", hook: hook, error_class: e.class.name)
156
+ end
157
+
158
+ def database_unavailable?(error)
159
+ DATABASE_UNAVAILABLE.any? { |type| error.is_a?(type) } ||
160
+ %w[PG::ConnectionBad PG::UnableToSend SQLite3::CantOpenException].include?(error.cause&.class&.name)
161
+ end
162
+ end
163
+ end
164
+
165
+ require_relative "bouncy/configuration"
166
+ require_relative "bouncy/identity"
167
+ require_relative "bouncy/record"
168
+ require_relative "bouncy/suppression"
169
+ require_relative "bouncy/event"
170
+ require_relative "bouncy/status"
171
+ require_relative "bouncy/status_set"
172
+ require_relative "bouncy/store"
173
+ require_relative "bouncy/providers/base"
174
+ require_relative "bouncy/providers/ses"
175
+ require_relative "bouncy/providers/ses_parser"
176
+ require_relative "bouncy/ingestor"
177
+ require_relative "bouncy/recovery"
178
+ require_relative "bouncy/scope_lock"
179
+ require_relative "bouncy/reconciler"
180
+ require_relative "bouncy/model"
181
+ require_relative "bouncy/interceptor"
182
+ require_relative "bouncy/webhook"
183
+ require_relative "bouncy/sync_job"
184
+ require_relative "bouncy/prune_job"
185
+ require_relative "bouncy/engine"
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/active_record"
4
+
5
+ module Bouncy
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ include ActiveRecord::Generators::Migration
9
+
10
+ source_root File.expand_path("templates", __dir__)
11
+
12
+ def self.next_migration_number(dirname)
13
+ ActiveRecord::Generators::Base.next_migration_number(dirname)
14
+ end
15
+
16
+ def create_files
17
+ migration_template "create_bouncy_tables.rb.erb", File.join(db_migrate_path, "create_bouncy_tables.rb")
18
+ destination = "config/initializers/bouncy.rb"
19
+ if File.exist?(File.expand_path(destination, destination_root))
20
+ say_status :skip, "#{destination} already exists; keeping your configuration", :yellow
21
+ else
22
+ template "initializer.rb.tt", destination
23
+ end
24
+ say "Run bin/rails db:migrate. Set your account scope, region and allowed SNS topics."
25
+ say "Mount Bouncy::Engine at /bouncy, then follow guides/amazon-ses.md."
26
+ say "Schedule Bouncy::SyncJob hourly and Bouncy::PruneJob daily using your application's scheduler."
27
+ say "Optional model integration: bouncy :email. No model or admin files were changed."
28
+ end
29
+
30
+ private
31
+
32
+ def migration_version
33
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateBouncyTables < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ primary_key_type, = primary_and_foreign_key_types
6
+
7
+ create_table :bouncy_suppressions, **primary_key_options(primary_key_type) do |t|
8
+ t.string :scope, null: false, limit: 191, **identity_column_options
9
+ t.string :email, null: false, limit: 254, **identity_column_options
10
+ t.string :provider, null: false
11
+ t.string :reason
12
+ t.datetime :blocked_at
13
+ t.datetime :manual_blocked_at
14
+ t.text :manual_note
15
+ t.public_send(json_column_type, :provider_entries, **json_column_options([]))
16
+ t.datetime :provider_blocked_at
17
+ t.string :provider_reason
18
+ t.datetime :event_blocked_at
19
+ t.string :event_reason
20
+ t.datetime :last_event_at
21
+ t.datetime :provider_checked_at
22
+ t.datetime :released_before
23
+ t.integer :soft_bounce_count, null: false, default: 0
24
+ t.datetime :last_soft_bounce_at
25
+ t.datetime :soft_blocked_until
26
+ t.integer :lock_version, null: false, default: 0
27
+ t.public_send(json_column_type, :details, **json_column_options({}))
28
+ t.timestamps
29
+ end
30
+ add_index :bouncy_suppressions, [ :scope, :email ], unique: true
31
+ add_index :bouncy_suppressions, [ :scope, :blocked_at ]
32
+ add_index :bouncy_suppressions, [ :scope, :reason ]
33
+
34
+ create_table :bouncy_events, **primary_key_options(primary_key_type) do |t|
35
+ t.string :scope, null: false, limit: 191, **identity_column_options
36
+ t.string :email, limit: 254, **identity_column_options
37
+ t.string :kind, null: false
38
+ t.string :source, null: false
39
+ t.string :provider
40
+ t.string :provider_event_id
41
+ t.string :message_id
42
+ t.string :dedupe_key, limit: 64
43
+ t.datetime :occurred_at, null: false
44
+ t.datetime :received_at, null: false
45
+ t.string :provider_reason
46
+ t.string :status_code
47
+ t.text :diagnostic
48
+ t.public_send(json_column_type, :details, **json_column_options({}))
49
+ t.datetime :created_at, null: false
50
+ end
51
+ add_index :bouncy_events, :dedupe_key, unique: true
52
+ add_index :bouncy_events, [ :scope, :email, :occurred_at ]
53
+ add_index :bouncy_events, [ :scope, :kind, :created_at ]
54
+ add_index :bouncy_events, :created_at
55
+ end
56
+
57
+ private
58
+
59
+ # Shared house convention: resolve the host key setting when migrating,
60
+ # not when generating, so one checked-in migration works in every environment.
61
+ def primary_and_foreign_key_types
62
+ config = Rails.configuration.generators
63
+ setting = config.options.fetch(config.orm, {})[:primary_key_type]
64
+ [ setting || :primary_key, setting || :bigint ]
65
+ end
66
+
67
+ def primary_key_options(type)
68
+ # PostgreSQL generates native UUIDs. Other adapters store application-generated
69
+ # UUID strings; Bouncy::Record supplies them without adapter queries at boot.
70
+ return { id: :string, limit: 36 } if type.to_s == "uuid" && !postgresql?
71
+
72
+ { id: type }
73
+ end
74
+
75
+ def json_column_type
76
+ postgresql? ? :jsonb : :json
77
+ end
78
+
79
+ def json_column_options(default)
80
+ # Avoid literal JSON defaults on MySQL. Models supply fresh Ruby defaults.
81
+ # This also works on versions that require an expression for JSON defaults.
82
+ mysql? ? { null: false } : { null: false, default: default }
83
+ end
84
+
85
+ def identity_column_options
86
+ # A case/accent-insensitive MySQL collation would broaden our canonical key.
87
+ mysql? ? { collation: "utf8mb4_bin" } : {}
88
+ end
89
+
90
+ def postgresql?
91
+ connection.adapter_name.downcase.include?("postgres")
92
+ end
93
+
94
+ def mysql?
95
+ connection.adapter_name.downcase.match?(/mysql|trilogy/)
96
+ end
97
+ end
@@ -0,0 +1,23 @@
1
+ Bouncy.configure do |config|
2
+ config.provider = :ses
3
+ # Leave the scope blank and Bouncy stays inactive: mail is delivered untouched, every address
4
+ # reads as unrestricted, and a warning is logged once. Set it to start observing.
5
+ config.scope = ENV["BOUNCY_SCOPE"] # ses:123456789012:us-east-1:account
6
+ config.ses.region = ENV["AWS_REGION"]
7
+ config.ses.topic_arns = ENV.fetch("BOUNCY_SNS_TOPIC_ARNS", "").split(",").map(&:strip)
8
+
9
+ # Imported provider restrictions are enforced only after `bin/rails bouncy:doctor` reports
10
+ # policy_verified: true. List every identity and configuration set you send through, then
11
+ # confirm the lists are complete. Until then sync mirrors the list without blocking anything.
12
+ config.ses.identities = []
13
+ config.ses.configuration_sets = []
14
+ config.ses.all_sending_paths_listed = false
15
+
16
+ # Soft bounces are temporary refusals, so they are recorded without blocking. Set a threshold
17
+ # from 1 to 50 to hold an address after repeated SES MailboxFull events inside the window.
18
+ # config.soft_bounce_threshold = 3
19
+ # config.soft_bounce_window = 30.days
20
+ # config.soft_bounce_block_for = 30.days
21
+
22
+ config.interception = :log # Review a complete sync before enabling :drop.
23
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :bouncy do
4
+ desc "Require a fresh verified mirror before starting a host's mail workers"
5
+ task bootstrap: :environment do
6
+ puts JSON.pretty_generate(Bouncy.bootstrap!.details)
7
+ end
8
+
9
+ desc "Import and reconcile the configured provider scope"
10
+ task sync: :environment do
11
+ puts JSON.pretty_generate(Bouncy.sync!.details)
12
+ end
13
+
14
+ desc "Check provider scope and local freshness without changing AWS"
15
+ task doctor: :environment do
16
+ checks = Bouncy.adapter.doctor
17
+ checks["last_attempt"] = Bouncy.last_sync&.created_at
18
+ checks["last_success"] = Bouncy.last_successful_sync&.created_at
19
+ puts JSON.pretty_generate(checks)
20
+ end
21
+
22
+ namespace :ses do
23
+ desc "Print a read-only SES setup plan; never provisions resources"
24
+ task setup: :environment do
25
+ raise Bouncy::ConfigurationError, "Write mode is not supported; omit APPLY" if ENV.key?("APPLY")
26
+
27
+ puts JSON.pretty_generate(Bouncy.adapter.doctor)
28
+ puts "1. Deploy POST <engine mount>/webhooks/ses with the exact topic allowlist (the mount is usually /bouncy)."
29
+ puts "2. Review the SNS publisher policy: SES service principal plus SourceAccount and SourceArn conditions."
30
+ puts "3. Subscribe that HTTPS URL; preserve existing feedback destinations. Disable raw message delivery."
31
+ puts "4. Confirm subscription status in SNS and test receiver delivery/retries."
32
+ puts "5. Run bouncy:sync; schedule Bouncy::SyncJob hourly and Bouncy::PruneJob daily."
33
+ puts "6. Review imported restrictions and perform a staging delivery test before enabling :drop."
34
+ end
35
+ end
36
+ end
metadata ADDED
@@ -0,0 +1,126 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bouncy
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - rameerez
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 7.2.3.2
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: 7.2.3.2
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9'
32
+ - !ruby/object:Gem::Dependency
33
+ name: json
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '2.0'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '3'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '2.0'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '3'
52
+ description: Know when your app's emails bounce. Mirror provider restrictions, query
53
+ email status locally, and recover addresses with an audited, provider-aware release.
54
+ email:
55
+ - rubygems@rameerez.com
56
+ executables: []
57
+ extensions: []
58
+ extra_rdoc_files: []
59
+ files:
60
+ - CHANGELOG.md
61
+ - LICENSE.txt
62
+ - README.md
63
+ - config/routes.rb
64
+ - guides/admin.md
65
+ - guides/amazon-ses.md
66
+ - guides/bounce-handling.md
67
+ - guides/compatibility.md
68
+ - guides/delivery.md
69
+ - guides/migrating.md
70
+ - guides/privacy.md
71
+ - guides/recovery.md
72
+ - guides/troubleshooting.md
73
+ - lib/bouncy.rb
74
+ - lib/bouncy/configuration.rb
75
+ - lib/bouncy/engine.rb
76
+ - lib/bouncy/event.rb
77
+ - lib/bouncy/identity.rb
78
+ - lib/bouncy/ingestor.rb
79
+ - lib/bouncy/interceptor.rb
80
+ - lib/bouncy/model.rb
81
+ - lib/bouncy/providers/base.rb
82
+ - lib/bouncy/providers/ses.rb
83
+ - lib/bouncy/providers/ses_parser.rb
84
+ - lib/bouncy/providers/sns_verifier.rb
85
+ - lib/bouncy/prune_job.rb
86
+ - lib/bouncy/reconciler.rb
87
+ - lib/bouncy/record.rb
88
+ - lib/bouncy/recovery.rb
89
+ - lib/bouncy/scope_lock.rb
90
+ - lib/bouncy/status.rb
91
+ - lib/bouncy/status_set.rb
92
+ - lib/bouncy/store.rb
93
+ - lib/bouncy/suppression.rb
94
+ - lib/bouncy/sync_job.rb
95
+ - lib/bouncy/version.rb
96
+ - lib/bouncy/webhook.rb
97
+ - lib/generators/bouncy/install_generator.rb
98
+ - lib/generators/bouncy/templates/create_bouncy_tables.rb.erb
99
+ - lib/generators/bouncy/templates/initializer.rb.tt
100
+ - lib/tasks/bouncy.rake
101
+ homepage: https://github.com/rameerez/bouncy
102
+ licenses:
103
+ - MIT
104
+ metadata:
105
+ allowed_push_host: https://rubygems.org
106
+ source_code_uri: https://github.com/rameerez/bouncy/tree/main
107
+ changelog_uri: https://github.com/rameerez/bouncy/blob/main/CHANGELOG.md
108
+ rubygems_mfa_required: 'true'
109
+ rdoc_options: []
110
+ require_paths:
111
+ - lib
112
+ required_ruby_version: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '3.3'
117
+ required_rubygems_version: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - ">="
120
+ - !ruby/object:Gem::Version
121
+ version: '0'
122
+ requirements: []
123
+ rubygems_version: 3.6.9
124
+ specification_version: 4
125
+ summary: Email bounce handling and suppression management for Rails.
126
+ test_files: []