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
@@ -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,6 @@
1
+ <div class="page-header">
2
+ <h1 class="page-title">Edit Project</h1>
3
+ </div>
4
+ <div class="card" style="max-width:560px;">
5
+ <%= render "ses_dashboard/projects/form", project: @project %>
6
+ </div>
@@ -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] %>&hellip;</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,6 @@
1
+ <div class="page-header">
2
+ <h1 class="page-title">New Project</h1>
3
+ </div>
4
+ <div class="card" style="max-width:560px;">
5
+ <%= render "ses_dashboard/projects/form", project: @project %>
6
+ </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,3 @@
1
+ <% flash.each do |type, message| %>
2
+ <div class="flash flash-<%= type %>"><%= message %></div>
3
+ <% end %>
@@ -0,0 +1,15 @@
1
+ <% if pagination[:total_pages] > 1 %>
2
+ <div class="pagination">
3
+ <%= pagination_link("&laquo; Prev".html_safe, pagination[:prev_page]) %>
4
+ <span class="pagination-info">
5
+ Page <%= pagination[:page] %> of <%= pagination[:total_pages] %>
6
+ &nbsp;&middot;&nbsp;
7
+ <%= number_with_delimiter(pagination[:total_count]) %> total
8
+ </span>
9
+ <%= pagination_link("Next &raquo;".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,4 @@
1
+ <div class="card">
2
+ <div class="card-title"><%= label %></div>
3
+ <div class="card-value" style="color: <%= color %>"><%= number_with_delimiter(value) %></div>
4
+ </div>
@@ -0,0 +1,38 @@
1
+ <div class="page-header">
2
+ <h1 class="page-title">Send test email — <%= @project.name %></h1>
3
+ <%= link_to "&larr; 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
@@ -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