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.
- checksums.yaml +7 -0
- data/Dockerfile +8 -0
- data/README.md +238 -0
- data/Rakefile +6 -0
- data/app/assets/javascripts/ses_dashboard/application.js +126 -0
- data/app/assets/stylesheets/ses_dashboard/application.css +226 -0
- data/app/controllers/ses_dashboard/application_controller.rb +50 -0
- data/app/controllers/ses_dashboard/dashboard_controller.rb +26 -0
- data/app/controllers/ses_dashboard/emails_controller.rb +93 -0
- data/app/controllers/ses_dashboard/projects_controller.rb +67 -0
- data/app/controllers/ses_dashboard/test_emails_controller.rb +38 -0
- data/app/controllers/ses_dashboard/webhooks_controller.rb +39 -0
- data/app/helpers/ses_dashboard/application_helper.rb +47 -0
- data/app/models/ses_dashboard/application_record.rb +5 -0
- data/app/models/ses_dashboard/email.rb +48 -0
- data/app/models/ses_dashboard/email_event.rb +18 -0
- data/app/models/ses_dashboard/project.rb +20 -0
- data/app/services/ses_dashboard/webhook_event_persistor.rb +69 -0
- data/app/views/layouts/ses_dashboard/application.html.erb +25 -0
- data/app/views/ses_dashboard/dashboard/index.html.erb +56 -0
- data/app/views/ses_dashboard/emails/index.html.erb +72 -0
- data/app/views/ses_dashboard/emails/show.html.erb +60 -0
- data/app/views/ses_dashboard/projects/_form.html.erb +24 -0
- data/app/views/ses_dashboard/projects/edit.html.erb +6 -0
- data/app/views/ses_dashboard/projects/index.html.erb +42 -0
- data/app/views/ses_dashboard/projects/new.html.erb +6 -0
- data/app/views/ses_dashboard/projects/show.html.erb +47 -0
- data/app/views/ses_dashboard/shared/_flash.html.erb +3 -0
- data/app/views/ses_dashboard/shared/_pagination.html.erb +15 -0
- data/app/views/ses_dashboard/shared/_stat_card.html.erb +4 -0
- data/app/views/ses_dashboard/test_emails/new.html.erb +38 -0
- data/config/routes.rb +16 -0
- data/db/migrate/20240101000001_create_ses_dashboard_projects.rb +13 -0
- data/db/migrate/20240101000002_create_ses_dashboard_emails.rb +21 -0
- data/db/migrate/20240101000003_create_ses_dashboard_email_events.rb +15 -0
- data/docker-compose.yml +45 -0
- data/lib/ses_dashboard/auth/base.rb +31 -0
- data/lib/ses_dashboard/auth/cloudflare_adapter.rb +106 -0
- data/lib/ses_dashboard/auth/devise_adapter.rb +22 -0
- data/lib/ses_dashboard/client.rb +95 -0
- data/lib/ses_dashboard/engine.rb +39 -0
- data/lib/ses_dashboard/paginatable.rb +30 -0
- data/lib/ses_dashboard/stats_aggregator.rb +107 -0
- data/lib/ses_dashboard/version.rb +3 -0
- data/lib/ses_dashboard/webhook_processor.rb +116 -0
- data/lib/ses_dashboard.rb +66 -0
- 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
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,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
|