ses-dashboard 0.1.0 → 0.4.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 +106 -31
- data/app/controllers/ses_dashboard/webhooks_controller.rb +21 -1
- 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/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/sns_signature_verifier.rb +92 -0
- data/lib/ses_dashboard/version.rb +1 -1
- data/lib/ses_dashboard/webhook_processor.rb +14 -2
- data/lib/ses_dashboard.rb +11 -11
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 58342ca28ca9d49b31764e988689df488d9a43061fdb145ded04a7d2dbb038b6
|
|
4
|
+
data.tar.gz: da07fcf3d2549fc68bdf24aceb72d289e3a8bf5d93238cd807932b71ef4d7cec
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8757e8e5024c11d93826bdb890e46dcf6fef0bac259d43da0dbec7bd64e16f07765b01967a2d7b5bbfa75de2d11f9bf193b1949f81c937b2e450fba6b2c13092
|
|
7
|
+
data.tar.gz: b222f9246a72fd94869c85e316a69668519536e8fc973e205036ce64ea92c02dd233a6268f889dde3a871759478a1a915e6379e4bed36845c41409acf5c2d208
|
data/README.md
CHANGED
|
@@ -1,7 +1,44 @@
|
|
|
1
1
|
# SES Dashboard
|
|
2
|
+
[](https://codecov.io/gh/antodoms/ses_dashboard)
|
|
3
|
+
[](https://badge.fury.io/rb/ses-dashboard)
|
|
2
4
|
|
|
3
5
|
A mountable Rails engine that provides a real-time dashboard for Amazon SES, tracking email delivery, bounces, complaints, opens, and clicks via SNS webhooks.
|
|
4
6
|
|
|
7
|
+
## Screenshots
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
<table>
|
|
12
|
+
<tr>
|
|
13
|
+
<td><img src="docs/screenshots/project-dashboard-view-page.png" alt="Project Dashboard" /></td>
|
|
14
|
+
<td><img src="docs/screenshots/project-activity-page.png" alt="Activity Log" /></td>
|
|
15
|
+
</tr>
|
|
16
|
+
<tr>
|
|
17
|
+
<td align="center"><em>Per-project stats & email volume chart</em></td>
|
|
18
|
+
<td align="center"><em>Paginated activity log with search & export</em></td>
|
|
19
|
+
</tr>
|
|
20
|
+
<tr>
|
|
21
|
+
<td colspan="2"><img src="docs/screenshots/email-details.png" alt="Email Detail" /></td>
|
|
22
|
+
</tr>
|
|
23
|
+
<tr>
|
|
24
|
+
<td colspan="2" align="center"><em>Email detail with full SNS event timeline</em></td>
|
|
25
|
+
</tr>
|
|
26
|
+
</table>
|
|
27
|
+
|
|
28
|
+
## Features
|
|
29
|
+
|
|
30
|
+
- **Real-time webhook processing** -- receives SNS notifications for delivery, bounce, complaint, open, click, reject, and rendering failure events
|
|
31
|
+
- **Per-project dashboards** -- stat cards, email volume charts (Chart.js), and paginated activity logs
|
|
32
|
+
- **Pluggable authentication** -- ships with Devise, Cloudflare Zero Trust, and no-auth adapters; bring your own with any object that responds to `#authenticate(request)`
|
|
33
|
+
- **CSV/JSON export** -- export filtered email activity from any project
|
|
34
|
+
- **Test email sending** -- send test emails directly from the dashboard via the SES API
|
|
35
|
+
- **Status state machine** -- unidirectional email status transitions (sent -> delivered/bounced/etc.)
|
|
36
|
+
- **SNS signature verification** -- validates RSA signatures (SHA1 and SHA256) on incoming SNS messages
|
|
37
|
+
- **Database agnostic** -- works with SQLite, PostgreSQL, and MySQL
|
|
38
|
+
- **Lightweight pagination** -- no external pagination gem required
|
|
39
|
+
|
|
40
|
+
## Architecture
|
|
41
|
+
|
|
5
42
|
```mermaid
|
|
6
43
|
graph TB
|
|
7
44
|
subgraph Host["Host Rails App"]
|
|
@@ -29,6 +66,7 @@ graph TB
|
|
|
29
66
|
subgraph Core["Core Library (lib/)"]
|
|
30
67
|
Client["Client<br/>AWS SES SDK wrapper"]
|
|
31
68
|
WP["WebhookProcessor<br/>SNS message parser"]
|
|
69
|
+
SV["SnsSignatureVerifier"]
|
|
32
70
|
SA["StatsAggregator<br/>Dashboard statistics"]
|
|
33
71
|
Pag["Paginatable"]
|
|
34
72
|
end
|
|
@@ -54,7 +92,8 @@ graph TB
|
|
|
54
92
|
DC --> SA
|
|
55
93
|
EC --> Pag
|
|
56
94
|
TC --> Client
|
|
57
|
-
WC -->|"POST /webhook/:token"|
|
|
95
|
+
WC -->|"POST /webhook/:token"| SV
|
|
96
|
+
SV --> WP
|
|
58
97
|
WP --> WEP
|
|
59
98
|
WEP --> Models
|
|
60
99
|
Client --> SES
|
|
@@ -68,31 +107,22 @@ graph TB
|
|
|
68
107
|
style Host fill:#e8f5e9,stroke:#4caf50
|
|
69
108
|
```
|
|
70
109
|
|
|
71
|
-
## Features
|
|
72
|
-
|
|
73
|
-
- **Real-time webhook processing** -- receives SNS notifications for delivery, bounce, complaint, open, click, reject, and rendering failure events
|
|
74
|
-
- **Per-project dashboards** -- stat cards, email volume charts (Chart.js), and paginated activity logs
|
|
75
|
-
- **Pluggable authentication** -- ships with Devise, Cloudflare Zero Trust, and no-auth adapters; bring your own with any object that responds to `#authenticate(request)`
|
|
76
|
-
- **CSV/JSON export** -- export filtered email activity from any project
|
|
77
|
-
- **Test email sending** -- send test emails directly from the dashboard via the SES API
|
|
78
|
-
- **Status state machine** -- unidirectional email status transitions (sent -> delivered/bounced/etc.)
|
|
79
|
-
- **Database agnostic** -- works with SQLite, PostgreSQL, and MySQL
|
|
80
|
-
- **Lightweight pagination** -- no external pagination gem required
|
|
81
|
-
|
|
82
110
|
## Installation
|
|
83
111
|
|
|
84
112
|
Add the gem to your Gemfile:
|
|
85
113
|
|
|
86
114
|
```ruby
|
|
87
|
-
gem "
|
|
115
|
+
gem "ses-dashboard"
|
|
88
116
|
```
|
|
89
117
|
|
|
118
|
+
The gem name uses a hyphen (`ses-dashboard`) — Bundler will auto-require the correct entry point automatically.
|
|
119
|
+
|
|
90
120
|
Then run:
|
|
91
121
|
|
|
92
122
|
```bash
|
|
93
123
|
bundle install
|
|
94
|
-
rails
|
|
95
|
-
rails db:migrate
|
|
124
|
+
bin/rails railties:install:migrations
|
|
125
|
+
bin/rails db:migrate
|
|
96
126
|
```
|
|
97
127
|
|
|
98
128
|
## Mounting
|
|
@@ -128,8 +158,8 @@ SesDashboard.configure do |c|
|
|
|
128
158
|
c.cloudflare_aud = "your-application-aud"
|
|
129
159
|
|
|
130
160
|
# Dashboard behaviour
|
|
131
|
-
c.per_page
|
|
132
|
-
c.time_zone
|
|
161
|
+
c.per_page = 25 # rows per page in the activity log
|
|
162
|
+
c.time_zone = "UTC" # timezone for chart date grouping
|
|
133
163
|
c.test_email_from = "noreply@example.com"
|
|
134
164
|
|
|
135
165
|
# Caching & security
|
|
@@ -144,24 +174,55 @@ Every controller action (except the webhook endpoint) runs through the configure
|
|
|
144
174
|
|
|
145
175
|
| Adapter | Value | Notes |
|
|
146
176
|
|---|---|---|
|
|
147
|
-
| None | `:none` | Open access
|
|
177
|
+
| None | `:none` | Open access — suitable for development |
|
|
148
178
|
| Devise | `:devise` | Calls `authenticate_user!` via Warden |
|
|
149
179
|
| Cloudflare Zero Trust | `:cloudflare` | Validates `CF_Authorization` JWT against JWKS |
|
|
150
180
|
| Custom | any object | Must respond to `#authenticate(request)` returning truthy/falsy |
|
|
151
181
|
|
|
182
|
+
### Custom adapter
|
|
183
|
+
|
|
184
|
+
Use a custom adapter when your app has its own authentication system (e.g. custom session-based auth, API keys, JWT):
|
|
185
|
+
|
|
152
186
|
```ruby
|
|
153
|
-
#
|
|
154
|
-
|
|
187
|
+
# config/initializers/ses_dashboard.rb
|
|
188
|
+
|
|
189
|
+
my_auth = Class.new(SesDashboard::Auth::Base) do
|
|
155
190
|
def authenticate(request)
|
|
156
|
-
|
|
191
|
+
session = request.session
|
|
192
|
+
# your auth logic here — return truthy to allow, falsy to deny
|
|
193
|
+
session[:user_id].present?
|
|
157
194
|
end
|
|
158
195
|
end
|
|
159
196
|
|
|
160
197
|
SesDashboard.configure do |c|
|
|
161
|
-
c.authentication_adapter =
|
|
198
|
+
c.authentication_adapter = my_auth.new
|
|
162
199
|
end
|
|
163
200
|
```
|
|
164
201
|
|
|
202
|
+
For apps with session timeout and whitelist checks (e.g. custom Rails session auth):
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
my_auth = Class.new(SesDashboard::Auth::Base) do
|
|
206
|
+
def authenticate(request)
|
|
207
|
+
session = request.session
|
|
208
|
+
user_id = session[:user_id]
|
|
209
|
+
logged_in_at = session[:logged_in_at]
|
|
210
|
+
|
|
211
|
+
return false unless user_id && logged_in_at
|
|
212
|
+
return false unless logged_in_at > 12.hours.ago
|
|
213
|
+
|
|
214
|
+
user = User.find_by(id: user_id)
|
|
215
|
+
user&.active? || false
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
SesDashboard.configure do |c|
|
|
220
|
+
c.authentication_adapter = my_auth.new
|
|
221
|
+
end
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The adapter is defined inline using `Class.new` so it is available at initializer load time without depending on Zeitwerk autoloading.
|
|
225
|
+
|
|
165
226
|
## SNS Webhook Setup
|
|
166
227
|
|
|
167
228
|
Each project gets a unique webhook URL displayed on its dashboard page:
|
|
@@ -178,17 +239,17 @@ To connect it to SES:
|
|
|
178
239
|
|
|
179
240
|
The webhook endpoint authenticates via the project token in the URL and does not require a session.
|
|
180
241
|
|
|
181
|
-
|
|
242
|
+
### SNS Signature Verification
|
|
182
243
|
|
|
183
|
-
|
|
244
|
+
When `verify_sns_signature = true`, the engine validates the RSA signature on every incoming SNS message before processing it. Both `SignatureVersion` `"1"` (SHA1) and `"2"` (SHA256) are supported.
|
|
184
245
|
|
|
185
|
-
|
|
186
|
-
|---|---|
|
|
187
|
-
| `ses_dashboard_projects` | `name`, `token` (unique, auto-generated), `description` |
|
|
188
|
-
| `ses_dashboard_emails` | `project_id`, `message_id` (unique), `source`, `destination` (JSON), `subject`, `status`, `opens`, `clicks`, `sent_at` |
|
|
189
|
-
| `ses_dashboard_email_events` | `email_id`, `event_type`, `event_data` (JSON), `occurred_at` |
|
|
246
|
+
For **raw message delivery** (SNS subscription setting), signature verification is automatically skipped as SNS does not include signature fields in raw payloads — the project token in the URL provides authentication instead.
|
|
190
247
|
|
|
191
|
-
|
|
248
|
+
Enable in production:
|
|
249
|
+
|
|
250
|
+
```ruby
|
|
251
|
+
c.verify_sns_signature = Rails.env.production?
|
|
252
|
+
```
|
|
192
253
|
|
|
193
254
|
## Development
|
|
194
255
|
|
|
@@ -226,13 +287,27 @@ bundle exec rspec spec/models/ses_dashboard/email_spec.rb:15
|
|
|
226
287
|
| Service | Purpose | Ports |
|
|
227
288
|
|---|---|---|
|
|
228
289
|
| `localstack` | Local AWS (SES + SNS) | 4566 |
|
|
229
|
-
| `chrome` | Selenium standalone Chromium | 4444 (WebDriver), 7900 (noVNC
|
|
290
|
+
| `chrome` | Selenium standalone Chromium | 4444 (WebDriver), 7900 (noVNC — watch tests live) |
|
|
230
291
|
| `web` | Runs the test suite | 4001 (Puma) |
|
|
231
292
|
|
|
232
293
|
### Watching System Tests
|
|
233
294
|
|
|
234
295
|
Open http://localhost:7900 in your browser (no password) to watch Chrome execute system specs in real time via noVNC.
|
|
235
296
|
|
|
297
|
+
## Database Schema
|
|
298
|
+
|
|
299
|
+
The engine creates three tables (prefixed `ses_dashboard_`):
|
|
300
|
+
|
|
301
|
+
| Table | Key Columns |
|
|
302
|
+
|---|---|
|
|
303
|
+
| `ses_dashboard_projects` | `name`, `token` (unique, auto-generated), `description` |
|
|
304
|
+
| `ses_dashboard_emails` | `project_id`, `message_id` (unique), `source`, `destination` (JSON), `subject`, `status`, `opens`, `clicks`, `sent_at` |
|
|
305
|
+
| `ses_dashboard_email_events` | `email_id`, `event_type`, `event_data` (JSON), `occurred_at` |
|
|
306
|
+
|
|
307
|
+
Email statuses: `sent`, `delivered`, `bounced`, `complained`, `rejected`, `failed`.
|
|
308
|
+
|
|
309
|
+
Migrations are compatible with Rails 7.x and 8.x — the migration version is resolved automatically from the host app's Rails version at install time.
|
|
310
|
+
|
|
236
311
|
## License
|
|
237
312
|
|
|
238
313
|
MIT
|
|
@@ -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
|
|
|
@@ -23,6 +27,9 @@ module SesDashboard
|
|
|
23
27
|
head :ok
|
|
24
28
|
rescue ActiveRecord::RecordNotFound
|
|
25
29
|
head :not_found
|
|
30
|
+
rescue SnsSignatureVerifier::VerificationError => e
|
|
31
|
+
Rails.logger.warn("[SesDashboard] SNS signature rejected: #{e.message}") if defined?(Rails)
|
|
32
|
+
head :forbidden
|
|
26
33
|
rescue => e
|
|
27
34
|
Rails.logger.error("[SesDashboard] Webhook error: #{e.message}") if defined?(Rails)
|
|
28
35
|
head :unprocessable_entity
|
|
@@ -35,5 +42,18 @@ module SesDashboard
|
|
|
35
42
|
rescue => e
|
|
36
43
|
Rails.logger.warn("[SesDashboard] SNS subscription confirm failed: #{e.message}") if defined?(Rails)
|
|
37
44
|
end
|
|
45
|
+
|
|
46
|
+
def parse_sns_json(body)
|
|
47
|
+
JSON.parse(body)
|
|
48
|
+
rescue JSON::ParserError
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def verify_sns_signature!(sns)
|
|
53
|
+
# Skip verification for raw delivery — no SNS envelope means no signature fields.
|
|
54
|
+
return unless sns&.key?("SigningCertURL")
|
|
55
|
+
|
|
56
|
+
SnsSignatureVerifier.new(sns).verify!
|
|
57
|
+
end
|
|
38
58
|
end
|
|
39
59
|
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 @@
|
|
|
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,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
|
|
@@ -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
|
|
|
@@ -61,6 +52,15 @@ module SesDashboard
|
|
|
61
52
|
@cloudflare_aud = nil
|
|
62
53
|
end
|
|
63
54
|
end
|
|
64
|
-
|
|
65
|
-
autoload :Engine, "ses_dashboard/engine"
|
|
66
55
|
end
|
|
56
|
+
|
|
57
|
+
require_relative "ses_dashboard/version"
|
|
58
|
+
require_relative "ses_dashboard/client"
|
|
59
|
+
require_relative "ses_dashboard/webhook_processor"
|
|
60
|
+
require_relative "ses_dashboard/sns_signature_verifier"
|
|
61
|
+
require_relative "ses_dashboard/stats_aggregator"
|
|
62
|
+
require_relative "ses_dashboard/paginatable"
|
|
63
|
+
require_relative "ses_dashboard/auth/base"
|
|
64
|
+
require_relative "ses_dashboard/auth/devise_adapter"
|
|
65
|
+
require_relative "ses_dashboard/auth/cloudflare_adapter"
|
|
66
|
+
require "ses_dashboard/engine"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ses-dashboard
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- antodoms
|
|
@@ -332,6 +332,7 @@ files:
|
|
|
332
332
|
- db/migrate/20240101000002_create_ses_dashboard_emails.rb
|
|
333
333
|
- db/migrate/20240101000003_create_ses_dashboard_email_events.rb
|
|
334
334
|
- docker-compose.yml
|
|
335
|
+
- lib/ses/dashboard.rb
|
|
335
336
|
- lib/ses_dashboard.rb
|
|
336
337
|
- lib/ses_dashboard/auth/base.rb
|
|
337
338
|
- lib/ses_dashboard/auth/cloudflare_adapter.rb
|
|
@@ -339,6 +340,7 @@ files:
|
|
|
339
340
|
- lib/ses_dashboard/client.rb
|
|
340
341
|
- lib/ses_dashboard/engine.rb
|
|
341
342
|
- lib/ses_dashboard/paginatable.rb
|
|
343
|
+
- lib/ses_dashboard/sns_signature_verifier.rb
|
|
342
344
|
- lib/ses_dashboard/stats_aggregator.rb
|
|
343
345
|
- lib/ses_dashboard/version.rb
|
|
344
346
|
- lib/ses_dashboard/webhook_processor.rb
|