Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Store mailer class and method after email sent in rails

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?

like image 289
Jared Rader Avatar asked Jan 15 '15 23:01

Jared Rader


4 Answers

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
like image 118
Lev Lukomsky Avatar answered Oct 07 '22 15:10

Lev Lukomsky


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?

like image 13
Jared Rader Avatar answered Nov 18 '22 03:11

Jared Rader


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.

like image 4
Brett Green Avatar answered Nov 18 '22 01:11

Brett Green


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!

like image 3
Ryan Buckley Avatar answered Nov 18 '22 03:11

Ryan Buckley