mailer-log 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/CHANGELOG.md +22 -0
- data/LICENSE +21 -0
- data/README.md +286 -0
- data/app/assets/stylesheets/mailer_log/emails.scss +18 -0
- data/app/controllers/mailer_log/admin/emails_controller.rb +34 -0
- data/app/controllers/mailer_log/admin_controller.rb +37 -0
- data/app/controllers/mailer_log/api/emails_controller.rb +88 -0
- data/app/controllers/mailer_log/api/mailers_controller.rb +15 -0
- data/app/controllers/mailer_log/application_controller.rb +7 -0
- data/app/controllers/mailer_log/assets_controller.rb +42 -0
- data/app/controllers/mailer_log/spa_controller.rb +13 -0
- data/app/controllers/mailer_log/webhooks_controller.rb +115 -0
- data/app/helpers/mailer_log/spa_helper.rb +48 -0
- data/app/jobs/mailer_log/application_job.rb +12 -0
- data/app/jobs/mailer_log/cleanup_job.rb +16 -0
- data/app/models/mailer_log/application_record.rb +7 -0
- data/app/models/mailer_log/current.rb +10 -0
- data/app/models/mailer_log/email.rb +47 -0
- data/app/models/mailer_log/event.rb +27 -0
- data/app/views/mailer_log/admin/emails/_email.html.erb +29 -0
- data/app/views/mailer_log/admin/emails/_filters.html.erb +58 -0
- data/app/views/mailer_log/admin/emails/index.html.erb +61 -0
- data/app/views/mailer_log/admin/emails/show.html.erb +132 -0
- data/app/views/mailer_log/spa/index.html.erb +21 -0
- data/config/locales/en.yml +5 -0
- data/config/routes.rb +26 -0
- data/db/schema.rb +17 -0
- data/lib/generators/mailer_log/install/install_generator.rb +33 -0
- data/lib/generators/mailer_log/install/templates/README +35 -0
- data/lib/generators/mailer_log/install/templates/create_mailer_log_tables.rb.tt +52 -0
- data/lib/generators/mailer_log/install/templates/initializer.rb.tt +33 -0
- data/lib/mailer_log/configuration.rb +33 -0
- data/lib/mailer_log/engine.rb +28 -0
- data/lib/mailer_log/mail_interceptor.rb +97 -0
- data/lib/mailer_log/mail_observer.rb +45 -0
- data/lib/mailer_log/version.rb +5 -0
- data/lib/mailer_log.rb +24 -0
- data/lib/tasks/mailer_log.rake +41 -0
- data/public/mailer_log/.vite/manifest.json +11 -0
- data/public/mailer_log/assets/index-D_66gvIL.css +1 -0
- data/public/mailer_log/assets/mailer_log-2Waj6tsV.js +46 -0
- data/public/mailer_log/index.html +13 -0
- metadata +139 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MailerLog
|
|
4
|
+
class WebhooksController < ActionController::Base
|
|
5
|
+
skip_before_action :verify_authenticity_token
|
|
6
|
+
|
|
7
|
+
before_action :verify_signature
|
|
8
|
+
before_action :deduplicate_event
|
|
9
|
+
|
|
10
|
+
# POST /mailer_log/webhooks/mailgun
|
|
11
|
+
def mailgun
|
|
12
|
+
event_data = params['event-data'] || params
|
|
13
|
+
|
|
14
|
+
email = find_email(event_data)
|
|
15
|
+
unless email
|
|
16
|
+
Rails.logger.info("MailerLog: Email not found for webhook event")
|
|
17
|
+
return head :ok
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
normalized_event = normalize_event_type(event_data['event'])
|
|
21
|
+
create_event(email, event_data, normalized_event)
|
|
22
|
+
email.update_status_from_event!(normalized_event)
|
|
23
|
+
|
|
24
|
+
head :ok
|
|
25
|
+
rescue StandardError => e
|
|
26
|
+
Rails.logger.error("MailerLog webhook error: #{e.message}")
|
|
27
|
+
Rails.logger.error(e.backtrace.first(5).join("\n"))
|
|
28
|
+
report_error(e)
|
|
29
|
+
head :ok # Return 200 to prevent Mailgun retries for processing errors
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def verify_signature
|
|
35
|
+
signature = params['signature']
|
|
36
|
+
return head :unauthorized unless signature
|
|
37
|
+
|
|
38
|
+
timestamp = signature['timestamp']
|
|
39
|
+
token = signature['token']
|
|
40
|
+
sig = signature['signature']
|
|
41
|
+
|
|
42
|
+
return head :unauthorized unless timestamp && token && sig
|
|
43
|
+
|
|
44
|
+
signing_key = MailerLog.configuration.webhook_signing_key
|
|
45
|
+
return head :unauthorized unless signing_key
|
|
46
|
+
|
|
47
|
+
expected = OpenSSL::HMAC.hexdigest(
|
|
48
|
+
OpenSSL::Digest.new('SHA256'),
|
|
49
|
+
signing_key,
|
|
50
|
+
"#{timestamp}#{token}"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
return if ActiveSupport::SecurityUtils.secure_compare(expected, sig)
|
|
54
|
+
|
|
55
|
+
head :unauthorized
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def deduplicate_event
|
|
59
|
+
event_id = params.dig('event-data', 'id')
|
|
60
|
+
return true unless event_id
|
|
61
|
+
|
|
62
|
+
cache_key = "mailer_log:event:#{event_id}"
|
|
63
|
+
|
|
64
|
+
if Rails.cache.exist?(cache_key)
|
|
65
|
+
head :ok
|
|
66
|
+
return false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
Rails.cache.write(cache_key, true, expires_in: 24.hours)
|
|
70
|
+
true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def find_email(event_data)
|
|
74
|
+
# Try message_id first
|
|
75
|
+
message_id = event_data.dig('message', 'headers', 'message-id')
|
|
76
|
+
if message_id.present?
|
|
77
|
+
email = Email.find_by(message_id: message_id.gsub(/[<>]/, ''))
|
|
78
|
+
return email if email
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Fallback to tracking_id from custom header
|
|
82
|
+
tracking_id = event_data.dig('message', 'headers', 'x-mailer-log-tracking-id')
|
|
83
|
+
Email.find_by(tracking_id: tracking_id) if tracking_id.present?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def create_event(email, event_data, normalized_event)
|
|
87
|
+
email.events.create!(
|
|
88
|
+
event_type: normalized_event,
|
|
89
|
+
mailgun_event_id: event_data['id'],
|
|
90
|
+
occurred_at: extract_timestamp(event_data),
|
|
91
|
+
recipient: event_data['recipient'],
|
|
92
|
+
ip_address: event_data['ip'],
|
|
93
|
+
user_agent: event_data.dig('client-info', 'user-agent'),
|
|
94
|
+
raw_payload: event_data.to_unsafe_h
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def normalize_event_type(mailgun_event)
|
|
99
|
+
case mailgun_event
|
|
100
|
+
when 'permanent_fail' then 'bounced'
|
|
101
|
+
when 'temporary_fail' then 'failed'
|
|
102
|
+
else mailgun_event
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def extract_timestamp(event_data)
|
|
107
|
+
timestamp = event_data['timestamp']
|
|
108
|
+
Time.zone.at(timestamp.to_f) if timestamp.present?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def report_error(exception)
|
|
112
|
+
Airbrake.notify(exception) if defined?(Airbrake)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MailerLog
|
|
4
|
+
module SpaHelper
|
|
5
|
+
def mailer_log_asset_path(name)
|
|
6
|
+
manifest = mailer_log_manifest
|
|
7
|
+
return "/admin/email_log/assets/#{name}" unless manifest
|
|
8
|
+
|
|
9
|
+
entry = manifest[name] || manifest["src/#{name}"] || manifest["assets/#{name}"]
|
|
10
|
+
return "/admin/email_log/assets/#{name}" unless entry
|
|
11
|
+
|
|
12
|
+
file = entry.is_a?(Hash) ? entry['file'] : entry
|
|
13
|
+
"/admin/email_log/assets/#{file}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def mailer_log_js_entry
|
|
17
|
+
manifest = mailer_log_manifest
|
|
18
|
+
return nil unless manifest
|
|
19
|
+
|
|
20
|
+
entry = manifest['index.html'] || manifest['src/main.js']
|
|
21
|
+
return nil unless entry
|
|
22
|
+
|
|
23
|
+
entry.is_a?(Hash) ? entry['file'] : entry
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def mailer_log_css_entry
|
|
27
|
+
manifest = mailer_log_manifest
|
|
28
|
+
return nil unless manifest
|
|
29
|
+
|
|
30
|
+
entry = manifest['index.html']
|
|
31
|
+
return nil unless entry.is_a?(Hash)
|
|
32
|
+
|
|
33
|
+
css_files = entry['css']
|
|
34
|
+
css_files&.first
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def mailer_log_manifest
|
|
40
|
+
@mailer_log_manifest ||= begin
|
|
41
|
+
manifest_path = MailerLog::Engine.root.join('public', 'mailer_log', '.vite', 'manifest.json')
|
|
42
|
+
return nil unless File.exist?(manifest_path)
|
|
43
|
+
|
|
44
|
+
JSON.parse(File.read(manifest_path))
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MailerLog
|
|
4
|
+
class ApplicationJob < ActiveJob::Base
|
|
5
|
+
# Retry on common transient failures
|
|
6
|
+
retry_on ActiveRecord::Deadlocked, wait: 5.seconds, attempts: 3
|
|
7
|
+
retry_on ActiveRecord::LockWaitTimeout, wait: 5.seconds, attempts: 3
|
|
8
|
+
|
|
9
|
+
# Discard job on unrecoverable errors
|
|
10
|
+
discard_on ActiveJob::DeserializationError
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MailerLog
|
|
4
|
+
class CleanupJob < ApplicationJob
|
|
5
|
+
queue_as :default
|
|
6
|
+
|
|
7
|
+
def perform
|
|
8
|
+
retention_period = MailerLog.configuration.retention_period
|
|
9
|
+
cutoff_date = retention_period.ago
|
|
10
|
+
|
|
11
|
+
deleted_count = MailerLog::Email.where('created_at < ?', cutoff_date).delete_all
|
|
12
|
+
|
|
13
|
+
Rails.logger.info("MailerLog::CleanupJob: Deleted #{deleted_count} emails older than #{cutoff_date}")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MailerLog
|
|
4
|
+
# Thread-safe storage for passing data between interceptor and observer.
|
|
5
|
+
# Uses ActiveSupport::CurrentAttributes which automatically resets
|
|
6
|
+
# between requests and is safe for multi-threaded servers.
|
|
7
|
+
class Current < ActiveSupport::CurrentAttributes
|
|
8
|
+
attribute :email_data
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MailerLog
|
|
4
|
+
class Email < ApplicationRecord
|
|
5
|
+
self.table_name = 'mailer_log_emails'
|
|
6
|
+
|
|
7
|
+
STATUSES = %w[pending sent delivered opened clicked bounced complained].freeze
|
|
8
|
+
|
|
9
|
+
has_many :events, class_name: 'MailerLog::Event', dependent: :delete_all
|
|
10
|
+
|
|
11
|
+
belongs_to :accountable, polymorphic: true, optional: true
|
|
12
|
+
|
|
13
|
+
validates :tracking_id, presence: true, uniqueness: true
|
|
14
|
+
validates :status, inclusion: { in: STATUSES }
|
|
15
|
+
|
|
16
|
+
scope :by_status, ->(status) { where(status: status) }
|
|
17
|
+
scope :by_mailer, ->(mailer_class) { where(mailer_class: mailer_class) }
|
|
18
|
+
scope :recent, -> { order(created_at: :desc) }
|
|
19
|
+
|
|
20
|
+
# Scopes for filtering
|
|
21
|
+
scope :recipient, ->(email) { where('? = ANY(to_addresses)', email) }
|
|
22
|
+
scope :sender, ->(val) { where('from_address ILIKE ?', "%#{val}%") }
|
|
23
|
+
scope :subject_search, ->(val) { where('subject ILIKE ?', "%#{val}%") }
|
|
24
|
+
scope :mailer, ->(val) { where(mailer_class: val) }
|
|
25
|
+
scope :date_from, ->(date) { where('created_at >= ?', date.to_date.beginning_of_day) }
|
|
26
|
+
scope :date_to, ->(date) { where('created_at <= ?', date.to_date.end_of_day) }
|
|
27
|
+
|
|
28
|
+
def update_status_from_event!(event_type)
|
|
29
|
+
case event_type
|
|
30
|
+
when 'delivered'
|
|
31
|
+
update!(status: 'delivered', delivered_at: Time.current) unless opened_at? || clicked_at?
|
|
32
|
+
when 'opened'
|
|
33
|
+
update!(status: 'opened', opened_at: Time.current) unless clicked_at?
|
|
34
|
+
when 'clicked'
|
|
35
|
+
update!(status: 'clicked', clicked_at: Time.current)
|
|
36
|
+
when 'bounced', 'failed', 'dropped'
|
|
37
|
+
update!(status: 'bounced', bounced_at: Time.current)
|
|
38
|
+
when 'complained'
|
|
39
|
+
update!(status: 'complained')
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def recipients
|
|
44
|
+
(to_addresses + cc_addresses + bcc_addresses).compact.uniq
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MailerLog
|
|
4
|
+
class Event < ApplicationRecord
|
|
5
|
+
self.table_name = 'mailer_log_events'
|
|
6
|
+
|
|
7
|
+
EVENT_TYPES = %w[
|
|
8
|
+
accepted
|
|
9
|
+
delivered
|
|
10
|
+
opened
|
|
11
|
+
clicked
|
|
12
|
+
bounced
|
|
13
|
+
failed
|
|
14
|
+
dropped
|
|
15
|
+
complained
|
|
16
|
+
unsubscribed
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
belongs_to :email, class_name: 'MailerLog::Email', inverse_of: :events
|
|
20
|
+
|
|
21
|
+
validates :event_type, presence: true, inclusion: { in: EVENT_TYPES }
|
|
22
|
+
validates :mailgun_event_id, uniqueness: true, allow_nil: true
|
|
23
|
+
|
|
24
|
+
scope :by_type, ->(type) { where(event_type: type) }
|
|
25
|
+
scope :recent, -> { order(occurred_at: :desc) }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<tr>
|
|
2
|
+
<td class="text-nowrap">
|
|
3
|
+
<%= email.created_at.strftime('%Y-%m-%d %H:%M') %>
|
|
4
|
+
</td>
|
|
5
|
+
<td>
|
|
6
|
+
<small class="text-muted"><%= email.mailer_class %></small><br>
|
|
7
|
+
<code class="small"><%= email.mailer_action %></code>
|
|
8
|
+
</td>
|
|
9
|
+
<td class="text-truncate" style="max-width: 200px;">
|
|
10
|
+
<%= email.to_addresses.join(', ') %>
|
|
11
|
+
</td>
|
|
12
|
+
<td class="text-truncate" style="max-width: 250px;">
|
|
13
|
+
<%= link_to email.subject.presence || '(no subject)', mailer_log_engine.admin_email_path(email) %>
|
|
14
|
+
</td>
|
|
15
|
+
<td>
|
|
16
|
+
<% status_class = case email.status
|
|
17
|
+
when 'delivered', 'opened', 'clicked' then 'success'
|
|
18
|
+
when 'bounced', 'complained' then 'danger'
|
|
19
|
+
when 'sent' then 'info'
|
|
20
|
+
else 'secondary'
|
|
21
|
+
end %>
|
|
22
|
+
<span class="badge badge-<%= status_class %>"><%= email.status %></span>
|
|
23
|
+
</td>
|
|
24
|
+
<td class="text-right">
|
|
25
|
+
<%= link_to mailer_log_engine.admin_email_path(email), class: 'btn btn-sm btn-outline-primary' do %>
|
|
26
|
+
<i class="far fa-eye"></i>
|
|
27
|
+
<% end %>
|
|
28
|
+
</td>
|
|
29
|
+
</tr>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<%= form_tag mailer_log_engine.admin_emails_path, method: :get, class: 'mb-3' do %>
|
|
2
|
+
<div class="row">
|
|
3
|
+
<div class="col-md-2">
|
|
4
|
+
<div class="form-group">
|
|
5
|
+
<label class="small text-muted">Recipient</label>
|
|
6
|
+
<%= text_field_tag :recipient, params[:recipient], class: 'form-control form-control-sm', placeholder: 'Email address' %>
|
|
7
|
+
</div>
|
|
8
|
+
</div>
|
|
9
|
+
<div class="col-md-2">
|
|
10
|
+
<div class="form-group">
|
|
11
|
+
<label class="small text-muted">Sender</label>
|
|
12
|
+
<%= text_field_tag :sender, params[:sender], class: 'form-control form-control-sm', placeholder: 'From address' %>
|
|
13
|
+
</div>
|
|
14
|
+
</div>
|
|
15
|
+
<div class="col-md-2">
|
|
16
|
+
<div class="form-group">
|
|
17
|
+
<label class="small text-muted">Subject</label>
|
|
18
|
+
<%= text_field_tag :subject_search, params[:subject_search], class: 'form-control form-control-sm', placeholder: 'Subject contains...' %>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="col-md-2">
|
|
22
|
+
<div class="form-group">
|
|
23
|
+
<label class="small text-muted">Mailer</label>
|
|
24
|
+
<%= select_tag :mailer, options_for_select([['All', '']] + @mailers.map { |m| [m, m] }, params[:mailer]), class: 'form-control form-control-sm' %>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
<div class="col-md-2">
|
|
28
|
+
<div class="form-group">
|
|
29
|
+
<label class="small text-muted">Status</label>
|
|
30
|
+
<%= select_tag :status, options_for_select([['All', ''], ['Pending', 'pending'], ['Sent', 'sent'], ['Delivered', 'delivered'], ['Opened', 'opened'], ['Clicked', 'clicked'], ['Bounced', 'bounced'], ['Complained', 'complained']], params[:status]), class: 'form-control form-control-sm' %>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
<div class="col-md-2 d-flex align-items-end">
|
|
34
|
+
<div class="form-group w-100">
|
|
35
|
+
<%= submit_tag 'Filter', class: 'btn btn-primary btn-sm w-100' %>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
<div class="row">
|
|
40
|
+
<div class="col-md-2">
|
|
41
|
+
<div class="form-group">
|
|
42
|
+
<label class="small text-muted">Date From</label>
|
|
43
|
+
<%= date_field_tag :date_from, params[:date_from], class: 'form-control form-control-sm' %>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
<div class="col-md-2">
|
|
47
|
+
<div class="form-group">
|
|
48
|
+
<label class="small text-muted">Date To</label>
|
|
49
|
+
<%= date_field_tag :date_to, params[:date_to], class: 'form-control form-control-sm' %>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
<div class="col-md-2 d-flex align-items-end">
|
|
53
|
+
<div class="form-group">
|
|
54
|
+
<%= link_to 'Clear', mailer_log_engine.admin_emails_path, class: 'btn btn-outline-secondary btn-sm' %>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
<% end %>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<% set_meta_tags title: ['Admin', 'Email Log'] if respond_to?(:set_meta_tags) %>
|
|
2
|
+
|
|
3
|
+
<div class="card card-border-color card-border-color-dark card-default card-table">
|
|
4
|
+
<div class="card-header">
|
|
5
|
+
<span class="title p-a-1">Email Log (<%= @emails.total_count %>)</span>
|
|
6
|
+
</div>
|
|
7
|
+
<div class="card-body">
|
|
8
|
+
<%= render 'filters' %>
|
|
9
|
+
|
|
10
|
+
<div class="table-responsive noSwipe">
|
|
11
|
+
<table class="table table-striped table-hover">
|
|
12
|
+
<thead>
|
|
13
|
+
<tr>
|
|
14
|
+
<th>Sent At</th>
|
|
15
|
+
<th>Mailer</th>
|
|
16
|
+
<th>To</th>
|
|
17
|
+
<th>Subject</th>
|
|
18
|
+
<th>Status</th>
|
|
19
|
+
<th></th>
|
|
20
|
+
</tr>
|
|
21
|
+
</thead>
|
|
22
|
+
<tbody>
|
|
23
|
+
<% @emails.each do |email| %>
|
|
24
|
+
<%= render 'email', email: email %>
|
|
25
|
+
<% end %>
|
|
26
|
+
</tbody>
|
|
27
|
+
</table>
|
|
28
|
+
|
|
29
|
+
<% if @emails.empty? %>
|
|
30
|
+
<div class="text-center text-muted py-4">
|
|
31
|
+
No emails found
|
|
32
|
+
</div>
|
|
33
|
+
<% end %>
|
|
34
|
+
|
|
35
|
+
<% # Use engine routes for pagination %>
|
|
36
|
+
<div class="pagination-wrapper">
|
|
37
|
+
<% if @emails.total_pages > 1 %>
|
|
38
|
+
<nav aria-label="Pagination">
|
|
39
|
+
<ul class="pagination justify-content-center">
|
|
40
|
+
<% if @emails.first_page? %>
|
|
41
|
+
<li class="page-item disabled"><span class="page-link">« First</span></li>
|
|
42
|
+
<li class="page-item disabled"><span class="page-link">‹ Prev</span></li>
|
|
43
|
+
<% else %>
|
|
44
|
+
<li class="page-item"><%= link_to '« First'.html_safe, mailer_log_engine.admin_emails_path(page: 1, per: params[:per]), class: 'page-link' %></li>
|
|
45
|
+
<li class="page-item"><%= link_to '‹ Prev'.html_safe, mailer_log_engine.admin_emails_path(page: @emails.prev_page, per: params[:per]), class: 'page-link' %></li>
|
|
46
|
+
<% end %>
|
|
47
|
+
<li class="page-item active"><span class="page-link">Page <%= @emails.current_page %> of <%= @emails.total_pages %></span></li>
|
|
48
|
+
<% if @emails.last_page? %>
|
|
49
|
+
<li class="page-item disabled"><span class="page-link">Next ›</span></li>
|
|
50
|
+
<li class="page-item disabled"><span class="page-link">Last »</span></li>
|
|
51
|
+
<% else %>
|
|
52
|
+
<li class="page-item"><%= link_to 'Next ›'.html_safe, mailer_log_engine.admin_emails_path(page: @emails.next_page, per: params[:per]), class: 'page-link' %></li>
|
|
53
|
+
<li class="page-item"><%= link_to 'Last »'.html_safe, mailer_log_engine.admin_emails_path(page: @emails.total_pages, per: params[:per]), class: 'page-link' %></li>
|
|
54
|
+
<% end %>
|
|
55
|
+
</ul>
|
|
56
|
+
</nav>
|
|
57
|
+
<% end %>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
<% set_meta_tags title: ['Admin', 'Email Log', @email.subject.presence || 'Email Details'] if respond_to?(:set_meta_tags) %>
|
|
2
|
+
|
|
3
|
+
<div class="mb-3">
|
|
4
|
+
<%= link_to mailer_log_engine.admin_emails_path, class: 'btn btn-outline-secondary btn-sm' do %>
|
|
5
|
+
<i class="far fa-arrow-left"></i> Back to Email Log
|
|
6
|
+
<% end %>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<div class="card card-border-color card-border-color-dark card-default">
|
|
10
|
+
<div class="card-header">
|
|
11
|
+
<span class="title"><%= @email.subject.presence || '(no subject)' %></span>
|
|
12
|
+
<% status_class = case @email.status
|
|
13
|
+
when 'delivered', 'opened', 'clicked' then 'success'
|
|
14
|
+
when 'bounced', 'complained' then 'danger'
|
|
15
|
+
when 'sent' then 'info'
|
|
16
|
+
else 'secondary'
|
|
17
|
+
end %>
|
|
18
|
+
<span class="badge badge-<%= status_class %> float-right"><%= @email.status %></span>
|
|
19
|
+
</div>
|
|
20
|
+
<div class="card-body">
|
|
21
|
+
<div class="row mb-4">
|
|
22
|
+
<div class="col-md-6">
|
|
23
|
+
<dl class="row">
|
|
24
|
+
<dt class="col-sm-3">From</dt>
|
|
25
|
+
<dd class="col-sm-9"><code><%= @email.from_address %></code></dd>
|
|
26
|
+
|
|
27
|
+
<dt class="col-sm-3">To</dt>
|
|
28
|
+
<dd class="col-sm-9"><code><%= @email.to_addresses.join(', ') %></code></dd>
|
|
29
|
+
|
|
30
|
+
<% if @email.cc_addresses.present? %>
|
|
31
|
+
<dt class="col-sm-3">CC</dt>
|
|
32
|
+
<dd class="col-sm-9"><code><%= @email.cc_addresses.join(', ') %></code></dd>
|
|
33
|
+
<% end %>
|
|
34
|
+
|
|
35
|
+
<% if @email.bcc_addresses.present? %>
|
|
36
|
+
<dt class="col-sm-3">BCC</dt>
|
|
37
|
+
<dd class="col-sm-9"><code><%= @email.bcc_addresses.join(', ') %></code></dd>
|
|
38
|
+
<% end %>
|
|
39
|
+
|
|
40
|
+
<dt class="col-sm-3">Mailer</dt>
|
|
41
|
+
<dd class="col-sm-9">
|
|
42
|
+
<code><%= @email.mailer_class %>#<%= @email.mailer_action %></code>
|
|
43
|
+
</dd>
|
|
44
|
+
|
|
45
|
+
<dt class="col-sm-3">Message ID</dt>
|
|
46
|
+
<dd class="col-sm-9">
|
|
47
|
+
<code class="small"><%= @email.message_id %></code>
|
|
48
|
+
</dd>
|
|
49
|
+
|
|
50
|
+
<dt class="col-sm-3">Sent At</dt>
|
|
51
|
+
<dd class="col-sm-9"><%= @email.created_at.strftime('%Y-%m-%d %H:%M:%S %Z') %></dd>
|
|
52
|
+
|
|
53
|
+
<% if @email.delivered_at %>
|
|
54
|
+
<dt class="col-sm-3">Delivered</dt>
|
|
55
|
+
<dd class="col-sm-9"><%= @email.delivered_at.strftime('%Y-%m-%d %H:%M:%S %Z') %></dd>
|
|
56
|
+
<% end %>
|
|
57
|
+
|
|
58
|
+
<% if @email.opened_at %>
|
|
59
|
+
<dt class="col-sm-3">Opened</dt>
|
|
60
|
+
<dd class="col-sm-9"><%= @email.opened_at.strftime('%Y-%m-%d %H:%M:%S %Z') %></dd>
|
|
61
|
+
<% end %>
|
|
62
|
+
|
|
63
|
+
<% if @email.clicked_at %>
|
|
64
|
+
<dt class="col-sm-3">Clicked</dt>
|
|
65
|
+
<dd class="col-sm-9"><%= @email.clicked_at.strftime('%Y-%m-%d %H:%M:%S %Z') %></dd>
|
|
66
|
+
<% end %>
|
|
67
|
+
|
|
68
|
+
<% if @email.domain.present? %>
|
|
69
|
+
<dt class="col-sm-3">Domain</dt>
|
|
70
|
+
<dd class="col-sm-9"><code><%= @email.domain %></code></dd>
|
|
71
|
+
<% end %>
|
|
72
|
+
</dl>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div class="col-md-6">
|
|
76
|
+
<h6>Delivery Events</h6>
|
|
77
|
+
<% if @email.events.any? %>
|
|
78
|
+
<table class="table table-sm table-bordered">
|
|
79
|
+
<thead class="thead-light">
|
|
80
|
+
<tr>
|
|
81
|
+
<th>Event</th>
|
|
82
|
+
<th>Time</th>
|
|
83
|
+
<th>Recipient</th>
|
|
84
|
+
</tr>
|
|
85
|
+
</thead>
|
|
86
|
+
<tbody>
|
|
87
|
+
<% @email.events.recent.each do |event| %>
|
|
88
|
+
<tr>
|
|
89
|
+
<td>
|
|
90
|
+
<span class="badge badge-<%= event.event_type == 'delivered' ? 'success' : 'secondary' %>">
|
|
91
|
+
<%= event.event_type %>
|
|
92
|
+
</span>
|
|
93
|
+
</td>
|
|
94
|
+
<td class="small"><%= event.occurred_at&.strftime('%H:%M:%S') %></td>
|
|
95
|
+
<td class="small"><%= event.recipient %></td>
|
|
96
|
+
</tr>
|
|
97
|
+
<% end %>
|
|
98
|
+
</tbody>
|
|
99
|
+
</table>
|
|
100
|
+
<% else %>
|
|
101
|
+
<p class="text-muted small">No delivery events recorded yet.</p>
|
|
102
|
+
<% end %>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<h5>Email Preview</h5>
|
|
107
|
+
<% if @email.html_body.present? %>
|
|
108
|
+
<iframe src="<%= mailer_log_engine.preview_admin_email_path(@email) %>"
|
|
109
|
+
class="mailer-log-email-preview"></iframe>
|
|
110
|
+
<% elsif @email.text_body.present? %>
|
|
111
|
+
<pre class="bg-light p-3 mailer-log-scrollable-content"><%= @email.text_body %></pre>
|
|
112
|
+
<% else %>
|
|
113
|
+
<p class="text-muted">No email body available.</p>
|
|
114
|
+
<% end %>
|
|
115
|
+
|
|
116
|
+
<% if @email.call_stack.present? %>
|
|
117
|
+
<h5 class="mt-4">Call Stack</h5>
|
|
118
|
+
<details>
|
|
119
|
+
<summary class="btn btn-sm btn-outline-secondary mb-2">Show call stack</summary>
|
|
120
|
+
<pre class="bg-light p-3 small mailer-log-scrollable-content-sm"><%= @email.call_stack %></pre>
|
|
121
|
+
</details>
|
|
122
|
+
<% end %>
|
|
123
|
+
|
|
124
|
+
<% if @email.headers.present? %>
|
|
125
|
+
<h5 class="mt-4">Headers</h5>
|
|
126
|
+
<details>
|
|
127
|
+
<summary class="btn btn-sm btn-outline-secondary mb-2">Show headers</summary>
|
|
128
|
+
<pre class="bg-light p-3 small mailer-log-scrollable-content-sm"><%= JSON.pretty_generate(@email.headers) %></pre>
|
|
129
|
+
</details>
|
|
130
|
+
<% end %>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
@@ -0,0 +1,21 @@
|
|
|
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.0">
|
|
6
|
+
<title>Email Log</title>
|
|
7
|
+
<% if (css = mailer_log_css_entry) %>
|
|
8
|
+
<link rel="stylesheet" href="/admin/email_log/<%= css %>">
|
|
9
|
+
<% else %>
|
|
10
|
+
<script type="module" src="http://localhost:5173/@vite/client"></script>
|
|
11
|
+
<% end %>
|
|
12
|
+
</head>
|
|
13
|
+
<body class="bg-gray-100 min-h-screen">
|
|
14
|
+
<div id="app"></div>
|
|
15
|
+
<% if (js = mailer_log_js_entry) %>
|
|
16
|
+
<script type="module" src="/admin/email_log/<%= js %>"></script>
|
|
17
|
+
<% else %>
|
|
18
|
+
<script type="module" src="http://localhost:5173/admin/email_log/assets/src/main.js"></script>
|
|
19
|
+
<% end %>
|
|
20
|
+
</body>
|
|
21
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
MailerLog::Engine.routes.draw do
|
|
4
|
+
# Webhook endpoint for email providers
|
|
5
|
+
post 'webhooks/mailgun', to: 'webhooks#mailgun'
|
|
6
|
+
|
|
7
|
+
# JSON API for Vue frontend
|
|
8
|
+
namespace :api do
|
|
9
|
+
resources :emails, only: %i[index show]
|
|
10
|
+
resources :mailers, only: :index
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Static assets for Vue SPA
|
|
14
|
+
get 'assets/*path', to: 'assets#show', as: :asset, format: false
|
|
15
|
+
|
|
16
|
+
# Legacy Admin UI (ERB-based) - keep for email preview iframe
|
|
17
|
+
namespace :admin do
|
|
18
|
+
resources :emails, only: %i[index show] do
|
|
19
|
+
get :preview, on: :member
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# SPA catch-all - serves Vue app for all other routes
|
|
24
|
+
get '/', to: 'spa#index'
|
|
25
|
+
get '/*path', to: 'spa#index', constraints: ->(req) { !req.path.start_with?('/api', '/admin', '/webhooks', '/assets') }
|
|
26
|
+
end
|
data/db/schema.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# This file is auto-generated from the current state of the database. Instead
|
|
2
|
+
# of editing this file, please use the migrations feature of Active Record to
|
|
3
|
+
# incrementally modify your database, and then regenerate this schema definition.
|
|
4
|
+
#
|
|
5
|
+
# This file is the source Rails uses to define your schema when running `bin/rails
|
|
6
|
+
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
|
|
7
|
+
# be faster and is potentially less error prone than running all of your
|
|
8
|
+
# migrations from scratch. Old migrations may fail to apply correctly if those
|
|
9
|
+
# migrations use external dependencies or application code.
|
|
10
|
+
#
|
|
11
|
+
# It's strongly recommended that you check this file into your version control system.
|
|
12
|
+
|
|
13
|
+
ActiveRecord::Schema[7.2].define(version: 0) do
|
|
14
|
+
# These are extensions that must be enabled in order to support this database
|
|
15
|
+
enable_extension "plpgsql"
|
|
16
|
+
|
|
17
|
+
end
|