ses-dashboard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/Dockerfile +8 -0
  3. data/README.md +238 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/javascripts/ses_dashboard/application.js +126 -0
  6. data/app/assets/stylesheets/ses_dashboard/application.css +226 -0
  7. data/app/controllers/ses_dashboard/application_controller.rb +50 -0
  8. data/app/controllers/ses_dashboard/dashboard_controller.rb +26 -0
  9. data/app/controllers/ses_dashboard/emails_controller.rb +93 -0
  10. data/app/controllers/ses_dashboard/projects_controller.rb +67 -0
  11. data/app/controllers/ses_dashboard/test_emails_controller.rb +38 -0
  12. data/app/controllers/ses_dashboard/webhooks_controller.rb +39 -0
  13. data/app/helpers/ses_dashboard/application_helper.rb +47 -0
  14. data/app/models/ses_dashboard/application_record.rb +5 -0
  15. data/app/models/ses_dashboard/email.rb +48 -0
  16. data/app/models/ses_dashboard/email_event.rb +18 -0
  17. data/app/models/ses_dashboard/project.rb +20 -0
  18. data/app/services/ses_dashboard/webhook_event_persistor.rb +69 -0
  19. data/app/views/layouts/ses_dashboard/application.html.erb +25 -0
  20. data/app/views/ses_dashboard/dashboard/index.html.erb +56 -0
  21. data/app/views/ses_dashboard/emails/index.html.erb +72 -0
  22. data/app/views/ses_dashboard/emails/show.html.erb +60 -0
  23. data/app/views/ses_dashboard/projects/_form.html.erb +24 -0
  24. data/app/views/ses_dashboard/projects/edit.html.erb +6 -0
  25. data/app/views/ses_dashboard/projects/index.html.erb +42 -0
  26. data/app/views/ses_dashboard/projects/new.html.erb +6 -0
  27. data/app/views/ses_dashboard/projects/show.html.erb +47 -0
  28. data/app/views/ses_dashboard/shared/_flash.html.erb +3 -0
  29. data/app/views/ses_dashboard/shared/_pagination.html.erb +15 -0
  30. data/app/views/ses_dashboard/shared/_stat_card.html.erb +4 -0
  31. data/app/views/ses_dashboard/test_emails/new.html.erb +38 -0
  32. data/config/routes.rb +16 -0
  33. data/db/migrate/20240101000001_create_ses_dashboard_projects.rb +13 -0
  34. data/db/migrate/20240101000002_create_ses_dashboard_emails.rb +21 -0
  35. data/db/migrate/20240101000003_create_ses_dashboard_email_events.rb +15 -0
  36. data/docker-compose.yml +45 -0
  37. data/lib/ses_dashboard/auth/base.rb +31 -0
  38. data/lib/ses_dashboard/auth/cloudflare_adapter.rb +106 -0
  39. data/lib/ses_dashboard/auth/devise_adapter.rb +22 -0
  40. data/lib/ses_dashboard/client.rb +95 -0
  41. data/lib/ses_dashboard/engine.rb +39 -0
  42. data/lib/ses_dashboard/paginatable.rb +30 -0
  43. data/lib/ses_dashboard/stats_aggregator.rb +107 -0
  44. data/lib/ses_dashboard/version.rb +3 -0
  45. data/lib/ses_dashboard/webhook_processor.rb +116 -0
  46. data/lib/ses_dashboard.rb +66 -0
  47. metadata +369 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c7165bdb98b1bf82aa3b6a65e7346101bc9792eb81e7b81fd8c1c9128865697f
