ses-dashboard 0.1.0 → 0.5.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/README.md +226 -31
- data/app/assets/javascripts/ses_dashboard/application.js +263 -0
- data/app/assets/stylesheets/ses_dashboard/application.css +65 -0
- data/app/controllers/ses_dashboard/projects_controller.rb +1 -1
- data/app/controllers/ses_dashboard/webhooks_controller.rb +22 -1
- data/app/models/ses_dashboard/project.rb +48 -0
- data/app/views/ses_dashboard/projects/_form.html.erb +16 -0
- data/db/migrate/20240101000001_create_ses_dashboard_projects.rb +7 -1
- data/db/migrate/20240101000002_create_ses_dashboard_emails.rb +7 -1
- data/db/migrate/20240101000003_create_ses_dashboard_email_events.rb +7 -1
- data/db/migrate/20240101000004_add_webhook_forwards_to_ses_dashboard_projects.rb +11 -0
- data/lib/ses/dashboard.rb +1 -0
- data/lib/ses_dashboard/client.rb +3 -3
- data/lib/ses_dashboard/engine.rb +0 -7
- data/lib/ses_dashboard/forward_rule.rb +72 -0
- data/lib/ses_dashboard/sns_signature_verifier.rb +92 -0
- data/lib/ses_dashboard/version.rb +1 -1
- data/lib/ses_dashboard/webhook_forwarder.rb +105 -0
- data/lib/ses_dashboard/webhook_processor.rb +14 -2
- data/lib/ses_dashboard.rb +23 -11
- metadata +6 -1
|
@@ -217,10 +217,75 @@ tr:hover td { background: var(--color-bg); }
|
|
|
217
217
|
/* ── Token display ───────────────────────────────────────── */
|
|
218
218
|
.token-display { font-family: monospace; font-size: .8125rem; background: var(--color-bg); padding: .25rem .5rem; border-radius: .25rem; border: 1px solid var(--color-border); }
|
|
219
219
|
|
|
220
|
+
/* ── Webhook Forwards builder ────────────────────────────── */
|
|
221
|
+
.wf-target {
|
|
222
|
+
margin-bottom: .75rem;
|
|
223
|
+
padding: 1rem;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.wf-target-header {
|
|
227
|
+
display: flex;
|
|
228
|
+
align-items: center;
|
|
229
|
+
justify-content: space-between;
|
|
230
|
+
margin-bottom: .75rem;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.wf-target-title {
|
|
234
|
+
font-weight: 600;
|
|
235
|
+
font-size: .875rem;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.wf-rules {
|
|
239
|
+
display: flex;
|
|
240
|
+
flex-direction: column;
|
|
241
|
+
gap: .5rem;
|
|
242
|
+
margin-bottom: .5rem;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.wf-rule {
|
|
246
|
+
display: flex;
|
|
247
|
+
align-items: flex-start;
|
|
248
|
+
gap: .5rem;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.wf-rule .wf-field { flex: 0 0 140px; }
|
|
252
|
+
.wf-rule .wf-operator { flex: 0 0 130px; }
|
|
253
|
+
.wf-rule .wf-value-wrap { flex: 1; min-width: 0; }
|
|
254
|
+
.wf-rule .wf-value { width: 100%; }
|
|
255
|
+
.wf-rule .btn { flex-shrink: 0; align-self: flex-start; margin-top: 1px; }
|
|
256
|
+
|
|
257
|
+
.wf-checkboxes {
|
|
258
|
+
display: flex;
|
|
259
|
+
flex-wrap: wrap;
|
|
260
|
+
gap: .15rem .75rem;
|
|
261
|
+
padding: .4rem .25rem;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.wf-checkbox-label {
|
|
265
|
+
display: inline-flex;
|
|
266
|
+
align-items: center;
|
|
267
|
+
gap: .3rem;
|
|
268
|
+
font-size: .8125rem;
|
|
269
|
+
cursor: pointer;
|
|
270
|
+
white-space: nowrap;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.form-hint {
|
|
274
|
+
display: block;
|
|
275
|
+
font-size: .8125rem;
|
|
276
|
+
color: var(--color-text-muted);
|
|
277
|
+
margin-top: .25rem;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
#wf-add-target { margin-top: .25rem; }
|
|
281
|
+
|
|
220
282
|
/* ── Responsive ──────────────────────────────────────────── */
|
|
221
283
|
@media (max-width: 640px) {
|
|
222
284
|
.stat-grid { grid-template-columns: 1fr 1fr; }
|
|
223
285
|
.page-header { flex-direction: column; align-items: flex-start; gap: .5rem; }
|
|
224
286
|
.filter-bar { flex-direction: column; }
|
|
225
287
|
.filter-bar .form-control { width: 100%; }
|
|
288
|
+
.wf-rule { flex-wrap: wrap; }
|
|
289
|
+
.wf-rule .wf-field, .wf-rule .wf-operator { flex: 1 1 45%; }
|
|
290
|
+
.wf-rule .wf-value-wrap { flex: 1 1 100%; }
|
|
226
291
|
}
|
|
@@ -9,7 +9,11 @@ module SesDashboard
|
|
|
9
9
|
|
|
10
10
|
def create
|
|
11
11
|
project = Project.find_by!(token: params[:project_token])
|
|
12
|
-
|
|
12
|
+
request.body.rewind
|
|
13
|
+
body = request.body.read
|
|
14
|
+
sns = parse_sns_json(body)
|
|
15
|
+
|
|
16
|
+
verify_sns_signature!(sns) if sns && SesDashboard.configuration.verify_sns_signature
|
|
13
17
|
|
|
14
18
|
result = WebhookProcessor.new(body).process
|
|
15
19
|
|
|
@@ -18,11 +22,15 @@ module SesDashboard
|
|
|
18
22
|
confirm_subscription(result.subscribe_url)
|
|
19
23
|
when :process_event
|
|
20
24
|
WebhookEventPersistor.new(project, result).persist
|
|
25
|
+
WebhookForwarder.new(project, result).forward
|
|
21
26
|
end
|
|
22
27
|
|
|
23
28
|
head :ok
|
|
24
29
|
rescue ActiveRecord::RecordNotFound
|
|
25
30
|
head :not_found
|
|
31
|
+
rescue SnsSignatureVerifier::VerificationError => e
|
|
32
|
+
Rails.logger.warn("[SesDashboard] SNS signature rejected: #{e.message}") if defined?(Rails)
|
|
33
|
+
head :forbidden
|
|
26
34
|
rescue => e
|
|
27
35
|
Rails.logger.error("[SesDashboard] Webhook error: #{e.message}") if defined?(Rails)
|
|
28
36
|
head :unprocessable_entity
|
|
@@ -35,5 +43,18 @@ module SesDashboard
|
|
|
35
43
|
rescue => e
|
|
36
44
|
Rails.logger.warn("[SesDashboard] SNS subscription confirm failed: #{e.message}") if defined?(Rails)
|
|
37
45
|
end
|
|
46
|
+
|
|
47
|
+
def parse_sns_json(body)
|
|
48
|
+
JSON.parse(body)
|
|
49
|
+
rescue JSON::ParserError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def verify_sns_signature!(sns)
|
|
54
|
+
# Skip verification for raw delivery — no SNS envelope means no signature fields.
|
|
55
|
+
return unless sns&.key?("SigningCertURL")
|
|
56
|
+
|
|
57
|
+
SnsSignatureVerifier.new(sns).verify!
|
|
58
|
+
end
|
|
38
59
|
end
|
|
39
60
|
end
|
|
@@ -6,15 +6,63 @@ module SesDashboard
|
|
|
6
6
|
|
|
7
7
|
validates :name, presence: true
|
|
8
8
|
validates :token, presence: true, uniqueness: true
|
|
9
|
+
validate :webhook_forwards_must_be_valid_json
|
|
9
10
|
|
|
10
11
|
before_validation :generate_token, on: :create
|
|
11
12
|
|
|
12
13
|
scope :ordered, -> { order(:name) }
|
|
13
14
|
|
|
15
|
+
# Manual JSON serialization for the webhook_forwards column.
|
|
16
|
+
# Avoids Rails serialize API which changed between 7.0 (positional)
|
|
17
|
+
# and 8.0 (keyword-only coder:).
|
|
18
|
+
def webhook_forwards
|
|
19
|
+
raw = read_attribute(:webhook_forwards)
|
|
20
|
+
return [] if raw.blank?
|
|
21
|
+
return raw if raw.is_a?(Array)
|
|
22
|
+
|
|
23
|
+
JSON.parse(raw)
|
|
24
|
+
rescue JSON::ParserError
|
|
25
|
+
[]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def webhook_forwards=(value)
|
|
29
|
+
write_attribute(:webhook_forwards, value.is_a?(String) ? value : Array(value).to_json)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Virtual accessor for editing webhook_forwards as a JSON string in forms.
|
|
33
|
+
def webhook_forwards_text
|
|
34
|
+
forwards = Array(webhook_forwards)
|
|
35
|
+
forwards.empty? ? "" : JSON.pretty_generate(forwards)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def webhook_forwards_text=(value)
|
|
39
|
+
if value.blank?
|
|
40
|
+
self.webhook_forwards = []
|
|
41
|
+
return
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
parsed = JSON.parse(value)
|
|
45
|
+
self.webhook_forwards = Array(parsed)
|
|
46
|
+
rescue JSON::ParserError
|
|
47
|
+
@webhook_forwards_invalid = true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Returns the effective forwards: project-level if configured, else global config.
|
|
51
|
+
def effective_webhook_forwards
|
|
52
|
+
project_level = Array(webhook_forwards).select { |f| (f[:url] || f["url"]).present? }
|
|
53
|
+
return project_level if project_level.any?
|
|
54
|
+
|
|
55
|
+
Array(SesDashboard.configuration&.webhook_forwards)
|
|
56
|
+
end
|
|
57
|
+
|
|
14
58
|
private
|
|
15
59
|
|
|
16
60
|
def generate_token
|
|
17
61
|
self.token ||= SecureRandom.hex(16)
|
|
18
62
|
end
|
|
63
|
+
|
|
64
|
+
def webhook_forwards_must_be_valid_json
|
|
65
|
+
errors.add(:webhook_forwards, "is not valid JSON") if @webhook_forwards_invalid
|
|
66
|
+
end
|
|
19
67
|
end
|
|
20
68
|
end
|
|
@@ -19,6 +19,22 @@
|
|
|
19
19
|
<%= f.text_area :description, class: "form-control", placeholder: "Optional description" %>
|
|
20
20
|
</div>
|
|
21
21
|
|
|
22
|
+
<div class="form-group">
|
|
23
|
+
<label class="form-label">Webhook Forwards</label>
|
|
24
|
+
<small class="form-hint" style="margin-bottom:.5rem;display:block;">
|
|
25
|
+
Forward events to external URLs (e.g. Zapier). Add rules to filter which events are forwarded.
|
|
26
|
+
All rules must match. Leave empty to use global defaults.
|
|
27
|
+
</small>
|
|
28
|
+
|
|
29
|
+
<%= f.hidden_field :webhook_forwards_text, id: "webhook-forwards-json" %>
|
|
30
|
+
|
|
31
|
+
<div id="wf-targets"></div>
|
|
32
|
+
|
|
33
|
+
<button type="button" class="btn btn-outline btn-sm" id="wf-add-target">+ Add Forward Target</button>
|
|
34
|
+
|
|
35
|
+
<script type="application/json" id="wf-initial-data"><%= raw project.webhook_forwards_text.html_safe %></script>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
22
38
|
<%= f.submit class: "btn btn-primary" %>
|
|
23
39
|
<%= link_to "Cancel", projects_path, class: "btn btn-outline" %>
|
|
24
40
|
<% end %>
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
_migration = begin
|
|
2
|
+
ActiveRecord::Migration["#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}"]
|
|
3
|
+
rescue ArgumentError
|
|
4
|
+
ActiveRecord::Migration["#{Rails::VERSION::MAJOR}.0"]
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
class CreateSesDashboardProjects < _migration
|
|
2
8
|
def change
|
|
3
9
|
create_table :ses_dashboard_projects do |t|
|
|
4
10
|
t.string :name, null: false
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
_migration = begin
|
|
2
|
+
ActiveRecord::Migration["#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}"]
|
|
3
|
+
rescue ArgumentError
|
|
4
|
+
ActiveRecord::Migration["#{Rails::VERSION::MAJOR}.0"]
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
class CreateSesDashboardEmails < _migration
|
|
2
8
|
def change
|
|
3
9
|
create_table :ses_dashboard_emails do |t|
|
|
4
10
|
t.references :project, null: false, foreign_key: { to_table: :ses_dashboard_projects }
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
_migration = begin
|
|
2
|
+
ActiveRecord::Migration["#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}"]
|
|
3
|
+
rescue ArgumentError
|
|
4
|
+
ActiveRecord::Migration["#{Rails::VERSION::MAJOR}.0"]
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
class CreateSesDashboardEmailEvents < _migration
|
|
2
8
|
def change
|
|
3
9
|
create_table :ses_dashboard_email_events do |t|
|
|
4
10
|
t.references :email, null: false, foreign_key: { to_table: :ses_dashboard_emails }
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
_migration = begin
|
|
2
|
+
ActiveRecord::Migration["#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}"]
|
|
3
|
+
rescue ArgumentError
|
|
4
|
+
ActiveRecord::Migration["#{Rails::VERSION::MAJOR}.0"]
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
class AddWebhookForwardsToSesDashboardProjects < _migration
|
|
8
|
+
def change
|
|
9
|
+
add_column :ses_dashboard_projects, :webhook_forwards, :text
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
require "ses_dashboard"
|
data/lib/ses_dashboard/client.rb
CHANGED
|
@@ -42,9 +42,9 @@ module SesDashboard
|
|
|
42
42
|
# Options: from:, to:, subject:, body:, configuration_set: (optional)
|
|
43
43
|
def send_email(from:, to:, subject:, body:, configuration_set: nil)
|
|
44
44
|
params = {
|
|
45
|
-
source:
|
|
46
|
-
|
|
47
|
-
message:
|
|
45
|
+
source: from,
|
|
46
|
+
destination: { to_addresses: [to] },
|
|
47
|
+
message: {
|
|
48
48
|
subject: { data: subject, charset: "UTF-8" },
|
|
49
49
|
body: { text: { data: body, charset: "UTF-8" } }
|
|
50
50
|
}
|
data/lib/ses_dashboard/engine.rb
CHANGED
|
@@ -13,13 +13,6 @@ module SesDashboard
|
|
|
13
13
|
g.helper false
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
# Make engine migrations available to the host app via `rails ses_dashboard:install:migrations`
|
|
17
|
-
initializer "ses_dashboard.add_migrations" do |app|
|
|
18
|
-
unless app.root.to_s == root.to_s
|
|
19
|
-
config.paths["db/migrate"].to_a.each { |path| app.config.paths["db/migrate"] << path }
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
|
|
23
16
|
# Precompile engine assets
|
|
24
17
|
initializer "ses_dashboard.assets" do |app|
|
|
25
18
|
if app.config.respond_to?(:assets)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
module SesDashboard
|
|
2
|
+
# Evaluates a single forwarding rule against a WebhookProcessor::Result.
|
|
3
|
+
#
|
|
4
|
+
# A rule is a Hash with three keys:
|
|
5
|
+
# "field" — which Result attribute to test
|
|
6
|
+
# "operator" — how to compare
|
|
7
|
+
# "value" — what to compare against
|
|
8
|
+
#
|
|
9
|
+
# Supported fields:
|
|
10
|
+
# "event_type" — string ("bounce", "delivery", "complaint", …)
|
|
11
|
+
# "source" — string (From: address)
|
|
12
|
+
# "destination" — array (To: addresses — rule passes if ANY element matches)
|
|
13
|
+
# "subject" — string (email subject)
|
|
14
|
+
#
|
|
15
|
+
# Supported operators:
|
|
16
|
+
# "in" — field value is included in the given array
|
|
17
|
+
# "not_in" — field value is NOT included in the given array
|
|
18
|
+
# "eq" — exact string equality
|
|
19
|
+
# "not_eq" — string inequality
|
|
20
|
+
# "starts_with" — prefix match (for arrays: any element matches)
|
|
21
|
+
# "ends_with" — suffix match (for arrays: any element matches)
|
|
22
|
+
# "contains" — substring match (for arrays: any element matches)
|
|
23
|
+
#
|
|
24
|
+
# New fields/operators can be added by extending the private methods below.
|
|
25
|
+
#
|
|
26
|
+
class ForwardRule
|
|
27
|
+
def initialize(rule_hash)
|
|
28
|
+
@field = (rule_hash["field"] || rule_hash[:field]).to_s
|
|
29
|
+
@operator = (rule_hash["operator"] || rule_hash[:operator]).to_s
|
|
30
|
+
@value = rule_hash["value"] || rule_hash[:value]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def match?(result)
|
|
34
|
+
field_value = extract_field(result)
|
|
35
|
+
evaluate(field_value)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def extract_field(result)
|
|
41
|
+
case @field
|
|
42
|
+
when "event_type" then result.event_type
|
|
43
|
+
when "source" then result.source
|
|
44
|
+
when "destination" then result.destination
|
|
45
|
+
when "subject" then result.subject
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def evaluate(field_value)
|
|
50
|
+
case @operator
|
|
51
|
+
when "in" then Array(@value).include?(field_value)
|
|
52
|
+
when "not_in" then !Array(@value).include?(field_value)
|
|
53
|
+
when "eq" then any_string_match(field_value) { |v| v == @value.to_s }
|
|
54
|
+
when "not_eq" then !any_string_match(field_value) { |v| v == @value.to_s }
|
|
55
|
+
when "starts_with" then any_string_match(field_value) { |v| v.start_with?(@value.to_s) }
|
|
56
|
+
when "ends_with" then any_string_match(field_value) { |v| v.end_with?(@value.to_s) }
|
|
57
|
+
when "contains" then any_string_match(field_value) { |v| v.include?(@value.to_s) }
|
|
58
|
+
else false
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# For array fields (e.g. destination), passes if ANY element matches.
|
|
63
|
+
# For scalar fields, tests the single value.
|
|
64
|
+
def any_string_match(field_value, &block)
|
|
65
|
+
if field_value.is_a?(Array)
|
|
66
|
+
field_value.any? { |v| block.call(v.to_s) }
|
|
67
|
+
else
|
|
68
|
+
block.call(field_value.to_s)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "base64"
|
|
5
|
+
|
|
6
|
+
module SesDashboard
|
|
7
|
+
# Verifies the authenticity of an SNS HTTP POST using AWS's RSA signature.
|
|
8
|
+
#
|
|
9
|
+
# SNS signs messages with SHA1 (SignatureVersion "1") or SHA256
|
|
10
|
+
# (SignatureVersion "2") using a per-region X.509 certificate hosted at
|
|
11
|
+
# a amazonaws.com URL included in every message.
|
|
12
|
+
#
|
|
13
|
+
# Verification steps:
|
|
14
|
+
# 1. Validate the SigningCertURL is from amazonaws.com (prevents substitution attacks)
|
|
15
|
+
# 2. Fetch and parse the X.509 certificate
|
|
16
|
+
# 3. Reconstruct the canonical string-to-sign
|
|
17
|
+
# 4. Verify the Signature against the cert's public key
|
|
18
|
+
#
|
|
19
|
+
class SnsSignatureVerifier
|
|
20
|
+
# Only trust certs hosted on Amazon's own infrastructure.
|
|
21
|
+
CERT_URL_PATTERN = %r{\Ahttps://sns\.[a-z0-9\-]+\.amazonaws\.com/}.freeze
|
|
22
|
+
|
|
23
|
+
class VerificationError < SesDashboard::Error; end
|
|
24
|
+
|
|
25
|
+
def initialize(sns_message)
|
|
26
|
+
@msg = sns_message
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns true if valid, raises VerificationError if not.
|
|
30
|
+
def verify!
|
|
31
|
+
validate_cert_url!
|
|
32
|
+
cert = fetch_cert
|
|
33
|
+
digest = signature_version == "2" ? OpenSSL::Digest::SHA256.new : OpenSSL::Digest::SHA1.new
|
|
34
|
+
|
|
35
|
+
unless cert.public_key.verify(digest, decoded_signature, string_to_sign)
|
|
36
|
+
raise VerificationError, "SNS signature verification failed"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def signature_version
|
|
45
|
+
@msg["SignatureVersion"] || "1"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def validate_cert_url!
|
|
49
|
+
url = @msg["SigningCertURL"].to_s
|
|
50
|
+
unless url.match?(CERT_URL_PATTERN)
|
|
51
|
+
raise VerificationError, "Invalid SigningCertURL: #{url.inspect}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def fetch_cert
|
|
56
|
+
url = URI(@msg["SigningCertURL"])
|
|
57
|
+
pem = Net::HTTP.get(url)
|
|
58
|
+
OpenSSL::X509::Certificate.new(pem)
|
|
59
|
+
rescue => e
|
|
60
|
+
raise VerificationError, "Failed to fetch signing certificate: #{e.message}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def decoded_signature
|
|
64
|
+
Base64.decode64(@msg["Signature"].to_s)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# AWS canonical string-to-sign — field order is fixed per message type.
|
|
68
|
+
# https://docs.aws.amazon.com/sns/latest/dg/sns-verify-signature-of-message.html
|
|
69
|
+
def string_to_sign
|
|
70
|
+
fields = case @msg["Type"]
|
|
71
|
+
when "Notification"
|
|
72
|
+
notification_fields
|
|
73
|
+
when "SubscriptionConfirmation", "UnsubscribeConfirmation"
|
|
74
|
+
subscription_fields
|
|
75
|
+
else
|
|
76
|
+
raise VerificationError, "Unknown SNS message type: #{@msg["Type"].inspect}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
fields.map { |key| "#{key}\n#{@msg[key]}\n" }.join
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def notification_fields
|
|
83
|
+
fields = %w[Message MessageId Subject Timestamp TopicArn Type]
|
|
84
|
+
# Subject is optional — omit if absent (AWS does the same)
|
|
85
|
+
@msg["Subject"] ? fields : fields - ["Subject"]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def subscription_fields
|
|
89
|
+
%w[Message MessageId SubscribeURL Timestamp Token TopicArn Type]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SesDashboard
|
|
6
|
+
# Forwards processed SES events to external webhook URLs based on configurable rules.
|
|
7
|
+
#
|
|
8
|
+
# Rules are resolved per-project (see Project#effective_webhook_forwards), with a
|
|
9
|
+
# global fallback from SesDashboard.configuration.webhook_forwards.
|
|
10
|
+
#
|
|
11
|
+
# Each forward entry supports a `rules` array — all rules must match (AND logic).
|
|
12
|
+
# If no rules are specified, every event is forwarded.
|
|
13
|
+
#
|
|
14
|
+
# [
|
|
15
|
+
# {
|
|
16
|
+
# "url": "https://hooks.zapier.com/hooks/catch/abc/xyz/",
|
|
17
|
+
# "rules": [
|
|
18
|
+
# { "field": "event_type", "operator": "in", "value": ["bounce", "complaint"] },
|
|
19
|
+
# { "field": "source", "operator": "ends_with", "value": "@myapp.com" }
|
|
20
|
+
# ]
|
|
21
|
+
# }
|
|
22
|
+
# ]
|
|
23
|
+
#
|
|
24
|
+
# Legacy shorthand `event_types` is still supported and auto-converted to a rule:
|
|
25
|
+
# { "url": "...", "event_types": ["bounce"] }
|
|
26
|
+
# ⟶ rules: [{ field: "event_type", operator: "in", value: ["bounce"] }]
|
|
27
|
+
#
|
|
28
|
+
class WebhookForwarder
|
|
29
|
+
OPEN_TIMEOUT = 5 # seconds
|
|
30
|
+
READ_TIMEOUT = 10 # seconds
|
|
31
|
+
|
|
32
|
+
def initialize(project, result)
|
|
33
|
+
@project = project
|
|
34
|
+
@result = result
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def forward
|
|
38
|
+
forwards = @project.effective_webhook_forwards
|
|
39
|
+
return if forwards.empty?
|
|
40
|
+
|
|
41
|
+
forwards.each do |config|
|
|
42
|
+
url = config[:url] || config["url"]
|
|
43
|
+
next if url.blank?
|
|
44
|
+
next unless matches_rules?(config)
|
|
45
|
+
|
|
46
|
+
post_to(url)
|
|
47
|
+
rescue => e
|
|
48
|
+
log_warn("Forward to #{url} failed: #{e.message}")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def matches_rules?(config)
|
|
55
|
+
rules = resolve_rules(config)
|
|
56
|
+
return true if rules.empty?
|
|
57
|
+
|
|
58
|
+
rules.all? { |rule| ForwardRule.new(rule).match?(@result) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Supports both the new `rules` format and the legacy `event_types` shorthand.
|
|
62
|
+
def resolve_rules(config)
|
|
63
|
+
rules = Array(config["rules"] || config[:rules])
|
|
64
|
+
return rules if rules.any?
|
|
65
|
+
|
|
66
|
+
event_types = Array(config["event_types"] || config[:event_types])
|
|
67
|
+
return [] if event_types.empty?
|
|
68
|
+
|
|
69
|
+
[{ "field" => "event_type", "operator" => "in", "value" => event_types }]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def post_to(url)
|
|
73
|
+
uri = URI(url)
|
|
74
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
75
|
+
http.use_ssl = uri.scheme == "https"
|
|
76
|
+
http.open_timeout = OPEN_TIMEOUT
|
|
77
|
+
http.read_timeout = READ_TIMEOUT
|
|
78
|
+
|
|
79
|
+
req = Net::HTTP::Post.new(uri.request_uri)
|
|
80
|
+
req["Content-Type"] = "application/json"
|
|
81
|
+
req.body = build_payload
|
|
82
|
+
|
|
83
|
+
response = http.request(req)
|
|
84
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
85
|
+
log_warn("Forward to #{url} returned HTTP #{response.code}")
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def build_payload
|
|
90
|
+
{
|
|
91
|
+
event_type: @result.event_type,
|
|
92
|
+
message_id: @result.message_id,
|
|
93
|
+
source: @result.source,
|
|
94
|
+
destination: @result.destination,
|
|
95
|
+
subject: @result.subject,
|
|
96
|
+
occurred_at: @result.occurred_at&.iso8601,
|
|
97
|
+
raw: @result.raw_payload
|
|
98
|
+
}.to_json
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def log_warn(msg)
|
|
102
|
+
Rails.logger.warn("[SesDashboard] #{msg}") if defined?(Rails)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -42,7 +42,9 @@ module SesDashboard
|
|
|
42
42
|
when "Notification"
|
|
43
43
|
process_notification(sns)
|
|
44
44
|
else
|
|
45
|
-
|
|
45
|
+
# SNS raw message delivery — the body is the SES event payload directly,
|
|
46
|
+
# with no SNS envelope. Treat it as a notification message directly.
|
|
47
|
+
process_raw_ses_event(sns)
|
|
46
48
|
end
|
|
47
49
|
rescue => e
|
|
48
50
|
Rails.logger.error("[SesDashboard] WebhookProcessor error: #{e.message}") if defined?(Rails)
|
|
@@ -55,6 +57,16 @@ module SesDashboard
|
|
|
55
57
|
message = parse_json(sns["Message"])
|
|
56
58
|
return unknown_result unless message
|
|
57
59
|
|
|
60
|
+
process_ses_message(message, sns["Timestamp"])
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# SNS raw message delivery — body is the SES event directly, no envelope.
|
|
64
|
+
def process_raw_ses_event(message)
|
|
65
|
+
process_ses_message(message, nil)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def process_ses_message(message, sns_timestamp)
|
|
69
|
+
|
|
58
70
|
# SES supports two notification formats:
|
|
59
71
|
# - Event Publishing (newer): uses "eventType" key
|
|
60
72
|
# - Feedback Notifications (legacy): uses "notificationType" key
|
|
@@ -62,7 +74,7 @@ module SesDashboard
|
|
|
62
74
|
event_type = normalize_event_type(raw_event_type)
|
|
63
75
|
|
|
64
76
|
mail = message["mail"] || {}
|
|
65
|
-
timestamp = parse_time(mail["timestamp"] ||
|
|
77
|
+
timestamp = parse_time(mail["timestamp"] || sns_timestamp)
|
|
66
78
|
|
|
67
79
|
Result.new(
|
|
68
80
|
action: :process_event,
|
data/lib/ses_dashboard.rb
CHANGED
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
require_relative "ses_dashboard/version"
|
|
2
|
-
require_relative "ses_dashboard/client"
|
|
3
|
-
require_relative "ses_dashboard/webhook_processor"
|
|
4
|
-
require_relative "ses_dashboard/stats_aggregator"
|
|
5
|
-
require_relative "ses_dashboard/paginatable"
|
|
6
|
-
require_relative "ses_dashboard/auth/base"
|
|
7
|
-
require_relative "ses_dashboard/auth/devise_adapter"
|
|
8
|
-
require_relative "ses_dashboard/auth/cloudflare_adapter"
|
|
9
|
-
|
|
10
1
|
module SesDashboard
|
|
11
2
|
class Error < StandardError; end
|
|
12
3
|
|
|
@@ -46,6 +37,15 @@ module SesDashboard
|
|
|
46
37
|
attr_accessor :cloudflare_team_domain # e.g. "myteam.cloudflareaccess.com"
|
|
47
38
|
attr_accessor :cloudflare_aud # JWT audience (your CF application AUD)
|
|
48
39
|
|
|
40
|
+
# Webhook forwarding — forward specific event types to external URLs (e.g. Zapier)
|
|
41
|
+
# Format: array of hashes with :url and :event_types keys
|
|
42
|
+
# Example:
|
|
43
|
+
# c.webhook_forwards = [
|
|
44
|
+
# { url: "https://hooks.zapier.com/...", event_types: ["bounce", "complaint"] }
|
|
45
|
+
# ]
|
|
46
|
+
# Omit :event_types (or set to []) to forward all event types.
|
|
47
|
+
attr_accessor :webhook_forwards
|
|
48
|
+
|
|
49
49
|
def initialize
|
|
50
50
|
@aws_region = ENV.fetch("AWS_REGION", "us-east-1")
|
|
51
51
|
@aws_access_key_id = nil
|
|
@@ -59,8 +59,20 @@ module SesDashboard
|
|
|
59
59
|
@verify_sns_signature = false
|
|
60
60
|
@cloudflare_team_domain = nil
|
|
61
61
|
@cloudflare_aud = nil
|
|
62
|
+
@webhook_forwards = []
|
|
62
63
|
end
|
|
63
64
|
end
|
|
64
|
-
|
|
65
|
-
autoload :Engine, "ses_dashboard/engine"
|
|
66
65
|
end
|
|
66
|
+
|
|
67
|
+
require_relative "ses_dashboard/version"
|
|
68
|
+
require_relative "ses_dashboard/client"
|
|
69
|
+
require_relative "ses_dashboard/webhook_processor"
|
|
70
|
+
require_relative "ses_dashboard/forward_rule"
|
|
71
|
+
require_relative "ses_dashboard/webhook_forwarder"
|
|
72
|
+
require_relative "ses_dashboard/sns_signature_verifier"
|
|
73
|
+
require_relative "ses_dashboard/stats_aggregator"
|
|
74
|
+
require_relative "ses_dashboard/paginatable"
|
|
75
|
+
require_relative "ses_dashboard/auth/base"
|
|
76
|
+
require_relative "ses_dashboard/auth/devise_adapter"
|
|
77
|
+
require_relative "ses_dashboard/auth/cloudflare_adapter"
|
|
78
|
+
require "ses_dashboard/engine"
|