From 114e922ef99a195ffced373b2595927748b82750 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Jan 2026 15:13:28 +0000 Subject: [PATCH 1/2] Add inbound email tracking and display to mailers page This commit adds comprehensive support for tracking and viewing inbound emails alongside outbound emails on the mailers page. Changes: - Add `direction` field to MailDelivery model to distinguish between 'outbound' and 'inbound' emails - Create ActionMailboxSubscriber to capture inbound emails via ActionMailbox events - Update ActionMailerSubscriber to set direction='outbound' for sent emails - Split mailers UI into separate "Outbound Deliveries" and "Inbound Deliveries" sections - Add "Emails Received" metric to summary panel alongside "Emails Sent" - Update controller to filter deliveries by direction parameter - Configure ActionMailbox in test dummy app with test/relay ingress - Create ApplicationMailbox and CatchAllMailbox for handling inbound emails - Add ActionMailbox database migration for inbound_emails and active_storage tables - Create seed task to generate sample inbound and outbound emails for testing - Add comprehensive tests for inbound email capture and UI rendering - Update existing tests to reflect new dual-table UI structure The implementation allows users to view both sent and received emails separately, with metrics tracked independently for each direction. --- .../rails_observatory/mailers_controller.rb | 4 +- .../mailers/_recent_deliveries.html.erb | 8 +-- .../rails_observatory/mailers/index.html.erb | 13 +++- .../action_mailbox_subscriber.rb | 34 +++++++++ .../action_mailer_subscriber.rb | 2 +- lib/rails_observatory/models/mail_delivery.rb | 1 + .../mailers_controller_test.rb | 39 ++++++++--- .../app/mailboxes/application_mailbox.rb | 6 ++ test/dummy/app/mailboxes/catch_all_mailbox.rb | 9 +++ test/dummy/config/environments/development.rb | 3 + test/dummy/config/environments/test.rb | 3 + ...0106000001_create_action_mailbox_tables.rb | 62 ++++++++++++++++ test/dummy/db/seeds.rb | 32 +++++++++ .../action_mailbox_subscriber_test.rb | 70 +++++++++++++++++++ 14 files changed, 269 insertions(+), 17 deletions(-) create mode 100644 lib/rails_observatory/action_mailbox_subscriber.rb create mode 100644 test/dummy/app/mailboxes/application_mailbox.rb create mode 100644 test/dummy/app/mailboxes/catch_all_mailbox.rb create mode 100644 test/dummy/db/migrate/20260106000001_create_action_mailbox_tables.rb create mode 100644 test/dummy/db/seeds.rb create mode 100644 test/subscribers/action_mailbox_subscriber_test.rb diff --git a/app/controllers/rails_observatory/mailers_controller.rb b/app/controllers/rails_observatory/mailers_controller.rb index 79b065e..ea314d3 100644 --- a/app/controllers/rails_observatory/mailers_controller.rb +++ b/app/controllers/rails_observatory/mailers_controller.rb @@ -12,7 +12,9 @@ def recent MailDelivery.ensure_index @page = [params[:page].to_i, 1].max @per_page = PER_PAGE - query = MailDelivery.all + @direction = params[:direction] || "outbound" + + query = MailDelivery.where(direction: @direction) @total_count = query.count @total_pages = (@total_count.to_f / @per_page).ceil @total_pages = 1 if @total_pages < 1 diff --git a/app/views/rails_observatory/mailers/_recent_deliveries.html.erb b/app/views/rails_observatory/mailers/_recent_deliveries.html.erb index 1291c29..bf59569 100644 --- a/app/views/rails_observatory/mailers/_recent_deliveries.html.erb +++ b/app/views/rails_observatory/mailers/_recent_deliveries.html.erb @@ -1,8 +1,8 @@ - + <%= render 'query_table', - attributes: { id: 'recent-mail-deliveries' }, + attributes: { id: "recent-mail-deliveries-#{@direction}" }, events: @deliveries, - fields: [:time, :mailer, :to, :subject], + fields: [:time, :mailer, :from, :to, :subject], formatters: { time: ->(time, delivery) { link_to Time.at(time).strftime('%H:%M:%S'), @@ -10,6 +10,6 @@ data: { turbo_frame: '_top' } } }, - pagination: { page: @page, total_pages: @total_pages, per_page: @per_page } + pagination: { page: @page, total_pages: @total_pages, per_page: @per_page, params: { direction: @direction } } %> diff --git a/app/views/rails_observatory/mailers/index.html.erb b/app/views/rails_observatory/mailers/index.html.erb index aef46a2..d362c72 100644 --- a/app/views/rails_observatory/mailers/index.html.erb +++ b/app/views/rails_observatory/mailers/index.html.erb @@ -4,13 +4,22 @@
Emails Sent
<%= metric_value(name: 'mailer.delivery_count_sum') || 0 %>
+
Emails Received
+
<%= metric_value(name: 'mailer.inbound_count_sum') || 0 %>
-

