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,93 @@
1
+ require "csv"
2
+
3
+ module SesDashboard
4
+ class EmailsController < ApplicationController
5
+ before_action :set_project
6
+
7
+ def index
8
+ scope = filtered_scope
9
+ @emails, @pagination = Paginatable.paginate(scope, page: params[:page])
10
+
11
+ respond_to do |format|
12
+ format.html
13
+ format.json { render json: serialize_emails(@emails) }
14
+ end
15
+ end
16
+
17
+ def show
18
+ @email = @project.emails.find(params[:id])
19
+ @events = @email.email_events.ordered
20
+ end
21
+
22
+ def export
23
+ scope = filtered_scope
24
+
25
+ respond_to do |format|
26
+ format.csv do
27
+ send_data generate_csv(scope), filename: "emails-#{Date.today}.csv", type: "text/csv"
28
+ end
29
+ format.json do
30
+ send_data serialize_emails(scope).to_json,
31
+ filename: "emails-#{Date.today}.json",
32
+ type: "application/json"
33
+ end
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def set_project
40
+ @project = Project.find(params[:project_id])
41
+ end
42
+
43
+ def filtered_scope
44
+ scope = @project.emails.ordered
45
+ scope = scope.search(params[:q])
46
+ scope = scope.by_status(params[:status])
47
+ scope = scope.in_date_range(
48
+ parse_date(params[:from_date]),
49
+ parse_date(params[:to_date])
50
+ )
51
+ scope
52
+ end
53
+
54
+ def parse_date(str)
55
+ Time.zone.parse(str) if str.present?
56
+ rescue ArgumentError
57
+ nil
58
+ end
59
+
60
+ def generate_csv(scope)
61
+ CSV.generate(headers: true) do |csv|
62
+ csv << %w[message_id source subject destination status opens clicks sent_at]
63
+ scope.each do |email|
64
+ csv << [
65
+ email.message_id,
66
+ email.source,
67
+ email.subject,
68
+ Array(email.destination).join("; "),
69
+ email.status,
70
+ email.opens,
71
+ email.clicks,
72
+ email.sent_at&.iso8601
73
+ ]
74
+ end
75
+ end
76
+ end
77
+
78
+ def serialize_emails(emails)
79
+ emails.map do |e|
80
+ {
81
+ message_id: e.message_id,
82
+ source: e.source,
83
+ subject: e.subject,
84
+ destination: e.destination,
85
+ status: e.status,
86
+ opens: e.opens,
87
+ clicks: e.clicks,
88
+ sent_at: e.sent_at&.iso8601
89
+ }
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,67 @@
1
+ module SesDashboard
2
+ class ProjectsController < ApplicationController
3
+ before_action :set_project, only: [:show, :edit, :update, :destroy]
4
+
5
+ def index
6
+ @projects = Project.ordered
7
+ end
8
+
9
+ def show
10
+ from = parse_date(params[:from]) || 30.days.ago.beginning_of_day
11
+ to = parse_date(params[:to]) || Time.current.end_of_day
12
+
13
+ agg = StatsAggregator.new(project_id: @project.id, from: from, to: to)
14
+
15
+ @counters = agg.counters
16
+ @total_opens = agg.total_opens
17
+ @total_clicks = agg.total_clicks
18
+ @chart_data = agg.time_series
19
+ @from = from
20
+ @to = to
21
+ end
22
+
23
+ def new
24
+ @project = Project.new
25
+ end
26
+
27
+ def create
28
+ @project = Project.new(project_params)
29
+ if @project.save
30
+ redirect_to project_path(@project), notice: "Project created."
31
+ else
32
+ render :new, status: :unprocessable_entity
33
+ end
34
+ end
35
+
36
+ def edit; end
37
+
38
+ def update
39
+ if @project.update(project_params)
40
+ redirect_to project_path(@project), notice: "Project updated."
41
+ else
42
+ render :edit, status: :unprocessable_entity
43
+ end
44
+ end
45
+
46
+ def destroy
47
+ @project.destroy
48
+ redirect_to projects_path, notice: "Project deleted."
49
+ end
50
+
51
+ private
52
+
53
+ def set_project
54
+ @project = Project.find(params[:id])
55
+ end
56
+
57
+ def project_params
58
+ params.require(:project).permit(:name, :description)
59
+ end
60
+
61
+ def parse_date(str)
62
+ Time.zone.parse(str) if str.present?
63
+ rescue ArgumentError
64
+ nil
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,38 @@
1
+ module SesDashboard
2
+ class TestEmailsController < ApplicationController
3
+ before_action :set_project
4
+
5
+ def new
6
+ @from = SesDashboard.configuration.test_email_from
7
+ end
8
+
9
+ def create
10
+ from = params[:from].presence || SesDashboard.configuration.test_email_from
11
+ to = params[:to].presence
12
+ subject = params[:subject].presence || "Test email from SES Dashboard"
13
+ body = params[:body].presence || "This is a test email sent via the SES Dashboard."
14
+
15
+ unless from && to
16
+ flash.now[:alert] = "From and To addresses are required."
17
+ @from = from
18
+ return render :new, status: :unprocessable_entity
19
+ end
20
+
21
+ begin
22
+ ses_client = SesDashboard::Client.new
23
+ ses_client.send_email(from: from, to: to, subject: subject, body: body)
24
+ redirect_to project_path(@project), notice: "Test email sent to #{to}."
25
+ rescue => e
26
+ flash.now[:alert] = "Failed to send email: #{e.message}"
27
+ @from = from
28
+ render :new, status: :unprocessable_entity
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def set_project
35
+ @project = Project.find(params[:project_id])
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,39 @@
1
+ require "net/http"
2
+ require "uri"
3
+
4
+ module SesDashboard
5
+ class WebhooksController < ApplicationController
6
+ # Webhooks are authenticated by the project token in the URL, not by session auth.
7
+ skip_before_action :authenticate!
8
+ skip_before_action :verify_authenticity_token
9
+
10
+ def create
11
+ project = Project.find_by!(token: params[:project_token])
12
+ body = request.body.read
13
+
14
+ result = WebhookProcessor.new(body).process
15
+
16
+ case result.action
17
+ when :confirm_subscription
18
+ confirm_subscription(result.subscribe_url)
19
+ when :process_event
20
+ WebhookEventPersistor.new(project, result).persist
21
+ end
22
+
23
+ head :ok
24
+ rescue ActiveRecord::RecordNotFound
25
+ head :not_found
26
+ rescue => e
27
+ Rails.logger.error("[SesDashboard] Webhook error: #{e.message}") if defined?(Rails)
28
+ head :unprocessable_entity
29
+ end
30
+
31
+ private
32
+
33
+ def confirm_subscription(url)
34
+ Net::HTTP.get(URI(url))
35
+ rescue => e
36
+ Rails.logger.warn("[SesDashboard] SNS subscription confirm failed: #{e.message}") if defined?(Rails)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,47 @@
1
+ module SesDashboard
2
+ module ApplicationHelper
3
+ STATUS_BADGE_CLASSES = {
4
+ "sent" => "badge-sent",
5
+ "delivered" => "badge-delivered",
6
+ "bounced" => "badge-bounced",
7
+ "complained" => "badge-complained",
8
+ "rejected" => "badge-rejected",
9
+ "failed" => "badge-failed"
10
+ }.freeze
11
+
12
+ def status_badge(status)
13
+ css = STATUS_BADGE_CLASSES.fetch(status.to_s, "badge-unknown")
14
+ content_tag(:span, status.to_s.capitalize, class: "badge #{css}")
15
+ end
16
+
17
+ def format_event_type(type)
18
+ type.to_s.gsub("_", " ").capitalize
19
+ end
20
+
21
+ def webhook_url_for(project)
22
+ ses_dashboard.webhook_url(project.token)
23
+ end
24
+
25
+ def format_destination(destination)
26
+ Array(destination).join(", ")
27
+ end
28
+
29
+ def chart_data_tag(data)
30
+ content_tag(:script, data.to_json.html_safe,
31
+ id: "chart-data", type: "application/json")
32
+ end
33
+
34
+ def date_range_link(label, days)
35
+ from = days.days.ago.beginning_of_day
36
+ to = Time.current.end_of_day
37
+ link_to label, request.path + "?from=#{from.iso8601}&to=#{to.iso8601}",
38
+ class: "date-preset-link"
39
+ end
40
+
41
+ def pagination_link(label, page, params_override = {})
42
+ return content_tag(:span, label, class: "pagination-disabled") unless page
43
+ link_to label, request.path + "?" + request.query_parameters.merge(params_override.merge(page: page)).to_query,
44
+ class: "pagination-link"
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,5 @@
1
+ module SesDashboard
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,48 @@
1
+ module SesDashboard
2
+ class Email < ApplicationRecord
3
+ self.table_name = "ses_dashboard_emails"
4
+
5
+ belongs_to :project, class_name: "SesDashboard::Project"
6
+ has_many :email_events, class_name: "SesDashboard::EmailEvent", dependent: :destroy
7
+
8
+ # destination is a JSON-encoded array of recipient addresses
9
+ serialize :destination, coder: JSON
10
+
11
+ STATUSES = %w[sent delivered bounced complained rejected failed].freeze
12
+
13
+ validates :message_id, presence: true, uniqueness: true
14
+ validates :source, presence: true
15
+ validates :destination, presence: true
16
+ validates :status, inclusion: { in: STATUSES }
17
+
18
+ scope :by_project, ->(project_id) { where(project_id: project_id) }
19
+ scope :in_date_range, ->(from, to) { where(sent_at: from..to) if from && to }
20
+ scope :by_status, ->(status) { where(status: status) if status.present? }
21
+ scope :ordered, -> { order(sent_at: :desc, created_at: :desc) }
22
+
23
+ scope :search, ->(query) {
24
+ return all unless query.present?
25
+ term = "%#{sanitize_sql_like(query)}%"
26
+ where("source LIKE :q OR subject LIKE :q OR destination LIKE :q", q: term)
27
+ }
28
+
29
+ # State-machine transitions applied when SNS events arrive.
30
+ # Only advance; never move backward (e.g., a late delivery event doesn't
31
+ # overwrite a bounce).
32
+ TRANSITIONS = {
33
+ "sent" => %w[delivered bounced complained rejected failed],
34
+ "delivered" => %w[complained],
35
+ "bounced" => [],
36
+ "complained" => [],
37
+ "rejected" => [],
38
+ "failed" => []
39
+ }.freeze
40
+
41
+ def apply_status!(new_status)
42
+ return if status == new_status
43
+ return unless TRANSITIONS.fetch(status, []).include?(new_status)
44
+
45
+ update_column(:status, new_status)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,18 @@
1
+ module SesDashboard
2
+ class EmailEvent < ApplicationRecord
3
+ self.table_name = "ses_dashboard_email_events"
4
+
5
+ belongs_to :email, class_name: "SesDashboard::Email"
6
+
7
+ serialize :event_data, coder: JSON
8
+
9
+ EVENT_TYPES = %w[
10
+ send delivery bounce complaint open click reject rendering_failure
11
+ ].freeze
12
+
13
+ validates :event_type, inclusion: { in: EVENT_TYPES }
14
+ validates :occurred_at, presence: true
15
+
16
+ scope :ordered, -> { order(occurred_at: :asc) }
17
+ end
18
+ end
@@ -0,0 +1,20 @@
1
+ module SesDashboard
2
+ class Project < ApplicationRecord
3
+ self.table_name = "ses_dashboard_projects"
4
+
5
+ has_many :emails, class_name: "SesDashboard::Email", dependent: :destroy
6
+
7
+ validates :name, presence: true
8
+ validates :token, presence: true, uniqueness: true
9
+
10
+ before_validation :generate_token, on: :create
11
+
12
+ scope :ordered, -> { order(:name) }
13
+
14
+ private
15
+
16
+ def generate_token
17
+ self.token ||= SecureRandom.hex(16)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,69 @@
1
+ module SesDashboard
2
+ # Persists a processed SNS event (from WebhookProcessor::Result) to the database.
3
+ #
4
+ # Separating parsing (WebhookProcessor, in lib/) from persistence (here) keeps
5
+ # the parser free of Rails/AR dependencies and fully unit-testable.
6
+ #
7
+ class WebhookEventPersistor
8
+ def initialize(project, result)
9
+ @project = project
10
+ @result = result
11
+ end
12
+
13
+ def persist
14
+ return if @result.message_id.blank?
15
+
16
+ ActiveRecord::Base.transaction do
17
+ email = find_or_create_email
18
+ create_event(email)
19
+ update_email_state(email)
20
+ end
21
+ rescue ActiveRecord::RecordInvalid => e
22
+ log_error("Failed to persist webhook event: #{e.message}")
23
+ end
24
+
25
+ private
26
+
27
+ def find_or_create_email
28
+ Email.find_or_initialize_by(message_id: @result.message_id) do |e|
29
+ e.project = @project
30
+ e.destination = @result.destination
31
+ e.source = @result.source
32
+ e.subject = @result.subject
33
+ e.sent_at = @result.occurred_at
34
+ e.status = "sent"
35
+ end.tap(&:save!)
36
+ end
37
+
38
+ def create_event(email)
39
+ email.email_events.create!(
40
+ event_type: @result.event_type,
41
+ event_data: @result.raw_payload,
42
+ occurred_at: @result.occurred_at
43
+ )
44
+ end
45
+
46
+ def update_email_state(email)
47
+ case @result.event_type
48
+ when "delivery"
49
+ email.apply_status!("delivered")
50
+ when "bounce"
51
+ email.apply_status!("bounced")
52
+ when "complaint"
53
+ email.apply_status!("complained")
54
+ when "reject"
55
+ email.apply_status!("rejected")
56
+ when "rendering_failure"
57
+ email.apply_status!("failed")
58
+ when "open"
59
+ Email.update_counters(email.id, opens: 1)
60
+ when "click"
61
+ Email.update_counters(email.id, clicks: 1)
62
+ end
63
+ end
64
+
65
+ def log_error(msg)
66
+ defined?(Rails) ? Rails.logger.error("[SesDashboard] #{msg}") : nil
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,25 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>SES Dashboard</title>
7
+ <%= stylesheet_link_tag "ses_dashboard/application", media: "all" %>
8
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4" defer></script>
9
+ <%= javascript_include_tag "ses_dashboard/application", defer: true %>
10
+ <%= csrf_meta_tags %>
11
+ </head>
12
+ <body>
13
+ <nav class="ses-nav">
14
+ <span class="ses-nav-brand">SES Dashboard</span>
15
+ <%= link_to "Dashboard", root_path %>
16
+ <%= link_to "Projects", projects_path %>
17
+ <span class="ses-nav-spacer"></span>
18
+ </nav>
19
+
20
+ <div class="ses-container">
21
+ <%= render "ses_dashboard/shared/flash" %>
22
+ <%= yield %>
23
+ </div>
24
+ </body>
25
+ </html>
@@ -0,0 +1,56 @@
1
+ <div class="page-header">
2
+ <h1 class="page-title">Dashboard</h1>
3
+ <form method="get" style="display:flex;gap:.5rem;align-items:center;">
4
+ <input type="date" name="from" class="form-control" value="<%= @from.to_date %>" style="width:auto;">
5
+ <span>to</span>
6
+ <input type="date" name="to" class="form-control" value="<%= @to.to_date %>" style="width:auto;">
7
+ <button type="submit" class="btn btn-outline btn-sm">Apply</button>
8
+ </form>
9
+ </div>
10
+
11
+ <div class="stat-grid">
12
+ <%= render "ses_dashboard/shared/stat_card", label: "Sent", value: @counters[:total], color: "var(--color-text)" %>
13
+ <%= render "ses_dashboard/shared/stat_card", label: "Delivered", value: @counters[:delivered], color: "var(--color-delivered)" %>
14
+ <%= render "ses_dashboard/shared/stat_card", label: "Opens", value: @total_opens, color: "var(--color-primary)" %>
15
+ <%= render "ses_dashboard/shared/stat_card", label: "Clicks", value: @total_clicks, color: "var(--color-primary)" %>
16
+ <%= render "ses_dashboard/shared/stat_card", label: "Not Delivered", value: @counters[:not_delivered], color: "var(--color-bounced)" %>
17
+ <%= render "ses_dashboard/shared/stat_card", label: "Bounced", value: @counters[:bounced], color: "var(--color-bounced)" %>
18
+ <%= render "ses_dashboard/shared/stat_card", label: "Complained", value: @counters[:complained], color: "var(--color-complained)" %>
19
+ </div>
20
+
21
+ <div class="card chart-card">
22
+ <div class="card-title">Email volume (by day)</div>
23
+ <canvas id="activity-chart"></canvas>
24
+ </div>
25
+ <%= chart_data_tag(@chart_data) %>
26
+
27
+ <div class="page-header" style="margin-top:1.5rem;">
28
+ <h2 class="page-title" style="font-size:1rem;">Projects</h2>
29
+ <%= link_to "Manage projects", projects_path, class: "btn btn-outline btn-sm" %>
30
+ </div>
31
+
32
+ <div class="table-wrapper">
33
+ <table>
34
+ <thead>
35
+ <tr>
36
+ <th>Project</th>
37
+ <th>Total emails</th>
38
+ <th>Activity</th>
39
+ </tr>
40
+ </thead>
41
+ <tbody>
42
+ <% @projects.each do |project| %>
43
+ <tr>
44
+ <td><%= link_to project.name, project_path(project) %></td>
45
+ <td><%= number_with_delimiter(project.emails.count) %></td>
46
+ <td><%= link_to "View activity", project_emails_path(project) %></td>
47
+ </tr>
48
+ <% end %>
49
+ <% if @projects.empty? %>
50
+ <tr><td colspan="3" style="color:var(--color-text-muted);text-align:center;padding:2rem;">
51
+ No projects yet. <%= link_to "Create one", new_project_path %>.
52
+ </td></tr>
53
+ <% end %>
54
+ </tbody>
55
+ </table>
56
+ </div>
@@ -0,0 +1,72 @@
1
+ <div class="page-header">
2
+ <h1 class="page-title">Activity — <%= @project.name %></h1>
3
+ <div style="display:flex;gap:.5rem;">
4
+ <%= link_to "Export CSV", export_project_emails_path(@project, request.query_parameters.merge(format: :csv)), class: "btn btn-outline btn-sm" %>
5
+ <%= link_to "Export JSON", export_project_emails_path(@project, request.query_parameters.merge(format: :json)), class: "btn btn-outline btn-sm" %>
6
+ </div>
7
+ </div>
8
+
9
+ <%= form_with url: project_emails_path(@project), method: :get, local: true do |f| %>
10
+ <div class="filter-bar">
11
+ <div class="form-group">
12
+ <label class="form-label" for="q">Search</label>
13
+ <input type="text" name="q" id="q" class="form-control" value="<%= params[:q] %>" placeholder="Subject, from, to&hellip;">
14
+ </div>
15
+ <div class="form-group">
16
+ <label class="form-label">Status</label>
17
+ <select name="status" class="form-control">
18
+ <option value="">All</option>
19
+ <% SesDashboard::Email::STATUSES.each do |s| %>
20
+ <option value="<%= s %>" <%= "selected" if params[:status] == s %>><%= s.capitalize %></option>
21
+ <% end %>
22
+ </select>
23
+ </div>
24
+ <div class="form-group">
25
+ <label class="form-label">From date</label>
26
+ <input type="date" name="from_date" class="form-control" value="<%= params[:from_date] %>">
27
+ </div>
28
+ <div class="form-group">
29
+ <label class="form-label">To date</label>
30
+ <input type="date" name="to_date" class="form-control" value="<%= params[:to_date] %>">
31
+ </div>
32
+ <div class="form-group" style="align-self:flex-end;">
33
+ <button type="submit" class="btn btn-primary btn-sm">Filter</button>
34
+ <%= link_to "Reset", project_emails_path(@project), class: "btn btn-outline btn-sm", id: "filter-reset" %>
35
+ </div>
36
+ </div>
37
+ <% end %>
38
+
39
+ <div class="table-wrapper">
40
+ <table>
41
+ <thead>
42
+ <tr>
43
+ <th>Subject</th>
44
+ <th>From</th>
45
+ <th>To</th>
46
+ <th>Status</th>
47
+ <th>Sent at</th>
48
+ <th>Opens</th>
49
+ <th>Clicks</th>
50
+ <th></th>
51
+ </tr>
52
+ </thead>
53
+ <tbody>
54
+ <% @emails.each do |email| %>
55
+ <tr>
56
+ <td><%= email.subject.presence || "(no subject)" %></td>
57
+ <td><%= email.source %></td>
58
+ <td><%= format_destination(email.destination) %></td>
59
+ <td><%= status_badge(email.status) %></td>
60
+ <td style="white-space:nowrap;"><%= email.sent_at&.strftime("%Y-%m-%d %H:%M") || "—" %></td>
61
+ <td><%= email.opens %></td>
62
+ <td><%= email.clicks %></td>
63
+ <td><%= link_to "Details", project_email_path(@project, email), class: "btn btn-sm btn-outline" %></td>
64
+ </tr>
65
+ <% end %>
66
+ <% if @emails.empty? %>
67
+ <tr><td colspan="8" style="color:var(--color-text-muted);text-align:center;padding:2rem;">No emails found.</td></tr>
68
+ <% end %>
69
+ </tbody>
70
+ </table>
71
+ <%= render "ses_dashboard/shared/pagination", pagination: @pagination %>
72
+ </div>
@@ -0,0 +1,60 @@
1
+ <div class="page-header">
2
+ <h1 class="page-title">Email detail</h1>
3
+ <%= link_to "&larr; Back to activity".html_safe, project_emails_path(@project), class: "btn btn-outline btn-sm" %>
4
+ </div>
5
+
6
+ <div class="card" style="margin-bottom:1.5rem;">
7
+ <table style="width:100%;border:none;">
8
+ <tbody>
9
+ <tr>
10
+ <td style="width:120px;color:var(--color-text-muted);font-weight:600;padding:.4rem 0;">Subject</td>
11
+ <td><%= @email.subject.presence || "(no subject)" %></td>
12
+ </tr>
13
+ <tr>
14
+ <td style="color:var(--color-text-muted);font-weight:600;padding:.4rem 0;">From</td>
15
+ <td><%= @email.source %></td>
16
+ </tr>
17
+ <tr>
18
+ <td style="color:var(--color-text-muted);font-weight:600;padding:.4rem 0;">To</td>
19
+ <td><%= format_destination(@email.destination) %></td>
20
+ </tr>
21
+ <tr>
22
+ <td style="color:var(--color-text-muted);font-weight:600;padding:.4rem 0;">Status</td>
23
+ <td><%= status_badge(@email.status) %></td>
24
+ </tr>
25
+ <tr>
26
+ <td style="color:var(--color-text-muted);font-weight:600;padding:.4rem 0;">Sent at</td>
27
+ <td><%= @email.sent_at&.strftime("%Y-%m-%d %H:%M UTC") || "—" %></td>
28
+ </tr>
29
+ <tr>
30
+ <td style="color:var(--color-text-muted);font-weight:600;padding:.4rem 0;">Opens / Clicks</td>
31
+ <td><%= @email.opens %> / <%= @email.clicks %></td>
32
+ </tr>
33
+ <tr>
34
+ <td style="color:var(--color-text-muted);font-weight:600;padding:.4rem 0;">Message ID</td>
35
+ <td style="font-family:monospace;font-size:.8125rem;"><%= @email.message_id %></td>
36
+ </tr>
37
+ </tbody>
38
+ </table>
39
+ </div>
40
+
41
+ <h2 class="page-title" style="font-size:1rem;margin-bottom:1rem;">Event timeline</h2>
42
+
43
+ <ul class="event-timeline card">
44
+ <% @events.each do |event| %>
45
+ <li class="event-item">
46
+ <span class="event-time"><%= event.occurred_at&.strftime("%Y-%m-%d %H:%M:%S UTC") %></span>
47
+ <div>
48
+ <%= status_badge(event.event_type) %>
49
+ <% if event.event_data.present? %>
50
+ <br>
51
+ <button class="event-data-toggle">Show raw</button>
52
+ <pre class="event-raw"><%= JSON.pretty_generate(event.event_data) %></pre>
53
+ <% end %>
54
+ </div>
55
+ </li>
56
+ <% end %>
57
+ <% if @events.empty? %>
58
+ <li style="padding:1rem;color:var(--color-text-muted);">No events recorded.</li>
59
+ <% end %>
60
+ </ul>