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?
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: "noreply#theapp.com"
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: "noreply#theapp.com"
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!
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
I'd probably use an alias_method_chain type approach - similar to Ryan's approach, but without using eval.
https://gist.github.com/kenmazaika/cd80b0379655d39690d8
Never been a big fan of observers.
Related
I have a 3rd party email integration library that I want to use to send email as one of my users. To send email I make an API call using an access_token that I save for each user.
To still use action mailer, I've created a custom delivery class like this:
module Mail
class CustomMailDelivery
attr_reader :settings
def initialize(settings)
#settings = settings
end
def deliver!(mail)
# use 3rd party client here
end
end
end
I'm configuring this in an initializer:
ActionMailer::Base.add_delivery_method :custom, Mail::CustomMailDelivery, {
app_id: ENV.fetch('3RDPARTY_APP_ID'),
app_secret: ENV.fetch('3RDPARTY_APP_SECRET'),
}
This allows me to set the delivery method on a per-mailer basis:
class LeadMailer < ApplicationMailer
self.delivery_method = :custom
...
end
The problem is, I need to pass the user that is sending this message along, so I can get their access_token.
I don't want to rely on fetching the EmailAccount using the sender's email address because it seems like this could break down the road (it's possible this email address might not be the same as the sending user).
In other words, I'd like to pass it explicitly, so it is easy to understand and I avoid any confusion.
Is there a way to provide per-mail context to a custom action mailer delivery class?
I ended up passing this data with a custom message header, which I later delete when handling the message.
class CustomMailer < ApplicationMailer
self.delivery_method = :custom
attr_reader :sending_account
def mail(params)
raise 'You must call set_sending_account before calling mail.' unless sending_email_account
super(params.merge({
Mail::CustomMailDelivery::ACCOUNT_ID_HEADER => sending_account.id
}))
end
def set_sending_account(account)
#sending_account = account
end
end
This way mailers that need this behavior subclass from this class and are forced to provide the custom data.
In the delivery class I yank this value out of the headers:
module Mail
class CustomMailDelivery
attr_reader :settings
# we'll hijack email headers in order to pass over some required data from the mailer to this class
ACCOUNT_ID_HEADER = '__account_id'
def initialize(settings)
#settings = settings
end
def deliver!(mail)
account = account_for(mail)
client = third_party_api_client(account.access_token)
client.send_message(...)
end
private
def third_party_api_client(access_token)
# ...
end
def account_for(mail)
header_field = mail[ACCOUNT_ID_HEADER]
missing_account_id_header! unless header_field
email_account = Account.find(header_field.value)
# remove the header field so it doesn't show up in the actual email
mail[ACCOUNT_ID_HEADER] = nil
account
end
def missing_account_id_header!
raise "Missing required header: #{ACCOUNT_ID_HEADER}"
end
end
end
This solution is not very elegant, but works.
Thanks for the idea, I put together a shorter version by using register_observer and register_interceptor.
It's basically the same idea, except you don't need to redefine too much delivery stuff. You just hook in the mail workflow.
First, declare the hook:
ActionMailer::Base.register_observer(MailToActionEventObserver)
ActionMailer::Base.register_interceptor(MailToActionEventObserver)
Then, the easy part is that the hooks are static methods inside of the same class:
class MailToActionEventObserver
def self.delivered_email(mail)
# Here you can use #passed_argument because it is called just after
# self.delivering_email
end
def self.delivering_email(mail)
#passed_argument = mail['MY-PERSONAL-HEADER'].to_s
# Now remove the temporary header:
mail['MY-PERSONAL-HEADER'] = nil
end
end
Now, same as your answer #Ben, just need to pass the argument as a header in the mailer:
def my_custom_mail
headers['MY-PERSONAL-HEADER'] = 'whatever-value'
mail(...)
end
I'm building an around_action for my customer_mailer class so that I don't have to wrap begin and rescue around every time I call deliver_now
class CustomerMailer < ApplicationMailer
around_action :rescue_error
def send_email(customer)
...
end
def invite_friend(customer, invitee_email)
...
end
private
def rescue_error
yield
rescue => e
msg = "Caught exception! #{e} | #{action_name}"
puts msg
raise
end
end
So in the rescue, I want to log the message with information such as which action was called, I managed to find the method action_name to show which action was called, but I couldn't find a way to retrieve the parameters that were passed into the action, any ideas?
Thanks!
Before I answer your question: would using Bugsnag or something similar work in your case? Alternatively would rescue_from Exception, with: :exception_handler work for you? (it won't allow you to reraise the exception though)
I dug into Rails source code and it seems that parameters are not stored anywhere. They are just passed as a splat to an instance method defined in your mailer class. However, there is a way to store them (without monkey-patching).
Mailers inherit from AbstractController::Base. Looking at the snippet below:
# Call the action. Override this in a subclass to modify the
# behavior around processing an action. This, and not #process,
# is the intended way to override action dispatching.
#
# Notice that the first argument is the method to be dispatched
# which is *not* necessarily the same as the action name.
def process_action(method_name, *args)
send_action(method_name, *args)
end
# Actually call the method associated with the action. Override
# this method if you wish to change how action methods are called,
# not to add additional behavior around it. For example, you would
# override #send_action if you want to inject arguments into the
# method.
alias send_action send
we can see that we can override #send_action and make it store the arguments. Add the following to your ApplicationMailer:
class ApplicationMailer < ActionMailer::Base
def send_action(method_name, *args)
#action_args = args
super
end
end
The arguments will be available as #action_args in all your mailers.
Just store the parameters with which the action has been called to an instance variable, say #params. Then these parameters will be accessible in rescue_error via #params. As per your example:
class CustomerMailer < ApplicationMailer
around_action :rescue_error
def send_email(customer)
#params = { customer: customer }
...
end
def invite_friend(customer, invitee_email)
#params = { customer: customer, invitee_email: invitee_email }
...
end
private
def rescue_error
begin
yield
rescue => e
msg = "Caught exception! #{e} | #{action_name} | #{#params.inspect}"
puts msg
raise
end
end
end
You can make the assignment to #params a bit cleaner by using hash parameters in your actions, e.g.
def invite_friend(options = {})
#params = params
...
end
Of course, this requires accessing the parameters via options, such as options[:customer] to access customer, and options[:invitee_email] to access invitee_email.
The action name have to be yielded , it depends on the way you use your rescue_error .
Define a variable in the block that will be yielded
or raise specifics errors (maybe your custom exception class )
this way you'll retrieve invormation via "e"
post an exemple use case of rescue_error.
I am trying to make a bit of a custom Rails logger which ultimately will log to a database. However, I don't have access to things like the request object, which I very much would like to have.
I'm currently trying to use the LogSubscriber (notification) interface to do the bulk of this; perhaps this is not the right approach. I do know I could abuse Thread.current[] but I was hoping to avoid doing that.
Here's the code I have which is as basic as I can get it for an example. This is loaded in an initializer.
module RequestLogging
class LogSubscriber < ActiveSupport::LogSubscriber
def process_action(event)
pp request # <--- does not work
pp event
end
end
RequestLogging::LogSubscriber.attach_to :action_controller
Probably you need to override process_action in ActionController::Instrumentation and then request object will be accessible like event.payload[:request]. I think you can put code somewhere in config/initializers, code example:
ActionController::Instrumentation.class_eval do
def process_action(*args)
raw_payload = {
controller: self.class.name,
action: self.action_name,
params: request.filtered_parameters,
format: request.format.try(:ref),
method: request.method,
path: (request.fullpath rescue "unknown"),
request: request,
session: session
}
ActiveSupport::Notifications.instrument("start_processing.action_controller", raw_payload.dup)
ActiveSupport::Notifications.instrument("process_action.action_controller", raw_payload) do |payload|
result = super
payload[:status] = response.status
append_info_to_payload(payload)
result
end
end
end
you can get the even.payload then pass it your own CustomLogger(formatted_log(even.payload) and then there you can define a module and save it.
You may want to customise your formatted_log function to beautify the payload accordingly.
def process_action(event)
CustomLogger.application(formattedLog(event.payload))
end
def formattedLog(payload)
# some restructuring of data.
end
In Rails notifications, I am subscribing to "process_action.action_controller", and would like to add more attributes to the payload. How can I do that?
I have tried using append_info_to_payload, but this seems to do nothing.
module AppendExceptionPayload
module ControllerRuntime
extend ActiveSupport::Concern
protected
def append_info_to_payload(payload)
super
payload[:happy] = "HAPPY"
end
end
end
The subscription and above code is in a Rails engine, so this is where I make the call to add it:
require 'append_exception_payload'
module Instrument
class Engine < ::Rails::Engine
ActiveSupport.on_load :action_controller do
include AppendExceptionPayload::ControllerRuntime
end
end
end
After putting up the bounty, I found a solution myself. Rails handles this really cleanly.
Basically, the append_info_to_payload method is meant exactly for this.
So to include session information and signed_in user information I added this to my application_controller.rb:
def append_info_to_payload(payload)
super
payload[:session] = request.session_options[:id] rescue ""
payload[:user_id] = session[:user_id] rescue "unknown"
end
So i jumped in and had a look at the api for the process_action method (private) and the append_info_to_payload instance method (public) and the proccess action method seems to call append_info_to_payload in its code like so:
ActiveSupport::Notifications.instrument("process_action.action_controller", raw_payload) do |payload|
result = super
payload[:status] = response.status
append_info_to_payload(payload)
result
end
and append_info_to_payload works something like this
def append_info_to_payload(payload) #:nodoc:
payload[:view_runtime] = view_runtime
end
I can suggest trying payload[:view_runtime] instead of payload[:happy] or trying to use payload[:status]
Let me know how you get on and I will try help more, unfortunately there is really no documentation for this stuff.
Suppose you have an ActiveRecord::Observer in one of your Ruby on Rails applications - how do you test this observer with rSpec?
You are on the right track, but I have run into a number of frustrating unexpected message errors when using rSpec, observers, and mock objects. When I am spec testing my model, I don't want to have to handle observer behavior in my message expectations.
In your example, there isn't a really good way to spec "set_status" on the model without knowledge of what the observer is going to do to it.
Therefore, I like to use the "No Peeping Toms" plugin. Given your code above and using the No Peeping Toms plugin, I would spec the model like this:
describe Person do
it "should set status correctly" do
#p = Person.new(:status => "foo")
#p.set_status("bar")
#p.save
#p.status.should eql("bar")
end
end
You can spec your model code without having to worry that there is an observer out there that is going to come in and clobber your value. You'd spec that separately in the person_observer_spec like this:
describe PersonObserver do
it "should clobber the status field" do
#p = mock_model(Person, :status => "foo")
#obs = PersonObserver.instance
#p.should_receive(:set_status).with("aha!")
#obs.after_save
end
end
If you REALLY REALLY want to test the coupled Model and Observer class, you can do it like this:
describe Person do
it "should register a status change with the person observer turned on" do
Person.with_observers(:person_observer) do
lambda { #p = Person.new; #p.save }.should change(#p, :status).to("aha!)
end
end
end
99% of the time, I'd rather spec test with the observers turned off. It's just easier that way.
Disclaimer: I've never actually done this on a production site, but it looks like a reasonable way would be to use mock objects, should_receive and friends, and invoke methods on the observer directly
Given the following model and observer:
class Person < ActiveRecord::Base
def set_status( new_status )
# do whatever
end
end
class PersonObserver < ActiveRecord::Observer
def after_save(person)
person.set_status("aha!")
end
end
I would write a spec like this (I ran it, and it passes)
describe PersonObserver do
before :each do
#person = stub_model(Person)
#observer = PersonObserver.instance
end
it "should invoke after_save on the observed object" do
#person.should_receive(:set_status).with("aha!")
#observer.after_save(#person)
end
end
no_peeping_toms is now a gem and can be found here: https://github.com/patmaddox/no-peeping-toms
If you want to test that the observer observes the correct model and receives the notification as expected, here is an example using RR.
your_model.rb:
class YourModel < ActiveRecord::Base
...
end
your_model_observer.rb:
class YourModelObserver < ActiveRecord::Observer
def after_create
...
end
def custom_notification
...
end
end
your_model_observer_spec.rb:
before do
#observer = YourModelObserver.instance
#model = YourModel.new
end
it "acts on the after_create notification"
mock(#observer).after_create(#model)
#model.save!
end
it "acts on the custom notification"
mock(#observer).custom_notification(#model)
#model.send(:notify, :custom_notification)
end