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,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,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…">
|
|
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 "← 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>
|