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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +22 -0
  3. data/LICENSE +21 -0
  4. data/README.md +286 -0
  5. data/app/assets/stylesheets/mailer_log/emails.scss +18 -0
  6. data/app/controllers/mailer_log/admin/emails_controller.rb +34 -0
  7. data/app/controllers/mailer_log/admin_controller.rb +37 -0
  8. data/app/controllers/mailer_log/api/emails_controller.rb +88 -0
  9. data/app/controllers/mailer_log/api/mailers_controller.rb +15 -0
  10. data/app/controllers/mailer_log/application_controller.rb +7 -0
  11. data/app/controllers/mailer_log/assets_controller.rb +42 -0
  12. data/app/controllers/mailer_log/spa_controller.rb +13 -0
  13. data/app/controllers/mailer_log/webhooks_controller.rb +115 -0
  14. data/app/helpers/mailer_log/spa_helper.rb +48 -0
  15. data/app/jobs/mailer_log/application_job.rb +12 -0
  16. data/app/jobs/mailer_log/cleanup_job.rb +16 -0
  17. data/app/models/mailer_log/application_record.rb +7 -0
  18. data/app/models/mailer_log/current.rb +10 -0
  19. data/app/models/mailer_log/email.rb +47 -0
  20. data/app/models/mailer_log/event.rb +27 -0
  21. data/app/views/mailer_log/admin/emails/_email.html.erb +29 -0
  22. data/app/views/mailer_log/admin/emails/_filters.html.erb +58 -0
  23. data/app/views/mailer_log/admin/emails/index.html.erb +61 -0
  24. data/app/views/mailer_log/admin/emails/show.html.erb +132 -0
  25. data/app/views/mailer_log/spa/index.html.erb +21 -0
  26. data/config/locales/en.yml +5 -0
  27. data/config/routes.rb +26 -0
  28. data/db/schema.rb +17 -0
  29. data/lib/generators/mailer_log/install/install_generator.rb +33 -0
  30. data/lib/generators/mailer_log/install/templates/README +35 -0
  31. data/lib/generators/mailer_log/install/templates/create_mailer_log_tables.rb.tt +52 -0
  32. data/lib/generators/mailer_log/install/templates/initializer.rb.tt +33 -0
  33. data/lib/mailer_log/configuration.rb +33 -0
  34. data/lib/mailer_log/engine.rb +28 -0
  35. data/lib/mailer_log/mail_interceptor.rb +97 -0
  36. data/lib/mailer_log/mail_observer.rb +45 -0
  37. data/lib/mailer_log/version.rb +5 -0
  38. data/lib/mailer_log.rb +24 -0
  39. data/lib/tasks/mailer_log.rake +41 -0
  40. data/public/mailer_log/.vite/manifest.json +11 -0
  41. data/public/mailer_log/assets/index-D_66gvIL.css +1 -0
  42. data/public/mailer_log/assets/mailer_log-2Waj6tsV.js +46 -0
  43. data/public/mailer_log/index.html +13 -0
  44. 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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailerLog
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ 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">&laquo; First</span></li>
42
+ <li class="page-item disabled"><span class="page-link">&lsaquo; Prev</span></li>
43
+ <% else %>
44
+ <li class="page-item"><%= link_to '&laquo; 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 '&lsaquo; 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 &rsaquo;</span></li>
50
+ <li class="page-item disabled"><span class="page-link">Last &raquo;</span></li>
51
+ <% else %>
52
+ <li class="page-item"><%= link_to 'Next &rsaquo;'.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 &raquo;'.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>
@@ -0,0 +1,5 @@
1
+ en:
2
+ application:
3
+ nav:
4
+ titles:
5
+ emails: Email Log
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