If your Rails application sends emails to users, it’s often helpful to keep a record of which messages have been sent and to who. This has a few key benefits:
- It helps debug issues with scheduled tasks or background jobs that are expected to send emails to certain users. By seeing which emails were sent, we can work backwards from logic errors that caused the wrong people to be contacted.
- In the event of a system outage or background job issue, it lets you see which emails were successfully sent and determine which jobs may need re-running.
- If customers contact you to say they’re not receiving notifications, a mail log helps you quickly determine whether the issue is in your app logic or the mail delivery process.
Historically, you had to use mail interceptors or observers to achieve this. That approach wasn’t very elegant and didn’t give access to all the information we wanted to log. However, Rail 7.1 added lifecycle callbacks to ActionMailer: before_deliver, around_deliver and after_deliver. The after_deliver callback is the perfect place to log successfully sent emails.
First, we need to decide where we are going to log this data. You could send it to a monitoring service or structured log store, but in our application we are writing to the database so that logs can be linked to the related account.
Creating a the model
As we’re storing our logs in the application’s database, we create a MailerLog model and migration with associations to the account and user tables.
The migration:
class CreateMailerLogs < ActiveRecord::Migration[8.1]
def change
create_table :mailer_logs do |t|
t.references :account, null: true, foreign_key: true
t.references :user, null: true, foreign_key: true
t.string :mailer
t.string :action
t.string :subject
t.string :to
t.string :cc
t.string :bcc
t.json :params
t.timestamps
end
end
end
The model:
class MailerLog < ApplicationRecord
belongs_to :account, optional: true
belongs_to :user, optional: true
validates :mailer, presence: true
validates :action, presence: true
validates :to, presence: true
validates :subject, presence: true
end
Next, we modify ApplicationMailer to collect the logs:
class ApplicationMailer < ActionMailer::Base
after_deliver :log_mail
private
def log_mail
MailerLog.create!(
account: params[:account].presence || @account,
user: params[:user].presence || @user,
mailer: self.class.name,
action: action_name,
to: Array.wrap(mail.to).join(", "),
cc: Array.wrap(mail.cc).join(", "),
bcc: Array.wrap(mail.bcc).join(", "),
subject: mail.subject,
params: params.except(:account, :user)
)
end
end
Recording the logs
Let’s step through the key pieces of how we are recording the logs:
after_deliver :log_mail
This adds the callback that records the logs. It only runs after the message is actually sent. So if you use deliver_later, the callback triggers when the background job sends the email, not when it’s queued.
Inside the callback, you have access to the mail object, any instance variables defined in your mailer action, and the params hash created when you invoke a mailer with MyMailer.with(user: @user).my_email
In our application we almost always pass in either the account or user (or define an instance variable for one) so we can address the message appropriately. To record who the message relates to, we use:
account: params[:account].presence || @account
This checks the params hash first, then the instance variable. If neither exists, that’s fine, we log nil. You’ll want to customise this logic to work with your core models.
We log the class name and action method name to identify the mailer that was sent.
Recipients (To, CC and BCC) are logged as a CSV (although you could use a JSON column if you prefer) so we know who got the email.
The subject is logged to help identify certain messages, and could be used in a future web UI to show a user’s notification history.
We deliberately do not log the email body as it might contain sensitive information we don’t want to store.
Finally, we record anything left in the params hash to see the target object of a message. For example, NotificationMailer.with(@project).recent_activity would let us see which project the notification was for. If you are passing in sensitive information (like password reset tokens) you’ll want to filter these from the logged data.
That’s all there is to it, we now have a production log of outbound emails. A couple of final thoughts:
- If you are using Devise, note that this will not record password reset / invitation emails as
Devise::Mailerdoes not subclassApplicationMailer. You can work around that by subclassing it locally (DeviseMailer < Devise::Mailer) and extracting the logging into a shared concern. - Over time, this log will grow large. You might want to add associations to related models, for example
has_many :mailer_logs, dependent: :delete_all. You may also want to add a scheduled job to prune old logs.
For us, this custom mail log sits alongside the audited gem that tracks changes to key fields on certain models and the authtrail gem that integrates with Devise to record authentication activity. All three are great tools to get visibility of what’s going on inside your application.

Efficient. Robust. Scalable. Secure.
Want to know more about Ruby on Rails?
Whether you’re a start-up or you’re looking for an agency to take on your existing Rails app, we’re here to help you.


