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.
@@ -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
  }
@@ -55,7 +55,7 @@ module SesDashboard
55
55
  end
56
56
 
57
57
  def project_params
58
- params.require(:project).permit(:name, :description)
58
+ params.require(:project).permit(:name, :description, :webhook_forwards_text)
59
59
  end
60
60
 
61
61
  def parse_date(str)
@@ -9,7 +9,11 @@ module SesDashboard
9
9
 
10
10
  def create
11
11
  project = Project.find_by!(token: params[:project_token])
12
- body = request.body.read
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
- class CreateSesDashboardProjects < ActiveRecord::Migration[8.0]
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
- class CreateSesDashboardEmails < ActiveRecord::Migration[8.0]
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
- class CreateSesDashboardEmailEvents < ActiveRecord::Migration[8.0]
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"
@@ -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: from,
46
- destinations: [to],
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
  }
@@ -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
@@ -1,3 +1,3 @@
1
1
  module SesDashboard
2
- VERSION = "0.1.0"
2
+ VERSION = "0.5.0"
3
3
  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
- unknown_result
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"] || sns["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"