I'm working on creating a database-backed email auditing system so I can keep track of email messages. The tricky part is I would love to be able to organize these by the mailer classes and also be able to store the name of the mailer method.
It's not difficult to create a mailer interceptor or observer to gather the data from the Mail::Message
instance, but I'm curious if there's a way to capture the class and method name that created the instance of that message.
I would prefer not to use callbacks if at all possible.
Any ideas?
You can use process_action
callback (why not?) to intercept mailer arguments, eg.:
class BaseMailer < ActionMailer::Base
private
# see https://api.rubyonrails.org/classes/AbstractController/Callbacks.html#method-i-process_action
def process_action(action_name, *action_args)
super
track_email_status(action_name, action_args)
end
# move these methods to the concern, copied here for the sake of simplicity!
def track_email_status(action_name, action_args)
email_status = EmailStatus.create!(
user: (action_args.first if action_args.first.is_a?(User)),
email: message.to.first,
mailer_kind: "#{self.class.name}##{action_name}",
mailer_args: tracked_mailer_args(action_name, action_args)
)
message.instance_variable_set(:@email_status, email_status)
end
def tracked_mailer_args(action_name, action_args)
args_map = method(action_name).parameters.map(&:second).zip(action_args).to_h
args_map = self.class.parameter_filter.filter(args_map)
simplify_tracked_arg(args_map.values)
end
def simplify_tracked_arg(argument)
case argument
when Hash then argument.transform_values { |v| simplify_tracked_arg(v) }
when Array then argument.map { |arg| simplify_tracked_arg(arg) }
when ActiveRecord::Base then "#{argument.class.name}##{argument.id}"
else argument
end
end
def self.parameter_filter
@parameter_filter ||= ActionDispatch::Http::ParameterFilter.new(Rails.application.config.filter_parameters)
end
end
This way you can track mailer headers/class/action_name/arguments and build sophisticated emails tracking backend. You can also use an Observer to track when email was sent:
class EmailStatusObserver
def self.delivered_email(mail)
mail.instance_variable_get(:@email_status)&.touch(:sent_at)
end
end
# config/initializers/email_status_observer.rb
ActionMailer::Base.register_observer(EmailStatusObserver)
RSpec test:
describe BaseMailer do
context 'track email status' do
let(:school) { create(:school) }
let(:teacher) { create(:teacher, school: school) }
let(:password) { 'May the Force be with you' }
let(:current_time) { Time.current.change(usec: 0) }
around { |example| travel_to(current_time, &example) }
class TestBaseMailer < BaseMailer
def test_email(user, password)
mail to: user.email, body: password
end
end
subject { TestBaseMailer.test_email(teacher, password) }
it 'creates EmailStatus with tracking data' do
expect { subject.deliver_now }.to change { EmailStatus.count }.by(1)
email_status = EmailStatus.last
expect(email_status.user_id).to eq(teacher.id)
expect(email_status.email).to eq(teacher.email)
expect(email_status.sent_at).to eq(current_time)
expect(email_status.status).to eq(:sent)
expect(email_status.mailer_kind).to eq('TestBaseMailer#test_email')
expect(email_status.mailer_args).to eq(["Teacher##{teacher.id}", '[FILTERED]'])
end
end
end
Here's what I ended up going with... I would love some feedback about the pros and cons of doing it this way. Feels kind of ugly to me but it was easy. Basically, I included the ability to use callbacks in my mailer, attaching the class and method name metadata to the Mail::Message
object so that it would be accessible in my observer. I attached it by setting instance variables on the Mail::Message
object, and then sending attr_reader
to the Mail::Message
class, allowing me to call mail.mailer_klass
and mail.mailer_action
.
I did it this way because I wanted to record the Mail::Message
object after it had been delivered so I could get the exact date it had been sent and know that the logged email should have successfully sent.
The mailer:
class MyMailer < ActionMailer::Base
default from: "[email protected]"
include AbstractController::Callbacks
# Where I attach the class and method
after_action :attach_metadata
def welcome_note(user)
@user = user
mail(subject: "Thanks for signing up!", to: @user.email)
end
private
def attach_metadata
mailer_klass = self.class.to_s
mailer_action = self.action_name
self.message.instance_variable_set(:@mailer_klass, mailer_klass)
self.message.instance_variable_set(:@mailer_action, mailer_action)
self.message.class.send(:attr_reader, :mailer_klass)
self.message.class.send(:attr_reader, :mailer_action)
end
end
The observer:
class MailAuditor
def self.delivered_email(mail)
if mail.multipart?
body = mail.html_part.decoded
else
body = mail.body.raw_source
end
Email.create!(
sender: mail.from,
recipient: mail.to,
bcc: mail.bcc,
cc: mail.cc,
subject: mail.subject,
body: body,
mailer_klass: mail.mailer_klass,
mailer_action: mail.mailer_action,
sent_at: mail.date
)
end
end
config/initializers/mail.rb
ActionMailer::Base.register_observer(MailAuditor)
Thoughts?
Just to illuminate a simpler answer that is hinted at by Mark Murphy's first comment, I went with a very simple approach like this:
class ApplicationMailer < ActionMailer::Base
default from: "[email protected]"
after_action :log_email
private
def log_email
mailer_class = self.class.to_s
mailer_action = self.action_name
EmailLog.log("#{mailer_class}##{mailer_action}", message)
end
end
With a simple model EmailLog to save the record
class EmailLog < ApplicationRecord
def self.log(email_type, message)
EmailLog.create(
email_type: email_type,
from: self.comma_separated(message.from),
to: self.comma_separated(message.to),
cc: self.comma_separated(message.cc),
subject: message.subject,
body: message.body)
end
private
def self.comma_separated(arr)
if !arr.nil? && !arr.empty?
arr.join(",")
else
""
end
end
end
If all of your mailers derive from ApplicationMailer, then you're all set.
Not sure exactly what you're asking here ... you want to track when the Mailer is used or where it's used from?
If it's the former, you could hook into method calls with something like: https://gist.github.com/ridiculous/783cf3686c51341ba32f
If it's the latter, then the only way I can think of is using __callee__
to get that info.
Hope that helps!
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With