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
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<%= form_with model: project, url: (project.persisted? ? project_path(project) : projects_path), local: true do |f| %>
|
|
2
|
+
<% if project.errors.any? %>
|
|
3
|
+
<div class="flash flash-alert">
|
|
4
|
+
<ul style="margin:0;padding-left:1rem;">
|
|
5
|
+
<% project.errors.full_messages.each do |msg| %>
|
|
6
|
+
<li><%= msg %></li>
|
|
7
|
+
<% end %>
|
|
8
|
+
</ul>
|
|
9
|
+
</div>
|
|
10
|
+
<% end %>
|
|
11
|
+
|
|
12
|
+
<div class="form-group">
|
|
13
|
+
<%= f.label :name, class: "form-label" %>
|
|
14
|
+
<%= f.text_field :name, class: "form-control", placeholder: "My Rails App" %>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<div class="form-group">
|
|
18
|
+
<%= f.label :description, class: "form-label" %>
|
|
19
|
+
<%= f.text_area :description, class: "form-control", placeholder: "Optional description" %>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<%= f.submit class: "btn btn-primary" %>
|
|
23
|
+
<%= link_to "Cancel", projects_path, class: "btn btn-outline" %>
|
|
24
|
+
<% end %>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<div class="page-header">
|
|
2
|
+
<h1 class="page-title">Projects</h1>
|
|
3
|
+
<%= link_to "New project", new_project_path, class: "btn btn-primary btn-sm" %>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<div class="table-wrapper">
|
|
7
|
+
<table>
|
|
8
|
+
<thead>
|
|
9
|
+
<tr>
|
|
10
|
+
<th>Name</th>
|
|
11
|
+
<th>Token</th>
|
|
12
|
+
<th>Emails</th>
|
|
13
|
+
<th>Actions</th>
|
|
14
|
+
</tr>
|
|
15
|
+
</thead>
|
|
16
|
+
<tbody>
|
|
17
|
+
<% @projects.each do |project| %>
|
|
18
|
+
<tr>
|
|
19
|
+
<td><%= link_to project.name, project_path(project) %></td>
|
|
20
|
+
<td>
|
|
21
|
+
<span class="token-display"><%= project.token[0..7] %>…</span>
|
|
22
|
+
<button class="btn btn-sm btn-outline" data-copy="<%= project.token %>">Copy token</button>
|
|
23
|
+
</td>
|
|
24
|
+
<td><%= number_with_delimiter(project.emails.count) %></td>
|
|
25
|
+
<td style="display:flex;gap:.4rem;">
|
|
26
|
+
<%= link_to "View", project_path(project), class: "btn btn-sm btn-outline" %>
|
|
27
|
+
<%= link_to "Edit", edit_project_path(project), class: "btn btn-sm btn-outline" %>
|
|
28
|
+
<%= link_to "Delete", project_path(project),
|
|
29
|
+
method: :delete,
|
|
30
|
+
data: { confirm: "Delete project '#{project.name}' and all its emails?" },
|
|
31
|
+
class: "btn btn-sm btn-danger" %>
|
|
32
|
+
</td>
|
|
33
|
+
</tr>
|
|
34
|
+
<% end %>
|
|
35
|
+
<% if @projects.empty? %>
|
|
36
|
+
<tr><td colspan="4" style="color:var(--color-text-muted);text-align:center;padding:2rem;">
|
|
37
|
+
No projects yet. <%= link_to "Create one", new_project_path %>.
|
|
38
|
+
</td></tr>
|
|
39
|
+
<% end %>
|
|
40
|
+
</tbody>
|
|
41
|
+
</table>
|
|
42
|
+
</div>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<div class="page-header">
|
|
2
|
+
<h1 class="page-title"><%= @project.name %></h1>
|
|
3
|
+
<div style="display:flex;gap:.5rem;">
|
|
4
|
+
<%= link_to "Edit", edit_project_path(@project), class: "btn btn-outline btn-sm" %>
|
|
5
|
+
<%= link_to "Activity log", project_emails_path(@project), class: "btn btn-outline btn-sm" %>
|
|
6
|
+
<%= link_to "Send test email", new_project_test_email_path(@project), class: "btn btn-primary btn-sm" %>
|
|
7
|
+
</div>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<% if @project.description.present? %>
|
|
11
|
+
<p style="color:var(--color-text-muted);margin-bottom:1rem;"><%= @project.description %></p>
|
|
12
|
+
<% end %>
|
|
13
|
+
|
|
14
|
+
<%# SNS Webhook URL %>
|
|
15
|
+
<div class="card" style="margin-bottom:1.5rem;">
|
|
16
|
+
<div class="card-title">SNS Webhook URL</div>
|
|
17
|
+
<p style="font-size:.8125rem;color:var(--color-text-muted);margin:.25rem 0 .75rem;">
|
|
18
|
+
Configure this URL as an SNS topic subscription endpoint (HTTPS) in your AWS console.
|
|
19
|
+
</p>
|
|
20
|
+
<div class="webhook-url-wrap">
|
|
21
|
+
<% url = webhook_url_for(@project) %>
|
|
22
|
+
<input type="text" class="form-control webhook-url-input" value="<%= url %>" readonly>
|
|
23
|
+
<button class="btn btn-outline btn-sm" data-copy="<%= url %>">Copy</button>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<%# Date range picker %>
|
|
28
|
+
<form method="get" style="display:flex;gap:.5rem;align-items:center;margin-bottom:1rem;">
|
|
29
|
+
<input type="date" name="from" class="form-control" value="<%= @from.to_date %>" style="width:auto;">
|
|
30
|
+
<span>to</span>
|
|
31
|
+
<input type="date" name="to" class="form-control" value="<%= @to.to_date %>" style="width:auto;">
|
|
32
|
+
<button type="submit" class="btn btn-outline btn-sm">Apply</button>
|
|
33
|
+
</form>
|
|
34
|
+
|
|
35
|
+
<div class="stat-grid">
|
|
36
|
+
<%= render "ses_dashboard/shared/stat_card", label: "Sent", value: @counters[:total], color: "var(--color-text)" %>
|
|
37
|
+
<%= render "ses_dashboard/shared/stat_card", label: "Delivered", value: @counters[:delivered], color: "var(--color-delivered)" %>
|
|
38
|
+
<%= render "ses_dashboard/shared/stat_card", label: "Opens", value: @total_opens, color: "var(--color-primary)" %>
|
|
39
|
+
<%= render "ses_dashboard/shared/stat_card", label: "Clicks", value: @total_clicks, color: "var(--color-primary)" %>
|
|
40
|
+
<%= render "ses_dashboard/shared/stat_card", label: "Not Delivered", value: @counters[:not_delivered], color: "var(--color-bounced)" %>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<div class="card chart-card">
|
|
44
|
+
<div class="card-title">Email volume (by day)</div>
|
|
45
|
+
<canvas id="activity-chart"></canvas>
|
|
46
|
+
</div>
|
|
47
|
+
<%= chart_data_tag(@chart_data) %>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<% if pagination[:total_pages] > 1 %>
|
|
2
|
+
<div class="pagination">
|
|
3
|
+
<%= pagination_link("« Prev".html_safe, pagination[:prev_page]) %>
|
|
4
|
+
<span class="pagination-info">
|
|
5
|
+
Page <%= pagination[:page] %> of <%= pagination[:total_pages] %>
|
|
6
|
+
·
|
|
7
|
+
<%= number_with_delimiter(pagination[:total_count]) %> total
|
|
8
|
+
</span>
|
|
9
|
+
<%= pagination_link("Next »".html_safe, pagination[:next_page]) %>
|
|
10
|
+
</div>
|
|
11
|
+
<% else %>
|
|
12
|
+
<div class="pagination">
|
|
13
|
+
<span class="pagination-info"><%= number_with_delimiter(pagination[:total_count]) %> records</span>
|
|
14
|
+
</div>
|
|
15
|
+
<% end %>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<div class="page-header">
|
|
2
|
+
<h1 class="page-title">Send test email — <%= @project.name %></h1>
|
|
3
|
+
<%= link_to "← Back".html_safe, project_path(@project), class: "btn btn-outline btn-sm" %>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<div class="card" style="max-width:560px;">
|
|
7
|
+
<% if flash[:alert] %>
|
|
8
|
+
<div class="flash flash-alert"><%= flash[:alert] %></div>
|
|
9
|
+
<% end %>
|
|
10
|
+
|
|
11
|
+
<%= form_with url: project_test_email_path(@project), method: :post, local: true do |f| %>
|
|
12
|
+
<div class="form-group">
|
|
13
|
+
<label class="form-label" for="from">From</label>
|
|
14
|
+
<input type="email" name="from" id="from" class="form-control"
|
|
15
|
+
value="<%= @from %>"
|
|
16
|
+
placeholder="sender@yourdomain.com" required>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div class="form-group">
|
|
20
|
+
<label class="form-label" for="to">To</label>
|
|
21
|
+
<input type="email" name="to" id="to" class="form-control"
|
|
22
|
+
placeholder="recipient@example.com" required>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<div class="form-group">
|
|
26
|
+
<label class="form-label" for="subject">Subject</label>
|
|
27
|
+
<input type="text" name="subject" id="subject" class="form-control"
|
|
28
|
+
value="Test email from SES Dashboard">
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<div class="form-group">
|
|
32
|
+
<label class="form-label" for="body">Body</label>
|
|
33
|
+
<textarea name="body" id="body" class="form-control">This is a test email sent from SES Dashboard.</textarea>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<button type="submit" class="btn btn-primary">Send email</button>
|
|
37
|
+
<% end %>
|
|
38
|
+
</div>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
SesDashboard::Engine.routes.draw do
|
|
2
|
+
root to: "dashboard#index"
|
|
3
|
+
|
|
4
|
+
resources :projects do
|
|
5
|
+
resources :emails, only: [:index, :show] do
|
|
6
|
+
collection do
|
|
7
|
+
get :export
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
resource :test_email, only: [:new, :create]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# SNS posts to this endpoint; authenticated by the project token in the URL,
|
|
14
|
+
# not by the session-based auth applied to all other routes.
|
|
15
|
+
post "webhook/:project_token", to: "webhooks#create", as: :webhook
|
|
16
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
class CreateSesDashboardProjects < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table :ses_dashboard_projects do |t|
|
|
4
|
+
t.string :name, null: false
|
|
5
|
+
t.string :token, null: false
|
|
6
|
+
t.text :description
|
|
7
|
+
|
|
8
|
+
t.timestamps
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
add_index :ses_dashboard_projects, :token, unique: true
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class CreateSesDashboardEmails < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table :ses_dashboard_emails do |t|
|
|
4
|
+
t.references :project, null: false, foreign_key: { to_table: :ses_dashboard_projects }
|
|
5
|
+
t.string :message_id, null: false
|
|
6
|
+
t.text :destination, null: false # JSON-serialized array of recipient addresses
|
|
7
|
+
t.string :source, null: false # From: address
|
|
8
|
+
t.string :subject
|
|
9
|
+
t.string :status, null: false, default: "sent"
|
|
10
|
+
t.integer :opens, null: false, default: 0
|
|
11
|
+
t.integer :clicks, null: false, default: 0
|
|
12
|
+
t.datetime :sent_at
|
|
13
|
+
|
|
14
|
+
t.timestamps
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
add_index :ses_dashboard_emails, :message_id, unique: true
|
|
18
|
+
add_index :ses_dashboard_emails, :status
|
|
19
|
+
add_index :ses_dashboard_emails, [:project_id, :sent_at]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class CreateSesDashboardEmailEvents < ActiveRecord::Migration[8.0]
|
|
2
|
+
def change
|
|
3
|
+
create_table :ses_dashboard_email_events do |t|
|
|
4
|
+
t.references :email, null: false, foreign_key: { to_table: :ses_dashboard_emails }
|
|
5
|
+
t.string :event_type, null: false
|
|
6
|
+
t.text :event_data # JSON blob of the full SNS notification payload
|
|
7
|
+
t.datetime :occurred_at, null: false
|
|
8
|
+
|
|
9
|
+
t.timestamps
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
add_index :ses_dashboard_email_events, [:email_id, :event_type]
|
|
13
|
+
add_index :ses_dashboard_email_events, :occurred_at
|
|
14
|
+
end
|
|
15
|
+
end
|
data/docker-compose.yml
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
services:
|
|
2
|
+
localstack:
|
|
3
|
+
image: localstack/localstack:3
|
|
4
|
+
ports:
|
|
5
|
+
- "4566:4566"
|
|
6
|
+
environment:
|
|
7
|
+
- SERVICES=ses,sns
|
|
8
|
+
- DEBUG=1
|
|
9
|
+
- AWS_ACCESS_KEY_ID=test
|
|
10
|
+
- AWS_SECRET_ACCESS_KEY=test
|
|
11
|
+
- AWS_DEFAULT_REGION=us-east-1
|
|
12
|
+
volumes:
|
|
13
|
+
- "./localstack:/var/lib/localstack"
|
|
14
|
+
|
|
15
|
+
chrome:
|
|
16
|
+
image: selenium/standalone-chromium:latest
|
|
17
|
+
ports:
|
|
18
|
+
- "4444:4444" # Selenium WebDriver
|
|
19
|
+
- "7900:7900" # noVNC — open http://localhost:7900 (no password) to watch tests live
|
|
20
|
+
shm_size: "2g" # prevents Chrome tab crashes under load
|
|
21
|
+
environment:
|
|
22
|
+
- JAVA_OPTS=-Dwebdriver.chrome.whitelistedIps=
|
|
23
|
+
- SE_VNC_NO_PASSWORD=1
|
|
24
|
+
|
|
25
|
+
web:
|
|
26
|
+
build: .
|
|
27
|
+
command: bundle exec rspec
|
|
28
|
+
ports:
|
|
29
|
+
- "4001:4001" # Puma server
|
|
30
|
+
depends_on:
|
|
31
|
+
- localstack
|
|
32
|
+
- chrome
|
|
33
|
+
volumes:
|
|
34
|
+
- .:/usr/src/app
|
|
35
|
+
- bundle:/usr/local/bundle
|
|
36
|
+
environment:
|
|
37
|
+
- AWS_ENDPOINT_URL=http://localstack:4566
|
|
38
|
+
- AWS_ACCESS_KEY_ID=test
|
|
39
|
+
- AWS_SECRET_ACCESS_KEY=test
|
|
40
|
+
- AWS_DEFAULT_REGION=us-east-1
|
|
41
|
+
- AWS_REGION=us-east-1
|
|
42
|
+
- SELENIUM_REMOTE_URL=http://chrome:4444/wd/hub
|
|
43
|
+
|
|
44
|
+
volumes:
|
|
45
|
+
bundle:
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require "rack"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
module SesDashboard
|
|
5
|
+
module Auth
|
|
6
|
+
class Base
|
|
7
|
+
def initialize(app = nil)
|
|
8
|
+
@app = app
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def authenticate(request = nil)
|
|
12
|
+
raise NotImplementedError, "Auth adapter must implement authenticate(request)"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(env)
|
|
16
|
+
request = Rack::Request.new(env)
|
|
17
|
+
if authenticate(request)
|
|
18
|
+
@app.call(env)
|
|
19
|
+
else
|
|
20
|
+
unauthorized_response
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def unauthorized_response
|
|
27
|
+
[401, { "Content-Type" => "application/json" }, [{ error: "Unauthorized" }.to_json]]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "base64"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module SesDashboard
|
|
8
|
+
module Auth
|
|
9
|
+
# Validates Cloudflare Zero Trust JWT tokens.
|
|
10
|
+
#
|
|
11
|
+
# Configure in your initializer:
|
|
12
|
+
# SesDashboard.configure do |c|
|
|
13
|
+
# c.authentication_adapter = :cloudflare
|
|
14
|
+
# c.cloudflare_team_domain = "myteam.cloudflareaccess.com"
|
|
15
|
+
# c.cloudflare_aud = "your-application-audience-tag"
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
class CloudflareAdapter < Base
|
|
19
|
+
JWKS_CACHE_TTL = 600 # seconds
|
|
20
|
+
|
|
21
|
+
def authenticate(request = nil)
|
|
22
|
+
return false unless request
|
|
23
|
+
token = extract_token(request)
|
|
24
|
+
return false unless token
|
|
25
|
+
|
|
26
|
+
payload = validate_jwt(token)
|
|
27
|
+
return false unless payload
|
|
28
|
+
|
|
29
|
+
config = SesDashboard.configuration
|
|
30
|
+
return false if config.cloudflare_aud && payload["aud"] != [config.cloudflare_aud]
|
|
31
|
+
|
|
32
|
+
true
|
|
33
|
+
rescue => e
|
|
34
|
+
log_error("Cloudflare JWT validation failed: #{e.message}")
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def extract_token(request)
|
|
41
|
+
# Cloudflare sets this cookie and/or header
|
|
42
|
+
request.cookies["CF_Authorization"] ||
|
|
43
|
+
request.get_header("HTTP_CF_ACCESS_JWT_ASSERTION")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def validate_jwt(token)
|
|
47
|
+
header_b64, payload_b64, signature_b64 = token.split(".")
|
|
48
|
+
return nil unless header_b64 && payload_b64 && signature_b64
|
|
49
|
+
|
|
50
|
+
header = JSON.parse(Base64.urlsafe_decode64(pad(header_b64)))
|
|
51
|
+
payload = JSON.parse(Base64.urlsafe_decode64(pad(payload_b64)))
|
|
52
|
+
|
|
53
|
+
return nil if payload["exp"].to_i < Time.now.to_i # expired
|
|
54
|
+
|
|
55
|
+
kid = header["kid"]
|
|
56
|
+
key = fetch_public_key(kid)
|
|
57
|
+
return nil unless key
|
|
58
|
+
|
|
59
|
+
signing_input = "#{header_b64}.#{payload_b64}"
|
|
60
|
+
signature = Base64.urlsafe_decode64(pad(signature_b64))
|
|
61
|
+
|
|
62
|
+
verified = key.verify(OpenSSL::Digest::SHA256.new, signature, signing_input)
|
|
63
|
+
verified ? payload : nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def fetch_public_key(kid)
|
|
67
|
+
jwks = cached_jwks
|
|
68
|
+
jwk = jwks["keys"]&.find { |k| k["kid"] == kid }
|
|
69
|
+
return nil unless jwk
|
|
70
|
+
|
|
71
|
+
# Build RSA public key from JWK n/e parameters
|
|
72
|
+
rsa = OpenSSL::PKey::RSA.new
|
|
73
|
+
n = OpenSSL::BN.new(Base64.urlsafe_decode64(pad(jwk["n"])), 2)
|
|
74
|
+
e = OpenSSL::BN.new(Base64.urlsafe_decode64(pad(jwk["e"])), 2)
|
|
75
|
+
rsa.set_key(n, e, nil)
|
|
76
|
+
rsa
|
|
77
|
+
rescue
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def cached_jwks
|
|
82
|
+
@jwks_fetched_at ||= 0
|
|
83
|
+
if Time.now.to_i - @jwks_fetched_at > JWKS_CACHE_TTL || @jwks_cache.nil?
|
|
84
|
+
@jwks_cache = fetch_jwks
|
|
85
|
+
@jwks_fetched_at = Time.now.to_i
|
|
86
|
+
end
|
|
87
|
+
@jwks_cache
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def fetch_jwks
|
|
91
|
+
team_domain = SesDashboard.configuration.cloudflare_team_domain
|
|
92
|
+
url = "https://#{team_domain}/cdn-cgi/access/certs"
|
|
93
|
+
response = Net::HTTP.get(URI(url))
|
|
94
|
+
JSON.parse(response)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def pad(str)
|
|
98
|
+
str + "=" * ((4 - str.length % 4) % 4)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def log_error(msg)
|
|
102
|
+
defined?(Rails) ? Rails.logger.warn("[SesDashboard] #{msg}") : nil
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module SesDashboard
|
|
2
|
+
module Auth
|
|
3
|
+
class DeviseAdapter < Base
|
|
4
|
+
def initialize(app = nil, controller: nil)
|
|
5
|
+
super(app)
|
|
6
|
+
@controller = controller
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def authenticate(request = nil)
|
|
10
|
+
return false unless controller
|
|
11
|
+
controller.authenticate_user!
|
|
12
|
+
!controller.current_user.nil?
|
|
13
|
+
rescue StandardError
|
|
14
|
+
false
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
attr_reader :controller
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
require "aws-sdk-ses"
|
|
2
|
+
|
|
3
|
+
module SesDashboard
|
|
4
|
+
class Client
|
|
5
|
+
def initialize(options = {})
|
|
6
|
+
@options = options.dup
|
|
7
|
+
@ses_client = build_ses_client
|
|
8
|
+
@cache = {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# ── SES monitoring API calls ─────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
def send_quota
|
|
14
|
+
cached(:send_quota) { ses_client.get_send_quota }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns send data points from the last 14 days.
|
|
18
|
+
# The SES v1 API accepts no date parameters — it always returns the last 14 days.
|
|
19
|
+
def send_statistics
|
|
20
|
+
cached(:send_statistics) { ses_client.get_send_statistics }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def list_identities
|
|
24
|
+
cached(:identities) { ses_client.list_identities(types: ["EmailAddress", "Domain"]) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def get_identity_verification_attributes(identities)
|
|
28
|
+
cached(:verification_attributes, identities.sort.join(",")) do
|
|
29
|
+
ses_client.get_identity_verification_attributes(identities: Array(identities))
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def get_identity_dkim_attributes(identities)
|
|
34
|
+
cached(:dkim_attributes, identities.sort.join(",")) do
|
|
35
|
+
ses_client.get_identity_dkim_attributes(identities: Array(identities))
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# ── Email sending ─────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
# Sends a plain-text email via SES SendEmail API.
|
|
42
|
+
# Options: from:, to:, subject:, body:, configuration_set: (optional)
|
|
43
|
+
def send_email(from:, to:, subject:, body:, configuration_set: nil)
|
|
44
|
+
params = {
|
|
45
|
+
source: from,
|
|
46
|
+
destinations: [to],
|
|
47
|
+
message: {
|
|
48
|
+
subject: { data: subject, charset: "UTF-8" },
|
|
49
|
+
body: { text: { data: body, charset: "UTF-8" } }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
params[:configuration_set_name] = configuration_set if configuration_set
|
|
53
|
+
ses_client.send_email(params)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
attr_reader :ses_client, :options
|
|
59
|
+
|
|
60
|
+
def build_ses_client
|
|
61
|
+
# Only set region and stub_responses by default; let the AWS SDK credential
|
|
62
|
+
# chain handle auth (SSO, IAM roles, instance profiles, env vars, etc.).
|
|
63
|
+
# Explicit credentials can be supplied via options or configuration for
|
|
64
|
+
# cases like CI where a static key pair is used.
|
|
65
|
+
params = {
|
|
66
|
+
region: options[:region] || SesDashboard.configuration&.aws_region,
|
|
67
|
+
stub_responses: options.fetch(:stub_responses, false)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
config = SesDashboard.configuration
|
|
71
|
+
access_key = options[:access_key_id] || config&.aws_access_key_id
|
|
72
|
+
secret_key = options[:secret_access_key] || config&.aws_secret_access_key
|
|
73
|
+
endpoint = options[:endpoint] || config&.endpoint
|
|
74
|
+
|
|
75
|
+
params[:access_key_id] = access_key if access_key
|
|
76
|
+
params[:secret_access_key] = secret_key if secret_key
|
|
77
|
+
params[:endpoint] = endpoint if endpoint
|
|
78
|
+
|
|
79
|
+
Aws::SES::Client.new(params)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def cached(key, *context)
|
|
83
|
+
return yield unless enable_cache?
|
|
84
|
+
|
|
85
|
+
cache_key = [key, context].flatten.compact.join("-")
|
|
86
|
+
return @cache[cache_key] if @cache.key?(cache_key)
|
|
87
|
+
|
|
88
|
+
@cache[cache_key] = yield
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def enable_cache?
|
|
92
|
+
options.fetch(:cache_enabled, SesDashboard.configuration&.cache_enabled != false)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require "rails"
|
|
2
|
+
require "active_record"
|
|
3
|
+
require "action_controller"
|
|
4
|
+
require "action_view"
|
|
5
|
+
|
|
6
|
+
module SesDashboard
|
|
7
|
+
class Engine < ::Rails::Engine
|
|
8
|
+
isolate_namespace SesDashboard
|
|
9
|
+
|
|
10
|
+
config.generators do |g|
|
|
11
|
+
g.test_framework :rspec
|
|
12
|
+
g.assets false
|
|
13
|
+
g.helper false
|
|
14
|
+
end
|
|
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
|
+
# Precompile engine assets
|
|
24
|
+
initializer "ses_dashboard.assets" do |app|
|
|
25
|
+
if app.config.respond_to?(:assets)
|
|
26
|
+
app.config.assets.precompile += %w[
|
|
27
|
+
ses_dashboard/application.css
|
|
28
|
+
ses_dashboard/application.js
|
|
29
|
+
]
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Expose the engine's helpers to its own views
|
|
34
|
+
config.to_prepare do
|
|
35
|
+
SesDashboard::ApplicationController.helper(SesDashboard::ApplicationHelper) if
|
|
36
|
+
defined?(SesDashboard::ApplicationController)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module SesDashboard
|
|
2
|
+
# Lightweight pagination that avoids requiring kaminari or will_paginate.
|
|
3
|
+
# Returns [records, pagination_info] where pagination_info is a Hash.
|
|
4
|
+
#
|
|
5
|
+
# Usage:
|
|
6
|
+
# records, pagination = Paginatable.paginate(scope, page: 2, per_page: 25)
|
|
7
|
+
#
|
|
8
|
+
module Paginatable
|
|
9
|
+
def self.paginate(scope, page:, per_page: nil)
|
|
10
|
+
per_page = (per_page || SesDashboard.configuration.per_page).to_i
|
|
11
|
+
page = [page.to_i, 1].max
|
|
12
|
+
total_count = scope.count
|
|
13
|
+
total_pages = [(total_count.to_f / per_page).ceil, 1].max
|
|
14
|
+
page = [page, total_pages].min
|
|
15
|
+
|
|
16
|
+
records = scope.offset((page - 1) * per_page).limit(per_page)
|
|
17
|
+
|
|
18
|
+
pagination = {
|
|
19
|
+
page: page,
|
|
20
|
+
per_page: per_page,
|
|
21
|
+
total_count: total_count,
|
|
22
|
+
total_pages: total_pages,
|
|
23
|
+
prev_page: page > 1 ? page - 1 : nil,
|
|
24
|
+
next_page: page < total_pages ? page + 1 : nil
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
[records, pagination]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|