4
+ data.tar.gz: 520bc27a98c191b442b307bb7878cc4566883d2567db36373ea62396e867748b
5
+ SHA512:
6
+ metadata.gz: c77bbac1e82db66182c2bd4141d8ab66ea9e9fb267dc0f33b0a082add3af7bf5ed72bdfb7c6bb2dd772d5db4ae19db61b120681d8f6d8001816ed90dbb8f7dfc
7
+ data.tar.gz: 8e3f511a3be5108a96247ff6fb42cfa05aed17003637f9d43c92863b6629a653cfd71ce74f5606952750a0655018fbfb01c8d06eedfd8f89cfdd6dce7f2b12d9
data/Dockerfile ADDED
@@ -0,0 +1,8 @@
1
+ FROM ruby:4
2
+
3
+ WORKDIR /usr/src/app
4
+ COPY . /usr/src/app
5
+
6
+ RUN bundle install
7
+
8
+ CMD ["bundle", "exec", "rspec"]
data/README.md ADDED
@@ -0,0 +1,238 @@
1
+ # SES Dashboard
2
+
3
+ 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
+
5
+ ```mermaid
6
+ graph TB
7
+ subgraph Host["Host Rails App"]
8
+ Routes["routes.rb<br/>mount SesDashboard::Engine"]
9
+ Initializer["initializer<br/>SesDashboard.configure"]
10
+ end
11
+
12
+ subgraph Engine["SesDashboard Engine"]
13
+ direction TB
14
+ subgraph Controllers
15
+ DC["DashboardController"]
16
+ PC["ProjectsController"]
17
+ EC["EmailsController"]
18
+ WC["WebhooksController"]
19
+ TC["TestEmailsController"]
20
+ end
21
+
22
+ subgraph Auth["Authentication Adapters"]
23
+ None[":none"]
24
+ Devise[":devise"]
25
+ CF[":cloudflare"]
26
+ Custom["Custom adapter"]
27
+ end
28
+
29
+ subgraph Core["Core Library (lib/)"]
30
+ Client["Client<br/>AWS SES SDK wrapper"]
31
+ WP["WebhookProcessor<br/>SNS message parser"]
32
+ SA["StatsAggregator<br/>Dashboard statistics"]
33
+ Pag["Paginatable"]
34
+ end
35
+
36
+ subgraph Models
37
+ Project["Project<br/>name, token"]
38
+ Email["Email<br/>status, opens, clicks"]
39
+ Event["EmailEvent<br/>event_type, event_data"]
40
+ end
41
+
42
+ subgraph Services
43
+ WEP["WebhookEventPersistor"]
44
+ end
45
+ end
46
+
47
+ subgraph AWS["Amazon Web Services"]
48
+ SES["SES API<br/>send quota, statistics,<br/>send email"]
49
+ SNS["SNS Topic<br/>email event notifications"]
50
+ end
51
+
52
+ Routes --> Controllers
53
+ Initializer --> Auth
54
+ DC --> SA
55
+ EC --> Pag
56
+ TC --> Client
57
+ WC -->|"POST /webhook/:token"| WP
58
+ WP --> WEP
59
+ WEP --> Models
60
+ Client --> SES
61
+ SNS -->|"HTTP POST"| WC
62
+ SA --> Models
63
+ Project -->|has_many| Email
64
+ Email -->|has_many| Event
65
+
66
+ style Engine fill:#f0f4ff,stroke:#3366cc
67
+ style AWS fill:#fff3e0,stroke:#ff9800
68
+ style Host fill:#e8f5e9,stroke:#4caf50
69
+ ```
70
+
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
+ ## Installation
83
+
84
+ Add the gem to your Gemfile:
85
+
86
+ ```ruby
87
+ gem "ses_dashboard"
88
+ ```
89
+
90
+ Then run:
91
+
92
+ ```bash
93
+ bundle install
94
+ rails ses_dashboard:install:migrations
95
+ rails db:migrate
96
+ ```
97
+
98
+ ## Mounting
99
+
100
+ Mount the engine in your `config/routes.rb`:
101
+
102
+ ```ruby
103
+ Rails.application.routes.draw do
104
+ mount SesDashboard::Engine, at: "/ses-dashboard"
105
+ end
106
+ ```
107
+
108
+ The dashboard is now available at `/ses-dashboard`.
109
+
110
+ ## Configuration
111
+
112
+ Create an initializer at `config/initializers/ses_dashboard.rb`:
113
+
114
+ ```ruby
115
+ SesDashboard.configure do |c|
116
+ # AWS credentials (optional — the SDK credential chain is used by default:
117
+ # SSO, IAM roles, instance profiles, environment variables, etc.)
118
+ c.aws_region = "us-east-1"
119
+ c.aws_access_key_id = ENV["AWS_ACCESS_KEY_ID"]
120
+ c.aws_secret_access_key = ENV["AWS_SECRET_ACCESS_KEY"]
121
+ c.endpoint = nil # set to "http://localhost:4566" for LocalStack
122
+
123
+ # Authentication adapter — :none, :devise, :cloudflare, or a custom object
124
+ c.authentication_adapter = :devise
125
+
126
+ # Cloudflare Zero Trust (only needed when using :cloudflare adapter)
127
+ c.cloudflare_team_domain = "myteam.cloudflareaccess.com"
128
+ c.cloudflare_aud = "your-application-aud"
129
+
130
+ # Dashboard behaviour
131
+ c.per_page = 25 # rows per page in the activity log
132
+ c.time_zone = "UTC" # timezone for chart date grouping
133
+ c.test_email_from = "noreply@example.com"
134
+
135
+ # Caching & security
136
+ c.cache_enabled = true # cache SES API responses in memory
137
+ c.verify_sns_signature = true # validate SNS signatures (enable in production)
138
+ end
139
+ ```
140
+
141
+ ## Authentication
142
+
143
+ Every controller action (except the webhook endpoint) runs through the configured authentication adapter.
144
+
145
+ | Adapter | Value | Notes |
146
+ |---|---|---|
147
+ | None | `:none` | Open access -- suitable for development |
148
+ | Devise | `:devise` | Calls `authenticate_user!` via Warden |
149
+ | Cloudflare Zero Trust | `:cloudflare` | Validates `CF_Authorization` JWT against JWKS |
150
+ | Custom | any object | Must respond to `#authenticate(request)` returning truthy/falsy |
151
+
152
+ ```ruby
153
+ # Example custom adapter
154
+ class ApiKeyAuth
155
+ def authenticate(request)
156
+ request.headers["X-Api-Key"] == Rails.application.credentials.dashboard_key
157
+ end
158
+ end
159
+
160
+ SesDashboard.configure do |c|
161
+ c.authentication_adapter = ApiKeyAuth.new
162
+ end
163
+ ```
164
+
165
+ ## SNS Webhook Setup
166
+
167
+ Each project gets a unique webhook URL displayed on its dashboard page:
168
+
169
+ ```
170
+ https://yourapp.com/ses-dashboard/webhook/<project-token>
171
+ ```
172
+
173
+ To connect it to SES:
174
+
175
+ 1. In the **AWS SNS console**, create a topic (or use an existing one).
176
+ 2. Add a **subscription** with protocol **HTTPS** and the webhook URL above. The engine auto-confirms the subscription.
177
+ 3. In the **SES console**, configure a **Configuration Set** with an SNS destination pointing to that topic. Select the event types you want to track (Send, Delivery, Bounce, Complaint, Open, Click, Reject, Rendering Failure).
178
+
179
+ The webhook endpoint authenticates via the project token in the URL and does not require a session.
180
+
181
+ ## Database Schema
182
+
183
+ The engine creates three tables (prefixed `ses_dashboard_`):
184
+
185
+ | Table | Key Columns |
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` |
190
+
191
+ Email statuses: `sent`, `delivered`, `bounced`, `complained`, `rejected`, `failed`.
192
+
193
+ ## Development
194
+
195
+ ### Prerequisites
196
+
197
+ - Docker & Docker Compose (for system specs and local AWS)
198
+ - Ruby >= 3.0
199
+
200
+ ### Setup
201
+
202
+ ```bash
203
+ git clone <repo-url>
204
+ cd ses-dashboard
205
+ docker compose run --rm web bundle install
206
+ ```
207
+
208
+ ### Running Tests
209
+
210
+ ```bash
211
+ # All tests (unit + controller + system) inside Docker
212
+ docker compose run --rm web bundle exec rspec
213
+
214
+ # Unit and controller tests only (no Docker needed)
215
+ bundle exec rspec spec/models spec/controllers spec/ses_dashboard
216
+
217
+ # Single test file
218
+ bundle exec rspec spec/models/ses_dashboard/email_spec.rb
219
+
220
+ # Single example by line number
221
+ bundle exec rspec spec/models/ses_dashboard/email_spec.rb:15
222
+ ```
223
+
224
+ ### Docker Compose Services
225
+
226
+ | Service | Purpose | Ports |
227
+ |---|---|---|
228
+ | `localstack` | Local AWS (SES + SNS) | 4566 |
229
+ | `chrome` | Selenium standalone Chromium | 4444 (WebDriver), 7900 (noVNC -- watch tests live) |
230
+ | `web` | Runs the test suite | 4001 (Puma) |
231
+
232
+ ### Watching System Tests
233
+
234
+ Open http://localhost:7900 in your browser (no password) to watch Chrome execute system specs in real time via noVNC.
235
+
236
+ ## License
237
+
238
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task default: :spec
@@ -0,0 +1,126 @@
1
+ /* SES Dashboard — vanilla JS, no framework required */
2
+
3
+ (function () {
4
+ "use strict";
5
+
6
+ // ── Chart initialisation ─────────────────────────────────────────────
7
+ function initChart() {
8
+ var el = document.getElementById("chart-data");
9
+ var canvas = document.getElementById("activity-chart");
10
+ if (!el || !canvas || typeof Chart === "undefined") return;
11
+
12
+ var data;
13
+ try { data = JSON.parse(el.textContent); } catch (e) { return; }
14
+
15
+ new Chart(canvas.getContext("2d"), {
16
+ type: "line",
17
+ data: {
18
+ labels: data.labels,
19
+ datasets: [{
20
+ label: "Emails sent",
21
+ data: data.data,
22
+ borderColor: "#0d6efd",
23
+ backgroundColor: "rgba(13,110,253,.08)",
24
+ borderWidth: 2,
25
+ pointRadius: 3,
26
+ fill: true,
27
+ tension: 0.3
28
+ }]
29
+ },
30
+ options: {
31
+ responsive: true,
32
+ maintainAspectRatio: true,
33
+ plugins: {
34
+ legend: { display: false },
35
+ tooltip: { mode: "index", intersect: false }
36
+ },
37
+ scales: {
38
+ x: { grid: { display: false } },
39
+ y: { beginAtZero: true, ticks: { precision: 0 } }
40
+ }
41
+ }
42
+ });
43
+ }
44
+
45
+ // ── Copy to clipboard ────────────────────────────────────────────────
46
+ function copyToClipboard(text) {
47
+ if (navigator.clipboard) {
48
+ navigator.clipboard.writeText(text).catch(function () {
49
+ fallbackCopy(text);
50
+ });
51
+ } else {
52
+ fallbackCopy(text);
53
+ }
54
+ }
55
+
56
+ function fallbackCopy(text) {
57
+ var ta = document.createElement("textarea");
58
+ ta.value = text;
59
+ ta.style.position = "fixed";
60
+ ta.style.opacity = "0";
61
+ document.body.appendChild(ta);
62
+ ta.focus();
63
+ ta.select();
64
+ try { document.execCommand("copy"); } catch (e) { /* ignore */ }
65
+ document.body.removeChild(ta);
66
+ }
67
+
68
+ // ── Copy buttons ─────────────────────────────────────────────────────
69
+ function initCopyButtons() {
70
+ document.querySelectorAll("[data-copy]").forEach(function (btn) {
71
+ btn.addEventListener("click", function () {
72
+ var text = btn.getAttribute("data-copy");
73
+ copyToClipboard(text);
74
+ var original = btn.textContent;
75
+ btn.textContent = "Copied!";
76
+ setTimeout(function () { btn.textContent = original; }, 1500);
77
+ });
78
+ });
79
+ }
80
+
81
+ // ── Event raw data toggles ───────────────────────────────────────────
82
+ function initEventToggles() {
83
+ document.querySelectorAll(".event-data-toggle").forEach(function (btn) {
84
+ btn.addEventListener("click", function () {
85
+ var raw = btn.closest(".event-item").querySelector(".event-raw");
86
+ if (!raw) return;
87
+ raw.classList.toggle("visible");
88
+ btn.textContent = raw.classList.contains("visible") ? "Hide raw" : "Show raw";
89
+ });
90
+ });
91
+ }
92
+
93
+ // ── Filter form reset ─────────────────────────────────────────────────
94
+ function initFilterReset() {
95
+ var resetBtn = document.getElementById("filter-reset");
96
+ if (!resetBtn) return;
97
+ resetBtn.addEventListener("click", function () {
98
+ var form = resetBtn.closest("form");
99
+ if (!form) return;
100
+ form.querySelectorAll("input[type=text], input[type=date], select").forEach(function (el) {
101
+ el.value = "";
102
+ });
103
+ form.submit();
104
+ });
105
+ }
106
+
107
+ // ── Confirm destructive actions ───────────────────────────────────────
108
+ function initConfirmLinks() {
109
+ document.querySelectorAll("[data-confirm]").forEach(function (el) {
110
+ el.addEventListener("click", function (e) {
111
+ if (!window.confirm(el.getAttribute("data-confirm"))) {
112
+ e.preventDefault();
113
+ }
114
+ });
115
+ });
116
+ }
117
+
118
+ // ── Boot ─────────────────────────────────────────────────────────────
119
+ document.addEventListener("DOMContentLoaded", function () {
120
+ initChart();
121
+ initCopyButtons();
122
+ initEventToggles();
123
+ initFilterReset();
124
+ initConfirmLinks();
125
+ });
126
+ })();
@@ -0,0 +1,226 @@
1
+ /* SES Dashboard — standalone stylesheet (no Bootstrap dependency) */
2
+
3
+ :root {
4
+ --color-bg: #f8f9fa;
5
+ --color-surface: #ffffff;
6
+ --color-border: #dee2e6;
7
+ --color-text: #212529;
8
+ --color-text-muted: #6c757d;
9
+ --color-primary: #0d6efd;
10
+ --color-primary-dk: #0a58ca;
11
+ --color-sent: #6c757d;
12
+ --color-delivered: #198754;
13
+ --color-bounced: #dc3545;
14
+ --color-complained: #fd7e14;
15
+ --color-rejected: #dc3545;
16
+ --color-failed: #6f42c1;
17
+ --nav-height: 56px;
18
+ }
19
+
20
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
21
+
22
+ body {
23
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
24
+ font-size: 14px;
25
+ color: var(--color-text);
26
+ background: var(--color-bg);
27
+ }
28
+
29
+ a { color: var(--color-primary); text-decoration: none; }
30
+ a:hover { text-decoration: underline; }
31
+
32
+ /* ── Navigation ────────────────────────────────────────── */
33
+ .ses-nav {
34
+ position: sticky;
35
+ top: 0;
36
+ z-index: 100;
37
+ display: flex;
38
+ align-items: center;
39
+ gap: 1.5rem;
40
+ height: var(--nav-height);
41
+ padding: 0 1.5rem;
42
+ background: var(--color-text);
43
+ color: #fff;
44
+ }
45
+
46
+ .ses-nav-brand {
47
+ font-weight: 700;
48
+ font-size: 1rem;
49
+ color: #fff;
50
+ letter-spacing: .025em;
51
+ }
52
+
53
+ .ses-nav a {
54
+ color: rgba(255,255,255,.8);
55
+ font-size: .875rem;
56
+ }
57
+ .ses-nav a:hover { color: #fff; text-decoration: none; }
58
+ .ses-nav a.active { color: #fff; font-weight: 600; }
59
+
60
+ .ses-nav-spacer { flex: 1; }
61
+
62
+ /* ── Layout ─────────────────────────────────────────────── */
63
+ .ses-container {
64
+ max-width: 1200px;
65
+ margin: 0 auto;
66
+ padding: 1.5rem;
67
+ }
68
+
69
+ /* ── Flash messages ─────────────────────────────────────── */
70
+ .flash { padding: .75rem 1rem; border-radius: .375rem; margin-bottom: 1rem; font-size: .875rem; }
71
+ .flash-notice { background: #d1e7dd; color: #0a3622; border: 1px solid #a3cfbb; }
72
+ .flash-alert { background: #f8d7da; color: #58151c; border: 1px solid #f1aeb5; }
73
+
74
+ /* ── Cards ──────────────────────────────────────────────── */
75
+ .card {
76
+ background: var(--color-surface);
77
+ border: 1px solid var(--color-border);
78
+ border-radius: .5rem;
79
+ padding: 1.25rem;
80
+ }
81
+
82
+ .card-title {
83
+ font-size: .75rem;
84
+ font-weight: 600;
85
+ text-transform: uppercase;
86
+ letter-spacing: .05em;
87
+ color: var(--color-text-muted);
88
+ margin-bottom: .5rem;
89
+ }
90
+
91
+ .card-value {
92
+ font-size: 2rem;
93
+ font-weight: 700;
94
+ line-height: 1;
95
+ }
96
+
97
+ /* ── Stat grid ──────────────────────────────────────────── */
98
+ .stat-grid {
99
+ display: grid;
100
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
101
+ gap: 1rem;
102
+ margin-bottom: 1.5rem;
103
+ }
104
+
105
+ /* ── Chart ──────────────────────────────────────────────── */
106
+ .chart-card { margin-bottom: 1.5rem; }
107
+ .chart-card canvas { max-height: 280px; }
108
+
109
+ /* ── Page header ────────────────────────────────────────── */
110
+ .page-header {
111
+ display: flex;
112
+ align-items: center;
113
+ justify-content: space-between;
114
+ margin-bottom: 1.25rem;
115
+ }
116
+ .page-title { font-size: 1.25rem; font-weight: 600; }
117
+
118
+ /* ── Buttons ────────────────────────────────────────────── */
119
+ .btn {
120
+ display: inline-flex;
121
+ align-items: center;
122
+ gap: .35rem;
123
+ padding: .4rem .85rem;
124
+ font-size: .875rem;
125
+ font-weight: 500;
126
+ border-radius: .375rem;
127
+ border: 1px solid transparent;
128
+ cursor: pointer;
129
+ text-decoration: none;
130
+ line-height: 1.5;
131
+ }
132
+ .btn-primary { background: var(--color-primary); color: #fff; border-color: var(--color-primary); }
133
+ .btn-primary:hover { background: var(--color-primary-dk); border-color: var(--color-primary-dk); text-decoration: none; color: #fff; }
134
+ .btn-outline { background: transparent; color: var(--color-primary); border-color: var(--color-primary); }
135
+ .btn-outline:hover { background: var(--color-primary); color: #fff; text-decoration: none; }
136
+ .btn-danger { background: var(--color-bounced); color: #fff; border-color: var(--color-bounced); }
137
+ .btn-sm { padding: .25rem .6rem; font-size: .8125rem; }
138
+
139
+ /* ── Forms ──────────────────────────────────────────────── */
140
+ .form-group { margin-bottom: 1rem; }
141
+ .form-label { display: block; font-weight: 500; margin-bottom: .35rem; font-size: .875rem; }
142
+ .form-control {
143
+ display: block;
144
+ width: 100%;
145
+ padding: .4rem .65rem;
146
+ font-size: .875rem;
147
+ border: 1px solid var(--color-border);
148
+ border-radius: .375rem;
149
+ background: var(--color-surface);
150
+ color: var(--color-text);
151
+ }
152
+ .form-control:focus { outline: 2px solid var(--color-primary); border-color: transparent; }
153
+ textarea.form-control { min-height: 100px; resize: vertical; }
154
+
155
+ /* ── Filter bar ──────────────────────────────────────────── */
156
+ .filter-bar {
157
+ display: flex;
158
+ flex-wrap: wrap;
159
+ gap: .5rem;
160
+ align-items: flex-end;
161
+ margin-bottom: 1rem;
162
+ background: var(--color-surface);
163
+ border: 1px solid var(--color-border);
164
+ border-radius: .5rem;
165
+ padding: .75rem 1rem;
166
+ }
167
+ .filter-bar .form-control { width: auto; }
168
+ .filter-bar .form-group { margin-bottom: 0; }
169
+
170
+ /* ── Tables ──────────────────────────────────────────────── */
171
+ .table-wrapper { overflow-x: auto; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: .5rem; }
172
+
173
+ table { width: 100%; border-collapse: collapse; font-size: .875rem; }
174
+ th, td { padding: .6rem .85rem; text-align: left; }
175
+ th { font-weight: 600; color: var(--color-text-muted); border-bottom: 1px solid var(--color-border); font-size: .75rem; text-transform: uppercase; letter-spacing: .04em; }
176
+ td { border-bottom: 1px solid var(--color-border); }
177
+ tr:last-child td { border-bottom: none; }
178
+ tr:hover td { background: var(--color-bg); }
179
+
180
+ /* ── Badges ──────────────────────────────────────────────── */
181
+ .badge {
182
+ display: inline-block;
183
+ padding: .2em .55em;
184
+ font-size: .75rem;
185
+ font-weight: 600;
186
+ border-radius: 9999px;
187
+ text-transform: capitalize;
188
+ }
189
+ .badge-sent { background: #e9ecef; color: var(--color-sent); }
190
+ .badge-delivered { background: #d1e7dd; color: #0a3622; }
191
+ .badge-bounced { background: #f8d7da; color: #58151c; }
192
+ .badge-complained { background: #ffe5d0; color: #6a2d00; }
193
+ .badge-rejected { background: #f8d7da; color: #58151c; }
194
+ .badge-failed { background: #e8d5fb; color: #3b0764; }
195
+ .badge-unknown { background: #e9ecef; color: var(--color-text-muted); }
196
+
197
+ /* ── Pagination ──────────────────────────────────────────── */
198
+ .pagination { display: flex; align-items: center; gap: .5rem; padding: .75rem 1rem; font-size: .875rem; }
199
+ .pagination-info { color: var(--color-text-muted); }
200
+ .pagination-link { padding: .3rem .65rem; border: 1px solid var(--color-border); border-radius: .375rem; }
201
+ .pagination-link:hover { background: var(--color-bg); text-decoration: none; }
202
+ .pagination-disabled { padding: .3rem .65rem; border: 1px solid var(--color-border); border-radius: .375rem; color: var(--color-text-muted); cursor: default; }
203
+
204
+ /* ── Webhook URL display ─────────────────────────────────── */
205
+ .webhook-url-wrap { display: flex; align-items: center; gap: .5rem; }
206
+ .webhook-url-input { flex: 1; font-family: monospace; font-size: .8125rem; background: var(--color-bg); }
207
+
208
+ /* ── Event timeline ──────────────────────────────────────── */
209
+ .event-timeline { list-style: none; }
210
+ .event-item { display: flex; gap: 1rem; padding: .75rem 0; border-bottom: 1px solid var(--color-border); }
211
+ .event-item:last-child { border-bottom: none; }
212
+ .event-time { color: var(--color-text-muted); font-size: .8125rem; white-space: nowrap; min-width: 160px; }
213
+ .event-data-toggle { font-size: .8125rem; cursor: pointer; color: var(--color-primary); border: none; background: none; padding: 0; }
214
+ .event-raw { margin-top: .5rem; background: var(--color-bg); border: 1px solid var(--color-border); border-radius: .375rem; padding: .75rem; font-family: monospace; font-size: .75rem; white-space: pre-wrap; display: none; }
215
+ .event-raw.visible { display: block; }
216
+
217
+ /* ── Token display ───────────────────────────────────────── */
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
+
220
+ /* ── Responsive ──────────────────────────────────────────── */
221
+ @media (max-width: 640px) {
222
+ .stat-grid { grid-template-columns: 1fr 1fr; }
223
+ .page-header { flex-direction: column; align-items: flex-start; gap: .5rem; }
224
+ .filter-bar { flex-direction: column; }
225
+ .filter-bar .form-control { width: 100%; }
226
+ }
@@ -0,0 +1,50 @@
1
+ module SesDashboard
2
+ class ApplicationController < ActionController::Base
3
+ protect_from_forgery with: :exception
4
+
5
+ before_action :authenticate!
6
+
7
+ helper SesDashboard::ApplicationHelper
8
+
9
+ rescue_from ActiveRecord::RecordNotFound do
10
+ respond_to do |format|
11
+ format.html { render plain: "Not Found", status: :not_found }
12
+ format.json { render json: { error: "Not Found" }, status: :not_found }
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def authenticate!
19
+ adapter = resolved_adapter
20
+ return if adapter.nil? # :none — open access
21
+
22
+ unless adapter.authenticate(request)
23
+ respond_to do |format|
24
+ format.html do
25
+ flash[:alert] = "You are not authorized to access this page."
26
+ redirect_to main_app.root_path
27
+ end
28
+ format.json { render json: { error: "Unauthorized" }, status: :unauthorized }
29
+ end
30
+ end
31
+ end
32
+
33
+ def resolved_adapter
34
+ case SesDashboard.configuration.authentication_adapter
35
+ when :none then nil
36
+ when :devise then Auth::DeviseAdapter.new(nil, controller: self)
37
+ when :cloudflare then Auth::CloudflareAdapter.new
38
+ else
39
+ # Allow a custom adapter object/class to be set directly
40
+ adapter = SesDashboard.configuration.authentication_adapter
41
+ adapter.respond_to?(:authenticate) ? adapter : nil
42
+ end
43
+ end
44
+
45
+ def current_project
46
+ @current_project ||= Project.find(params[:project_id]) if params[:project_id]
47
+ end
48
+ helper_method :current_project
49
+ end
50
+ end
@@ -0,0 +1,26 @@
1
+ module SesDashboard
2
+ class DashboardController < ApplicationController
3
+ def index
4
+ from = parse_date(params[:from]) || 30.days.ago.beginning_of_day
5
+ to = parse_date(params[:to]) || Time.current.end_of_day
6
+
7
+ agg = StatsAggregator.new(from: from, to: to)
8
+
9
+ @counters = agg.counters
10
+ @total_opens = agg.total_opens
11
+ @total_clicks = agg.total_clicks
12
+ @chart_data = agg.time_series
13
+ @projects = Project.ordered
14
+ @from = from
15
+ @to = to
16
+ end
17
+
18
+ private
19
+
20
+ def parse_date(str)
21
+ Time.zone.parse(str) if str.present?
22
+ rescue ArgumentError
23
+ nil
24
+ end
25
+ end
26
+ end