Recent Deliveries

+

Outbound Deliveries

- + +
+ +
+
+

Inbound Deliveries

+
+
diff --git a/lib/rails_observatory/action_mailbox_subscriber.rb b/lib/rails_observatory/action_mailbox_subscriber.rb new file mode 100644 index 0000000..640007b --- /dev/null +++ b/lib/rails_observatory/action_mailbox_subscriber.rb @@ -0,0 +1,34 @@ +module RailsObservatory + class ActionMailboxSubscriber < ActiveSupport::Subscriber + attach_to :action_mailbox + + def process(event) + inbound_email = event.payload[:inbound_email] + mail = inbound_email.mail + + # Extract mailbox name from the inbound_email + mailer = inbound_email.class.name + + MailDelivery.new( + mail: mail.to_s, + mailer: mailer, + to: format_addresses(mail.to), + from: format_addresses(mail.from), + subject: mail.subject, + message_id: mail.message_id, + time: Time.now.to_f, + duration: event.duration, + direction: "inbound" + ).save + + RedisTimeSeries.record_occurrence("mailer.inbound_count", labels: {mailer: mailer}) + end + + private + + def format_addresses(addresses) + return nil if addresses.nil? + Array(addresses).join(", ") + end + end +end diff --git a/lib/rails_observatory/action_mailer_subscriber.rb b/lib/rails_observatory/action_mailer_subscriber.rb index b73d4d3..f55292f 100644 --- a/lib/rails_observatory/action_mailer_subscriber.rb +++ b/lib/rails_observatory/action_mailer_subscriber.rb @@ -4,7 +4,7 @@ class ActionMailerSubscriber < ActiveSupport::Subscriber def deliver(event) event.payload => {mail:, mailer:, to:, from:, subject:, message_id:} - MailDelivery.new(mail:, mailer:, to:, from:, subject:, message_id:, time: Time.now.to_f, duration: event.duration).save + MailDelivery.new(mail:, mailer:, to:, from:, subject:, message_id:, time: Time.now.to_f, duration: event.duration, direction: "outbound").save RedisTimeSeries.record_occurrence("mailer.delivery_count", labels: {mailer:}) end diff --git a/lib/rails_observatory/models/mail_delivery.rb b/lib/rails_observatory/models/mail_delivery.rb index df64d97..0980f4f 100644 --- a/lib/rails_observatory/models/mail_delivery.rb +++ b/lib/rails_observatory/models/mail_delivery.rb @@ -7,6 +7,7 @@ class MailDelivery < RedisModel attribute :to, :string attribute :from, :string attribute :subject, :string + attribute :direction, :string # 'outbound' or 'inbound' attribute :mail, compressed: true, indexed: false alias_attribute :id, :message_id diff --git a/test/controllers/rails_observatory/mailers_controller_test.rb b/test/controllers/rails_observatory/mailers_controller_test.rb index e0099c9..84088d2 100644 --- a/test/controllers/rails_observatory/mailers_controller_test.rb +++ b/test/controllers/rails_observatory/mailers_controller_test.rb @@ -18,37 +18,58 @@ def teardown assert_response :success end - test "index renders turbo frame for recent deliveries" do + test "index renders turbo frames for outbound and inbound deliveries" do get mailers_path assert_response :success - # Verify the turbo-frame element is present with lazy loading - assert_select "turbo-frame#recent-deliveries[src='#{recent_mailers_path}'][loading='lazy']" + # Verify the outbound turbo-frame element is present with lazy loading + assert_select "turbo-frame#recent-outbound-deliveries[src='#{recent_mailers_path(direction: 'outbound')}'][loading='lazy']" + + # Verify the inbound turbo-frame element is present with lazy loading + assert_select "turbo-frame#recent-inbound-deliveries[src='#{recent_mailers_path(direction: 'inbound')}'][loading='lazy']" end - test "recent action renders deliveries" do - get recent_mailers_path + test "recent action renders outbound deliveries" do + get recent_mailers_path(direction: "outbound") assert_response :success # Verify the turbo-frame wrapper is present - assert_select "turbo-frame#recent-deliveries" + assert_select "turbo-frame#recent-outbound-deliveries" end - test "index renders summary panel" do + test "recent action renders inbound deliveries" do + get recent_mailers_path(direction: "inbound") + assert_response :success + + # Verify the turbo-frame wrapper is present + assert_select "turbo-frame#recent-inbound-deliveries" + end + + test "index renders summary panel with sent and received counts" do get mailers_path assert_response :success assert_select "div.summary-panel" do assert_select "dt", text: "Emails Sent" + assert_select "dt", text: "Emails Received" + end + end + + test "index renders outbound deliveries section" do + get mailers_path + assert_response :success + + assert_select "section.recent-requests" do + assert_select "h2", text: "Outbound Deliveries" end end - test "index renders recent deliveries section" do + test "index renders inbound deliveries section" do get mailers_path assert_response :success assert_select "section.recent-requests" do - assert_select "h2", text: "Recent Deliveries" + assert_select "h2", text: "Inbound Deliveries" end end end diff --git a/test/dummy/app/mailboxes/application_mailbox.rb b/test/dummy/app/mailboxes/application_mailbox.rb new file mode 100644 index 0000000..36dbc79 --- /dev/null +++ b/test/dummy/app/mailboxes/application_mailbox.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class ApplicationMailbox < ActionMailbox::Base + # Route all inbound emails to CatchAll mailbox + routing :all => :catch_all +end diff --git a/test/dummy/app/mailboxes/catch_all_mailbox.rb b/test/dummy/app/mailboxes/catch_all_mailbox.rb new file mode 100644 index 0000000..6731410 --- /dev/null +++ b/test/dummy/app/mailboxes/catch_all_mailbox.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# CatchAllMailbox processes all inbound emails +# The actual tracking is done by RailsObservatory::ActionMailboxSubscriber +class CatchAllMailbox < ApplicationMailbox + def process + # Just process the email - the subscriber will capture it + end +end diff --git a/test/dummy/config/environments/development.rb b/test/dummy/config/environments/development.rb index f6da608..fab7157 100644 --- a/test/dummy/config/environments/development.rb +++ b/test/dummy/config/environments/development.rb @@ -67,4 +67,7 @@ # Rails observatory configuration config.rails_observatory.redis = {host: "localhost", port: 6379, db: 0, middlewares: [], pool_size: ENV["RAILS_MAX_THREADS"] || 5} + + # ActionMailbox configuration + config.action_mailbox.ingress = :relay end diff --git a/test/dummy/config/environments/test.rb b/test/dummy/config/environments/test.rb index 7ad1dd0..e8013e1 100644 --- a/test/dummy/config/environments/test.rb +++ b/test/dummy/config/environments/test.rb @@ -59,4 +59,7 @@ # config.action_view.annotate_rendered_view_with_filenames = true config.rails_observatory.redis = {host: "localhost", port: 6399, db: 0, middlewares: [], pool_size: 1} + + # ActionMailbox configuration + config.action_mailbox.ingress = :test end diff --git a/test/dummy/db/migrate/20260106000001_create_action_mailbox_tables.rb b/test/dummy/db/migrate/20260106000001_create_action_mailbox_tables.rb new file mode 100644 index 0000000..ff1dfb7 --- /dev/null +++ b/test/dummy/db/migrate/20260106000001_create_action_mailbox_tables.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +class CreateActionMailboxTables < ActiveRecord::Migration[8.1] + def change + create_table :action_mailbox_inbound_emails do |t| + t.integer :status, default: 0, null: false + t.string :message_id, null: false + t.string :message_checksum, null: false + + t.timestamps + + t.index [:message_id, :message_checksum], name: "index_action_mailbox_inbound_emails_uniqueness", unique: true + end + + create_active_storage_tables if !ActiveRecord::Base.connection.table_exists?(:active_storage_blobs) + end + + private + + def create_active_storage_tables + create_table :active_storage_blobs do |t| + t.string :key, null: false + t.string :filename, null: false + t.string :content_type + t.text :metadata + t.string :service_name, null: false + t.bigint :byte_size, null: false + t.string :checksum + + if connection.supports_datetime_with_precision? + t.datetime :created_at, precision: 6, null: false + else + t.datetime :created_at, null: false + end + + t.index [:key], unique: true + end + + create_table :active_storage_attachments do |t| + t.string :name, null: false + t.references :record, null: false, polymorphic: true, index: false + t.references :blob, null: false + + if connection.supports_datetime_with_precision? + t.datetime :created_at, precision: 6, null: false + else + t.datetime :created_at, null: false + end + + t.index [:record_type, :record_id, :name, :blob_id], name: :index_active_storage_attachments_uniqueness, unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + + create_table :active_storage_variant_records do |t| + t.belongs_to :blob, null: false, index: false + t.string :variation_digest, null: false + + t.index [:blob_id, :variation_digest], name: :index_active_storage_variant_records_uniqueness, unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + end +end diff --git a/test/dummy/db/seeds.rb b/test/dummy/db/seeds.rb new file mode 100644 index 0000000..ecb9c87 --- /dev/null +++ b/test/dummy/db/seeds.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +puts "Seeding test data..." + +# Generate some outbound emails +puts "Generating outbound emails..." +5.times do |i| + NewUserMailer.welcome_email("user#{i}@example.com").deliver_now + sleep 0.1 # Small delay to ensure distinct timestamps +end + +# Generate some inbound emails +puts "Generating inbound emails..." +5.times do |i| + inbound_email = ActionMailbox::InboundEmail.create_and_extract_message_id!(<<~EMAIL) + From: sender#{i}@example.com + To: support@example.com + Subject: Test Inbound Email #{i + 1} + Date: #{Time.now.rfc2822} + Message-ID: + + This is a test inbound email message number #{i + 1}. + + It can contain multiple lines of text. + EMAIL + + # Process the inbound email + inbound_email.deliver + sleep 0.1 # Small delay to ensure distinct timestamps +end + +puts "Done! Created 5 outbound and 5 inbound emails." diff --git a/test/subscribers/action_mailbox_subscriber_test.rb b/test/subscribers/action_mailbox_subscriber_test.rb new file mode 100644 index 0000000..100f246 --- /dev/null +++ b/test/subscribers/action_mailbox_subscriber_test.rb @@ -0,0 +1,70 @@ +require "test_helper" + +module RailsObservatory + class ActionMailboxSubscriberTest < ActiveSupport::TestCase + def setup + @redis = Rails.configuration.rails_observatory.redis + @test_keys = [] + MailDelivery.ensure_index + end + + def teardown + @test_keys.each { |key| @redis.call("DEL", key) } + end + + test "captures inbound email when processed" do + # Create and process an inbound email + inbound_email = ActionMailbox::InboundEmail.create_and_extract_message_id!(<<~EMAIL) + From: sender@example.com + To: recipient@example.com + Subject: Test Inbound Email + Date: #{Time.now.rfc2822} + Message-ID: + + This is a test inbound email. + EMAIL + + # Track the key for cleanup + message_id = inbound_email.mail.message_id + @test_keys << "md:#{message_id}" + + # Process the email + inbound_email.deliver + + # Verify the email was captured + delivery = MailDelivery.find(message_id) + assert_not_nil delivery + assert_equal "inbound", delivery.direction + assert_equal "sender@example.com", delivery.from + assert_equal "recipient@example.com", delivery.to + assert_equal "Test Inbound Email", delivery.subject + assert_equal message_id, delivery.message_id + end + + test "records inbound count metric" do + # Create and process an inbound email + inbound_email = ActionMailbox::InboundEmail.create_and_extract_message_id!(<<~EMAIL) + From: sender@example.com + To: recipient@example.com + Subject: Test Metric Email + Date: #{Time.now.rfc2822} + Message-ID: + + Testing metrics. + EMAIL + + message_id = inbound_email.mail.message_id + @test_keys << "md:#{message_id}" + + # Process the email + inbound_email.deliver + + # Note: We can't easily verify the metric was recorded without + # querying Redis TimeSeries directly, but the test ensures + # no errors are raised during processing + assert_nothing_raised do + inbound_email.deliver + end + end + end +end From 6564e1a95c239953735d51c424ac6b1438e8b960 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Jan 2026 00:03:27 +0000 Subject: [PATCH 2/2] Add inbound email generation to observatory:seed task Update the observatory:seed rake task to periodically generate inbound emails alongside existing outbound emails, requests, and jobs. Changes: - Add inbound email scenarios with variety of subjects (support, inquiries, feedback, etc.) - Update event distribution: 50% requests, 30% jobs, 10% outbound mail, 10% inbound mail - Generate realistic inbound emails using ActionMailbox::InboundEmail API - Each inbound email includes random sender, subject, and message content Usage: bin/rails observatory:seed This allows continuous testing and visualization of both inbound and outbound email tracking in the mailers dashboard. --- test/dummy/lib/tasks/observatory.rake | 49 ++++++++++++++++++++++----- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/test/dummy/lib/tasks/observatory.rake b/test/dummy/lib/tasks/observatory.rake index 77b746f..7245897 100644 --- a/test/dummy/lib/tasks/observatory.rake +++ b/test/dummy/lib/tasks/observatory.rake @@ -27,13 +27,23 @@ namespace :observatory do {type: :retry, desc: "RetryJob"} ] - # Mailer scenarios - mailers = [ + # Outbound mailer scenarios + outbound_mailers = [ {action: :welcome, args: -> { {user_email: "user#{rand(1000)}@example.com"} }, desc: "welcome"}, {action: :notification, args: -> { {user_email: "user#{rand(1000)}@example.com", message: "Notification #{rand(1000)}"} }, desc: "notification"}, {action: :newsletter, args: -> { {recipients: ["list#{rand(100)}@example.com"]} }, desc: "newsletter"} ] + # Inbound email scenarios + inbound_email_subjects = [ + "Customer Support Request", + "Product Inquiry", + "Feedback on Recent Order", + "Question About Pricing", + "Bug Report", + "Feature Request" + ] + # Helper to make HTTP requests make_request = lambda do |method, path, params = {}| uri = URI("#{base_url}#{path}") @@ -83,26 +93,47 @@ namespace :observatory do while running count += 1 - # Pick event type: 60% request, 30% job, 10% mailer + # Pick event type: 50% request, 30% job, 10% outbound mail, 10% inbound mail roll = rand(100) - if roll < 60 + if roll < 50 # Request req = requests.sample print "[#{count}] REQUEST #{req[:desc]}..." response = make_request.call(req[:method], req[:path], req[:params] || {}) puts response ? " #{response.code}" : " (unreachable)" - elsif roll < 90 + elsif roll < 80 # Job job = jobs.sample print "[#{count}] JOB #{job[:desc]}..." run_job.call(job[:type]) puts " done" - else - # Mailer - mailer = mailers.sample - print "[#{count}] MAIL #{mailer[:desc]}..." + elsif roll < 90 + # Outbound Mailer + mailer = outbound_mailers.sample + print "[#{count}] MAIL OUTBOUND #{mailer[:desc]}..." ScenariosMailer.send(mailer[:action], **mailer[:args].call).deliver_now puts " done" + else + # Inbound Email + subject = inbound_email_subjects.sample + from = "customer#{rand(1000)}@example.com" + print "[#{count}] MAIL INBOUND #{subject}..." + inbound_email = ActionMailbox::InboundEmail.create_and_extract_message_id!(<<~EMAIL) + From: #{from} + To: support@example.com + Subject: #{subject} + Date: #{Time.now.rfc2822} + Message-ID: + + This is an automated test inbound email. + + #{["Please help with this issue.", "I have a question about your service.", "Great product!", "Looking forward to hearing from you."].sample} + + Thanks, + Customer + EMAIL + inbound_email.deliver + puts " delivered" end sleep(rand(0.1..0.5)) if running