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,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/migration'
5
+
6
+ module MailerLog
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include Rails::Generators::Migration
10
+
11
+ source_root File.expand_path('templates', __dir__)
12
+
13
+ desc 'Creates MailerLog initializer and copies migrations to your application.'
14
+
15
+ def self.next_migration_number(dirname)
16
+ next_migration_number = current_migration_number(dirname) + 1
17
+ ActiveRecord::Migration.next_migration_number(next_migration_number)
18
+ end
19
+
20
+ def copy_initializer
21
+ template 'initializer.rb', 'config/initializers/mailer_log.rb'
22
+ end
23
+
24
+ def copy_migrations
25
+ migration_template 'create_mailer_log_tables.rb', 'db/migrate/create_mailer_log_tables.rb'
26
+ end
27
+
28
+ def show_readme
29
+ readme 'README' if behavior == :invoke
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,35 @@
1
+
2
+ ===============================================================================
3
+
4
+ MailerLog has been installed successfully!
5
+
6
+ Next steps:
7
+
8
+ 1. Run migrations:
9
+
10
+ $ rails db:migrate
11
+
12
+ 2. Add routes to config/routes.rb:
13
+
14
+ Rails.application.routes.draw do
15
+ mount MailerLog::Engine, at: '/mailer_log'
16
+ end
17
+
18
+ 3. Configure Mailgun webhooks (optional, for delivery tracking):
19
+
20
+ - Go to Mailgun Dashboard -> Sending -> Webhooks
21
+ - Add webhook URL: https://your-app.com/mailer_log/webhooks/mailgun
22
+ - Select events: delivered, opened, clicked, bounced, failed, complained
23
+ - Copy webhook signing key to ENV['MAILGUN_WEBHOOK_SIGNING_KEY']
24
+
25
+ 4. Schedule cleanup job (optional):
26
+
27
+ # In sidekiq-cron config:
28
+ cleanup_mailer_log:
29
+ cron: '0 3 * * *'
30
+ class: MailerLog::CleanupJob
31
+
32
+ 5. Access admin UI at: /mailer_log/admin/emails
33
+
34
+ ===============================================================================
35
+
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateMailerLogTables < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
+ def change
5
+ create_table :mailer_log_emails do |t|
6
+ t.string :message_id
7
+ t.uuid :tracking_id, null: false
8
+ t.string :mailer_class
9
+ t.string :mailer_action
10
+ t.string :from_address
11
+ t.string :to_addresses, array: true, default: []
12
+ t.string :cc_addresses, array: true, default: []
13
+ t.string :bcc_addresses, array: true, default: []
14
+ t.string :subject
15
+ t.text :html_body
16
+ t.text :text_body
17
+ t.jsonb :headers, default: {}
18
+ t.text :call_stack
19
+ t.string :domain
20
+ t.references :accountable, polymorphic: true, index: true
21
+ t.string :status, null: false, default: 'pending'
22
+ t.datetime :delivered_at
23
+ t.datetime :opened_at
24
+ t.datetime :clicked_at
25
+ t.datetime :bounced_at
26
+ t.timestamps
27
+ end
28
+
29
+ add_index :mailer_log_emails, :tracking_id, unique: true
30
+ add_index :mailer_log_emails, :message_id, unique: true
31
+ add_index :mailer_log_emails, :mailer_class
32
+ add_index :mailer_log_emails, :status
33
+ add_index :mailer_log_emails, :to_addresses, using: :gin
34
+ add_index :mailer_log_emails, :created_at
35
+
36
+ create_table :mailer_log_events do |t|
37
+ t.references :email, null: false, foreign_key: { to_table: :mailer_log_emails }
38
+ t.string :event_type, null: false
39
+ t.string :mailgun_event_id
40
+ t.datetime :occurred_at
41
+ t.string :recipient
42
+ t.string :ip_address
43
+ t.string :user_agent
44
+ t.jsonb :raw_data, default: {}
45
+ t.timestamps
46
+ end
47
+
48
+ add_index :mailer_log_events, :mailgun_event_id, unique: true
49
+ add_index :mailer_log_events, :event_type
50
+ add_index :mailer_log_events, :occurred_at
51
+ end
52
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ MailerLog.configure do |config|
4
+ # Email retention period (default: 1 year)
5
+ # Emails older than this will be deleted by CleanupJob
6
+ config.retention_period = 1.year
7
+
8
+ # Mailgun webhook signing key for signature verification
9
+ # Get it from: Mailgun Dashboard -> Sending -> Webhooks -> HTTP webhook signing key
10
+ config.webhook_signing_key = ENV['MAILGUN_WEBHOOK_SIGNING_KEY']
11
+
12
+ # Capture call stack to see where emails were triggered from (default: true)
13
+ config.capture_call_stack = Rails.env.development? || Rails.env.staging?
14
+ config.call_stack_depth = 20
15
+
16
+ # Layout for admin views (default: 'application')
17
+ config.admin_layout = 'application'
18
+
19
+ # Records per page in admin UI (default: 25)
20
+ config.per_page = 25
21
+
22
+ # Optional: Additional authentication for admin UI
23
+ # By default, engine inherits from your AdminsController
24
+ # config.authenticate_with do |controller|
25
+ # controller.send(:require_super_admin!)
26
+ # end
27
+
28
+ # Optional: Resolver for accountable association (e.g., Organization)
29
+ # config.resolve_accountable do |email_record|
30
+ # user = User.find_by(email: email_record.to_addresses&.first)
31
+ # user&.organization
32
+ # end
33
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailerLog
4
+ class Configuration
5
+ attr_accessor :retention_period,
6
+ :webhook_signing_key,
7
+ :capture_call_stack,
8
+ :call_stack_depth,
9
+ :admin_layout,
10
+ :per_page
11
+
12
+ attr_reader :authenticate_with_proc, :resolve_accountable_proc
13
+
14
+ def initialize
15
+ @retention_period = 1.year
16
+ @webhook_signing_key = nil
17
+ @capture_call_stack = true
18
+ @call_stack_depth = 20
19
+ @admin_layout = 'application'
20
+ @per_page = 25
21
+ @authenticate_with_proc = nil
22
+ @resolve_accountable_proc = nil
23
+ end
24
+
25
+ def authenticate_with(&block)
26
+ @authenticate_with_proc = block
27
+ end
28
+
29
+ def resolve_accountable(&block)
30
+ @resolve_accountable_proc = block
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mailer_log/mail_interceptor'
4
+ require 'mailer_log/mail_observer'
5
+
6
+ module MailerLog
7
+ class Engine < ::Rails::Engine
8
+ isolate_namespace MailerLog
9
+
10
+ initializer 'mailer_log.action_mailer' do
11
+ ActiveSupport.on_load(:action_mailer) do
12
+ ActionMailer::Base.register_interceptor(MailerLog::MailInterceptor)
13
+ ActionMailer::Base.register_observer(MailerLog::MailObserver)
14
+ end
15
+ end
16
+
17
+ config.generators do |g|
18
+ g.test_framework :rspec
19
+ g.fixture_replacement :factory_bot
20
+ g.factory_bot dir: 'spec/factories'
21
+ end
22
+
23
+ # Load rake tasks
24
+ rake_tasks do
25
+ load MailerLog::Engine.root.join('lib', 'tasks', 'mailer_log.rake')
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailerLog
4
+ class MailInterceptor
5
+ class << self
6
+ def delivering_email(message)
7
+ tracking_id = SecureRandom.uuid
8
+
9
+ # Add tracking header for Mailgun webhook correlation
10
+ message.header['X-Mailer-Log-Tracking-ID'] = tracking_id
11
+
12
+ # Store data for observer to pick up after delivery
13
+ MailerLog::Current.email_data = {
14
+ tracking_id: tracking_id,
15
+ mailer_class: extract_mailer_class(message),
16
+ mailer_action: extract_mailer_action(message),
17
+ from_address: message.from&.first,
18
+ to_addresses: Array(message.to),
19
+ cc_addresses: Array(message.cc),
20
+ bcc_addresses: Array(message.bcc),
21
+ subject: message.subject,
22
+ html_body: extract_html_body(message),
23
+ text_body: extract_text_body(message),
24
+ headers: extract_headers(message),
25
+ call_stack: capture_call_stack,
26
+ domain: message[:domain]&.value
27
+ }
28
+ rescue StandardError => e
29
+ Rails.logger.error("MailerLog::MailInterceptor error: #{e.message}")
30
+ Rails.logger.error(e.backtrace.first(5).join("\n"))
31
+ end
32
+
33
+ private
34
+
35
+ def extract_mailer_class(message)
36
+ handler = message.delivery_handler
37
+ return message['X-Mailer']&.to_s || 'Unknown' unless handler
38
+
39
+ handler.is_a?(Class) ? handler.name : handler.class.name
40
+ end
41
+
42
+ def extract_mailer_action(message)
43
+ handler = message.delivery_handler
44
+ return 'unknown' unless handler
45
+
46
+ if handler.respond_to?(:action_name)
47
+ handler.action_name.to_s
48
+ elsif message['X-Mailer-Action']
49
+ message['X-Mailer-Action'].to_s
50
+ else
51
+ 'unknown'
52
+ end
53
+ end
54
+
55
+ def extract_html_body(message)
56
+ if message.multipart?
57
+ message.html_part&.body&.decoded
58
+ elsif message.content_type&.include?('text/html')
59
+ message.body.decoded
60
+ end
61
+ rescue StandardError => e
62
+ Rails.logger.warn("MailerLog: Failed to extract HTML body: #{e.message}")
63
+ nil
64
+ end
65
+
66
+ def extract_text_body(message)
67
+ if message.multipart?
68
+ message.text_part&.body&.decoded
69
+ elsif message.content_type.nil? || message.content_type.include?('text/plain')
70
+ message.body.decoded
71
+ end
72
+ rescue StandardError => e
73
+ Rails.logger.warn("MailerLog: Failed to extract text body: #{e.message}")
74
+ nil
75
+ end
76
+
77
+ def extract_headers(message)
78
+ message.header.fields.each_with_object({}) do |field, hash|
79
+ hash[field.name] = field.value
80
+ end
81
+ rescue StandardError => e
82
+ Rails.logger.warn("MailerLog: Failed to extract headers: #{e.message}")
83
+ {}
84
+ end
85
+
86
+ def capture_call_stack
87
+ return nil unless MailerLog.configuration.capture_call_stack
88
+
89
+ depth = MailerLog.configuration.call_stack_depth || 20
90
+ caller.select { |line| line.include?(Rails.root.to_s) }
91
+ .reject { |line| line.include?('mailer_log') }
92
+ .first(depth)
93
+ .join("\n")
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailerLog
4
+ class MailObserver
5
+ class << self
6
+ def delivered_email(message)
7
+ data = MailerLog::Current.email_data
8
+ return unless data
9
+
10
+ email = MailerLog::Email.new(
11
+ data.merge(
12
+ message_id: message.message_id,
13
+ status: 'sent'
14
+ )
15
+ )
16
+
17
+ # Resolve accountable via configured resolver
18
+ resolve_accountable(email)
19
+
20
+ email.save!
21
+ rescue StandardError => e
22
+ Rails.logger.error("MailerLog::MailObserver error: #{e.message}")
23
+ Rails.logger.error(e.backtrace.first(5).join("\n"))
24
+ report_error(e)
25
+ ensure
26
+ MailerLog::Current.reset
27
+ end
28
+
29
+ private
30
+
31
+ def resolve_accountable(email)
32
+ resolver = MailerLog.configuration.resolve_accountable_proc
33
+ return unless resolver
34
+
35
+ email.accountable = resolver.call(email)
36
+ rescue StandardError => e
37
+ Rails.logger.warn("MailerLog: Failed to resolve accountable: #{e.message}")
38
+ end
39
+
40
+ def report_error(exception)
41
+ Airbrake.notify(exception) if defined?(Airbrake)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MailerLog
4
+ VERSION = '0.1.0'
5
+ end
data/lib/mailer_log.rb ADDED
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'has_scope'
4
+ require 'kaminari'
5
+
6
+ require 'mailer_log/version'
7
+ require 'mailer_log/configuration'
8
+ require 'mailer_log/engine'
9
+
10
+ module MailerLog
11
+ class << self
12
+ def configuration
13
+ @configuration ||= Configuration.new
14
+ end
15
+
16
+ def configure
17
+ yield(configuration)
18
+ end
19
+
20
+ def reset_configuration!
21
+ @configuration = Configuration.new
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :mailer_log do
4
+ desc 'Build MailerLog Vue.js frontend (set MAILER_LOG_OVERRIDES_PATH to customize components)'
5
+ task :build_frontend do
6
+ frontend_dir = MailerLog::Engine.root.join('frontend')
7
+ overrides_path = ENV['MAILER_LOG_OVERRIDES_PATH']
8
+
9
+ puts 'Installing npm dependencies...'
10
+ system('npm install', chdir: frontend_dir.to_s) || raise('npm install failed')
11
+
12
+ if overrides_path
13
+ puts "Building frontend with overrides from: #{overrides_path}"
14
+ else
15
+ puts 'Building frontend (no overrides)...'
16
+ puts 'Tip: Set MAILER_LOG_OVERRIDES_PATH to customize navbar and other components'
17
+ end
18
+
19
+ env = overrides_path ? { 'MAILER_LOG_OVERRIDES_PATH' => overrides_path } : {}
20
+ system(env, 'npm run build', chdir: frontend_dir.to_s) || raise('npm build failed')
21
+
22
+ puts 'Frontend built successfully!'
23
+ end
24
+
25
+ desc 'Start Vite development server for MailerLog frontend'
26
+ task :dev_server do
27
+ frontend_dir = MailerLog::Engine.root.join('frontend')
28
+ overrides_path = ENV['MAILER_LOG_OVERRIDES_PATH']
29
+
30
+ puts 'Starting Vite dev server...'
31
+ puts 'Frontend will be available at http://localhost:5173'
32
+ puts 'Make sure Rails is running on http://localhost:3000'
33
+
34
+ if overrides_path
35
+ puts "Using overrides from: #{overrides_path}"
36
+ end
37
+
38
+ env = overrides_path ? { 'MAILER_LOG_OVERRIDES_PATH' => overrides_path } : {}
39
+ exec(env, 'npm run dev', chdir: frontend_dir.to_s)
40
+ end
41
+ end
@@ -0,0 +1,11 @@
1
+ {
2
+ "index.html": {
3
+ "file": "assets/mailer_log-2Waj6tsV.js",
4
+ "name": "index",
5
+ "src": "index.html",
6
+ "isEntry": true,
7
+ "css": [
8
+ "assets/index-D_66gvIL.css"
9
+ ]
10
+ }
11
+ }
@@ -0,0 +1 @@
1
+ *,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width: 640px){.container{max-width:640px}}@media (min-width: 768px){.container{max-width:768px}}@media (min-width: 1024px){.container{max-width:1024px}}@media (min-width: 1280px){.container{max-width:1280px}}@media (min-width: 1536px){.container{max-width:1536px}}.collapse{visibility:collapse}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mt-0\.5{margin-top:.125rem}.block{display:block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-4{height:1rem}.h-5{height:1.25rem}.h-96{height:24rem}.max-h-64{max-height:16rem}.max-h-96{max-height:24rem}.w-24{width:6rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-full{width:100%}.min-w-full{min-width:100%}.max-w-md{max-width:28rem}.flex-shrink{flex-shrink:1}.border-collapse{border-collapse:collapse}.rotate-180{--tw-rotate: 180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-gray-200>:not([hidden])~:not([hidden]){--tw-divide-opacity: 1;border-color:rgb(229 231 235 / var(--tw-divide-opacity, 1))}.overflow-auto{overflow:auto}.overflow-x-auto{overflow-x:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-0{border-width:0px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-gray-200{--tw-border-opacity: 1;border-color:rgb(229 231 235 / var(--tw-border-opacity, 1))}.border-gray-300{--tw-border-opacity: 1;border-color:rgb(209 213 219 / var(--tw-border-opacity, 1))}.bg-blue-100{--tw-bg-opacity: 1;background-color:rgb(219 234 254 / var(--tw-bg-opacity, 1))}.bg-blue-600{--tw-bg-opacity: 1;background-color:rgb(37 99 235 / var(--tw-bg-opacity, 1))}.bg-gray-100{--tw-bg-opacity: 1;background-color:rgb(243 244 246 / var(--tw-bg-opacity, 1))}.bg-gray-50{--tw-bg-opacity: 1;background-color:rgb(249 250 251 / var(--tw-bg-opacity, 1))}.bg-green-100{--tw-bg-opacity: 1;background-color:rgb(220 252 231 / var(--tw-bg-opacity, 1))}.bg-red-100{--tw-bg-opacity: 1;background-color:rgb(254 226 226 / var(--tw-bg-opacity, 1))}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.p-4{padding:1rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.pb-4{padding-bottom:1rem}.text-left{text-align:left}.text-center{text-align:center}.text-2xl{font-size:1.5rem;line-height:2rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.uppercase{text-transform:uppercase}.tracking-wider{letter-spacing:.05em}.text-blue-600{--tw-text-opacity: 1;color:rgb(37 99 235 / var(--tw-text-opacity, 1))}.text-blue-800{--tw-text-opacity: 1;color:rgb(30 64 175 / var(--tw-text-opacity, 1))}.text-gray-400{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity, 1))}.text-gray-500{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity, 1))}.text-gray-600{--tw-text-opacity: 1;color:rgb(75 85 99 / var(--tw-text-opacity, 1))}.text-gray-700{--tw-text-opacity: 1;color:rgb(55 65 81 / var(--tw-text-opacity, 1))}.text-gray-800{--tw-text-opacity: 1;color:rgb(31 41 55 / var(--tw-text-opacity, 1))}.text-gray-900{--tw-text-opacity: 1;color:rgb(17 24 39 / var(--tw-text-opacity, 1))}.text-green-800{--tw-text-opacity: 1;color:rgb(22 101 52 / var(--tw-text-opacity, 1))}.text-red-500{--tw-text-opacity: 1;color:rgb(239 68 68 / var(--tw-text-opacity, 1))}.text-red-800{--tw-text-opacity: 1;color:rgb(153 27 27 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.underline{text-decoration-line:underline}.shadow{--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.mailer-log-app{min-height:100vh;--tw-bg-opacity: 1;background-color:rgb(249 250 251 / var(--tw-bg-opacity, 1))}.hover\:bg-blue-700:hover{--tw-bg-opacity: 1;background-color:rgb(29 78 216 / var(--tw-bg-opacity, 1))}.hover\:bg-gray-50:hover{--tw-bg-opacity: 1;background-color:rgb(249 250 251 / var(--tw-bg-opacity, 1))}.hover\:text-blue-600:hover{--tw-text-opacity: 1;color:rgb(37 99 235 / var(--tw-text-opacity, 1))}.hover\:text-blue-800:hover{--tw-text-opacity: 1;color:rgb(30 64 175 / var(--tw-text-opacity, 1))}.hover\:text-gray-700:hover{--tw-text-opacity: 1;color:rgb(55 65 81 / var(--tw-text-opacity, 1))}.focus\:border-blue-500:focus{--tw-border-opacity: 1;border-color:rgb(59 130 246 / var(--tw-border-opacity, 1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-blue-500:focus{--tw-ring-opacity: 1;--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity, 1))}.focus\:ring-offset-2:focus{--tw-ring-offset-width: 2px}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}@media (min-width: 640px){.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}}@media (min-width: 768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width: 1024px){.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.lg\:px-8{padding-left:2rem;padding-right:2rem}}.email-detail-panel-content[data-v-4ad871a7]{display:flex;flex-direction:column;height:100%}.panel-header[data-v-4ad871a7]{display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;padding:.75rem 1rem;border-bottom:1px solid #e5e7eb;flex-shrink:0}.header-content[data-v-4ad871a7]{display:flex;align-items:center;gap:.75rem;min-width:0}.panel-title[data-v-4ad871a7]{font-size:.9375rem;font-weight:600;color:#111827;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.close-btn[data-v-4ad871a7]{padding:.25rem;color:#6b7280;background:none;border:none;border-radius:.25rem;cursor:pointer;flex-shrink:0}.close-btn[data-v-4ad871a7]:hover{color:#374151;background:#f3f4f6}.loading-state[data-v-4ad871a7],.error-state[data-v-4ad871a7]{padding:2rem;text-align:center;color:#6b7280}.error-state[data-v-4ad871a7]{color:#dc2626}.panel-body[data-v-4ad871a7]{display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden}.meta-section[data-v-4ad871a7]{padding:.75rem 1rem;border-bottom:1px solid #e5e7eb;flex-shrink:0}.meta-grid[data-v-4ad871a7]{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:.5rem 1rem}.meta-item[data-v-4ad871a7]{display:flex;align-items:baseline;gap:.5rem;font-size:.8125rem}.meta-label[data-v-4ad871a7]{color:#6b7280;flex-shrink:0;min-width:60px}.meta-value[data-v-4ad871a7]{color:#111827;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}code.meta-value[data-v-4ad871a7]{font-size:.75rem;background:#f3f4f6;padding:.125rem .25rem;border-radius:.25rem}.tabs-section[data-v-4ad871a7]{display:flex;flex-direction:column;flex:1;min-height:0}.tabs-header[data-v-4ad871a7]{display:flex;gap:.25rem;padding:.5rem 1rem 0;border-bottom:1px solid #e5e7eb;flex-shrink:0}.tab-btn[data-v-4ad871a7]{display:flex;align-items:center;gap:.375rem;padding:.5rem .75rem;font-size:.8125rem;font-weight:500;color:#6b7280;background:none;border:none;border-bottom:2px solid transparent;margin-bottom:-1px;cursor:pointer}.tab-btn[data-v-4ad871a7]:hover{color:#374151}.tab-btn.active[data-v-4ad871a7]{color:#3b82f6;border-bottom-color:#3b82f6}.tab-count[data-v-4ad871a7]{font-size:.6875rem;background:#e5e7eb;color:#374151;padding:.125rem .375rem;border-radius:9999px}.tab-btn.active .tab-count[data-v-4ad871a7]{background:#dbeafe;color:#3b82f6}.tab-content[data-v-4ad871a7]{flex:1;overflow:auto}.preview-content[data-v-4ad871a7],.events-content[data-v-4ad871a7],.stack-content[data-v-4ad871a7],.headers-content[data-v-4ad871a7]{height:100%}.html-preview[data-v-4ad871a7]{height:100%;position:relative}.iframe-loading[data-v-4ad871a7]{position:absolute;top:0;right:0;bottom:0;left:0;display:flex;align-items:center;justify-content:center;background:#f9fafb;color:#6b7280;font-size:.875rem}.preview-iframe[data-v-4ad871a7]{width:100%;height:100%;border:none;opacity:1;transition:opacity .15s ease}.preview-iframe.is-loading[data-v-4ad871a7]{opacity:0}.text-preview[data-v-4ad871a7]{margin:0;padding:1rem;font-size:.8125rem;background:#f9fafb;overflow:auto;height:100%}.no-content[data-v-4ad871a7]{padding:2rem;text-align:center;color:#6b7280;font-size:.875rem}.events-list[data-v-4ad871a7]{padding:.5rem}.event-item[data-v-4ad871a7]{display:flex;align-items:center;gap:.75rem;padding:.5rem;border-radius:.25rem}.event-item[data-v-4ad871a7]:hover{background:#f9fafb}.event-time[data-v-4ad871a7]{font-size:.75rem;color:#6b7280}.event-recipient[data-v-4ad871a7]{font-size:.8125rem;color:#374151}.stack-trace[data-v-4ad871a7],.headers-json[data-v-4ad871a7]{margin:0;padding:1rem;font-size:.75rem;background:#f9fafb;overflow:auto;height:100%}.email-log-container[data-v-af6aea95]{display:flex;flex-direction:column;height:calc(100vh - 80px);gap:.75rem}.filters-section[data-v-af6aea95]{background:#fff;border-radius:.5rem;box-shadow:0 1px 3px #0000001a;flex-shrink:0}.filters-toggle[data-v-af6aea95]{display:flex;align-items:center;gap:.5rem;width:100%;padding:.75rem 1rem;font-weight:500;font-size:.875rem;color:#374151;background:none;border:none;cursor:pointer}.filters-toggle[data-v-af6aea95]:hover{background:#f9fafb}.filter-count[data-v-af6aea95]{background:#3b82f6;color:#fff;font-size:.75rem;padding:.125rem .5rem;border-radius:9999px}.filters-panel[data-v-af6aea95]{padding:0 1rem 1rem;border-top:1px solid #e5e7eb}.filters-grid[data-v-af6aea95]{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:.75rem;padding-top:.75rem}.filter-label[data-v-af6aea95]{display:block;font-size:.75rem;font-weight:500;color:#6b7280;margin-bottom:.25rem}.filter-input[data-v-af6aea95]{width:100%;padding:.375rem .5rem;font-size:.875rem;border:1px solid #d1d5db;border-radius:.375rem}.filter-input[data-v-af6aea95]:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 2px #3b82f633}.filter-actions[data-v-af6aea95]{display:flex;align-items:flex-end;gap:.5rem}.btn-primary[data-v-af6aea95]{padding:.375rem .75rem;font-size:.875rem;font-weight:500;color:#fff;background:#3b82f6;border:none;border-radius:.375rem;cursor:pointer}.btn-primary[data-v-af6aea95]:hover{background:#2563eb}.btn-secondary[data-v-af6aea95]{padding:.375rem .75rem;font-size:.875rem;color:#3b82f6;background:none;border:none;cursor:pointer}.btn-secondary[data-v-af6aea95]:hover{text-decoration:underline}.split-container[data-v-af6aea95]{display:flex;flex:1;gap:.75rem;min-height:0}.email-list-panel[data-v-af6aea95]{display:flex;flex-direction:column;background:#fff;border-radius:.5rem;box-shadow:0 1px 3px #0000001a;flex:1;min-width:0}.email-list-panel.has-selection[data-v-af6aea95]{flex:0 0 35%;max-width:500px;min-width:320px}.list-header[data-v-af6aea95]{padding:.75rem 1rem;border-bottom:1px solid #e5e7eb;flex-shrink:0}.list-title[data-v-af6aea95]{font-size:1rem;font-weight:600;color:#111827;margin:0}.loading-state[data-v-af6aea95],.empty-state[data-v-af6aea95]{padding:2rem;text-align:center;color:#6b7280}.table-wrapper[data-v-af6aea95]{flex:1;overflow:auto}.email-table[data-v-af6aea95]{width:100%;border-collapse:collapse;font-size:.8125rem}.email-table thead[data-v-af6aea95]{position:sticky;top:0;background:#f9fafb;z-index:1}.email-table th[data-v-af6aea95]{padding:.5rem .75rem;text-align:left;font-weight:500;color:#6b7280;font-size:.75rem;text-transform:uppercase;letter-spacing:.025em;border-bottom:1px solid #e5e7eb;white-space:nowrap}.email-table th.sortable[data-v-af6aea95]{cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none}.email-table th.sortable[data-v-af6aea95]:hover{background:#f3f4f6}.email-table th.sorted[data-v-af6aea95]{color:#3b82f6}.th-content[data-v-af6aea95]{display:flex;align-items:center;gap:.25rem}.sort-indicator[data-v-af6aea95]{font-size:.75rem}.email-table tbody tr[data-v-af6aea95]{cursor:pointer;border-bottom:1px solid #f3f4f6}.email-table tbody tr[data-v-af6aea95]:hover{background:#f9fafb}.email-table tbody tr.selected[data-v-af6aea95]{background:#eff6ff}.email-table td[data-v-af6aea95]{padding:.5rem .75rem;color:#374151;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mailer-cell[data-v-af6aea95]{display:flex;flex-direction:column;gap:.125rem}.mailer-name[data-v-af6aea95]{font-size:.75rem;background:#f3f4f6;padding:.125rem .25rem;border-radius:.25rem}.mailer-action[data-v-af6aea95]{font-size:.6875rem;color:#6b7280}.pagination[data-v-af6aea95]{display:flex;align-items:center;justify-content:space-between;padding:.5rem 1rem;border-top:1px solid #e5e7eb;flex-shrink:0}.page-info[data-v-af6aea95]{font-size:.75rem;color:#6b7280}.page-buttons[data-v-af6aea95]{display:flex;gap:.25rem}.page-btn[data-v-af6aea95]{padding:.25rem .5rem;font-size:.875rem;border:1px solid #d1d5db;border-radius:.25rem;background:#fff;cursor:pointer}.page-btn[data-v-af6aea95]:disabled{opacity:.5;cursor:not-allowed}.page-btn[data-v-af6aea95]:not(:disabled):hover{background:#f3f4f6}.email-detail-panel[data-v-af6aea95]{flex:0 0 65%;background:#fff;border-radius:.5rem;box-shadow:0 1px 3px #0000001a;overflow:hidden;display:flex;flex-direction:column}.slide-enter-active[data-v-af6aea95]{transition:opacity .15s ease,transform .15s ease}.slide-leave-active[data-v-af6aea95]{transition:opacity .1s ease,transform .1s ease}.slide-enter-from[data-v-af6aea95],.slide-leave-to[data-v-af6aea95]{opacity:0;transform:translate(20px)}@media (max-width: 1024px){.split-container[data-v-af6aea95]{flex-direction:column}.email-list-panel.has-selection[data-v-af6aea95]{flex:0 0 40%;max-width:none;min-width:0}.email-detail-panel[data-v-af6aea95]{flex:1}.slide-enter-from[data-v-af6aea95],.slide-leave-to[data-v-af6aea95]{transform:translateY(20px)}}