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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +32 -0
- data/LICENSE.txt +21 -0
- data/README.md +213 -0
- data/config/routes.rb +5 -0
- data/guides/admin.md +71 -0
- data/guides/amazon-ses.md +63 -0
- data/guides/bounce-handling.md +27 -0
- data/guides/compatibility.md +42 -0
- data/guides/delivery.md +21 -0
- data/guides/migrating.md +15 -0
- data/guides/privacy.md +13 -0
- data/guides/recovery.md +28 -0
- data/guides/troubleshooting.md +46 -0
- data/lib/bouncy/configuration.rb +80 -0
- data/lib/bouncy/engine.rb +15 -0
- data/lib/bouncy/event.rb +22 -0
- data/lib/bouncy/identity.rb +18 -0
- data/lib/bouncy/ingestor.rb +94 -0
- data/lib/bouncy/interceptor.rb +57 -0
- data/lib/bouncy/model.rb +30 -0
- data/lib/bouncy/providers/base.rb +24 -0
- data/lib/bouncy/providers/ses.rb +197 -0
- data/lib/bouncy/providers/ses_parser.rb +112 -0
- data/lib/bouncy/providers/sns_verifier.rb +97 -0
- data/lib/bouncy/prune_job.rb +13 -0
- data/lib/bouncy/reconciler.rb +150 -0
- data/lib/bouncy/record.rb +16 -0
- data/lib/bouncy/recovery.rb +56 -0
- data/lib/bouncy/scope_lock.rb +52 -0
- data/lib/bouncy/status.rb +59 -0
- data/lib/bouncy/status_set.rb +31 -0
- data/lib/bouncy/store.rb +30 -0
- data/lib/bouncy/suppression.rb +33 -0
- data/lib/bouncy/sync_job.rb +13 -0
- data/lib/bouncy/version.rb +5 -0
- data/lib/bouncy/webhook.rb +40 -0
- data/lib/bouncy.rb +185 -0
- data/lib/generators/bouncy/install_generator.rb +37 -0
- data/lib/generators/bouncy/templates/create_bouncy_tables.rb.erb +97 -0
- data/lib/generators/bouncy/templates/initializer.rb.tt +23 -0
- data/lib/tasks/bouncy.rake +36 -0
- metadata +126 -0
|
@@ -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: []
|