John Naegle
John Naegle

Reputation: 8247

Detect action mailer delivery failures in after_action callbacks

I am using an after_action callback in my mailers to record that email was sent. The emails are sent through Delayed Job. This works, except when we are unable to reach the remote server - in which case, the email is not sent, but we record that it was. Delayed Job retries the email later, and it is successfully delivered, but we've then recorded that two emails were sent.

It looks something like this:

class UserMailer < ActionMailer::Base

  after_action :record_email

  def record_email
   Rails.logger.info("XYZZY: Recording Email")
   @user.emails.create!
  end

  def spam!(user) 
   @user = user
   Rails.logger.info("XYZZY: Sending spam!")
   m = mail(to: user.email, subject: 'SPAM!')
   Rails.logger.info("XYZZY: mail method finished")
   m
  end
end

I call this code like this (using delayed job performable mailer):

UserMailer.delay.spam!( User.find(1))

When I step through this in a debugger, it seems that my after_action method is called before the mail is delivered.

[Job:104580969] XYZZY: Sending spam!
[Job:104580969] XYZZY: mail method finished
[Job:104580969] XYZZY: Recording Email
Job UserMailer.app_registration_welcome (id=104580969) FAILED (3 prior attempts) with Errno::ECONNREFUSED: Connection refused - connect(2) for "localhost" port 1025

How can I catch network errors in my mailer methods and record that the email attempt failed, or do nothing at all? I'm using Rails 4.2.4.

Upvotes: 9

Views: 3922

Answers (5)

mechnicov
mechnicov

Reputation: 15248

In rails 7.1+ there are before_deliver, around_deliver, after_deliver callbacks

class MyMailer < ApplicationMailer
  after_deliver :make_something_after_deliver

  def send_mail
    mail to: "[email protected]", subject: "Email"
  end

  private

  def make_something_after_deliver
    # make something after deliver
  end
end

Upvotes: 1

jaynetics
jaynetics

Reputation: 1313

Callbacks after successful delivery are called observers in action mailer and available since rails 3.x.

From https://guides.rubyonrails.org/action_mailer_basics.html#observing-emails :

An observer class must implement the :delivered_email(message) method, which will be called after the email is sent.

class EmailDeliveryObserver
  def self.delivered_email(message)
    EmailDelivery.log(message)
  end
end

Similar to interceptors, you must register observers using the observers config option. You can do this in an initializer file like config/initializers/mail_observers.rb:

Rails.application.configure do
  config.action_mailer.observers = %w[EmailDeliveryObserver]
end

There is also a register_observer class method to add an observer only to a specific mailer.

Upvotes: 0

Matouš Bor&#225;k
Matouš Bor&#225;k

Reputation: 15934

The debug messages you showed make perfect sense - the mailer action finishes immediately because the mailing action itself is asynchronous, handled by Delayed job in a completely different process. So there is no way the mailer class itself can know how the mailing action finished.

What I think you need instead is implementing Delayed job hooks. You'd have to rewrite your mailers and the calls to send emails a bit though.

I have not tested it fully but something along the following lines should work:

class MailerJob

  def initialize(mailer_class, mailer_action, recipient, *params)
    @mailer_class = mailer_class
    @mailer_action = mailer_action
    @recipient = recipient
    @params = params
  end

  def perform
    @mailer_class.send(@mailer_action, @recipient, *@params)
  end

  def success(job)
    Rails.logger.debug "recording email!"
    @recipient.emails.create!
  end

  def failure(job)
    Rails.logger.debug "sending email to #{@recipient.email} failed!"
  end

end

MailerJob is a custom job to be run by Delayed job. I tried to make it as general as possible, so it accepts the mailer class, the mailer action, the recipient (typically the user) and other optional params. Also it requires the recipient to have the emails association.

The job has two hooks defined: success when the mailing action succeeds which creates the email record in the database and another one for logging failure. The actual sending is done in the perform method. Note that inside it the delayed method is not used as the whole job is already enqueued in a background Delayed job queue when being called.

To send a mail using this custom job you have to enqueue it to Delayed job, e.g.:

Delayed::Job.enqueue MailerJob.new(UserMailer, :spam!, User.find(1))

Upvotes: 0

John Naegle
John Naegle

Reputation: 8247

This is what I came up with, I would love to have a better way.

I used the Mail delivery callback:

delivery_callback.rb

class DeliveryCallback
  def delivered_email(mail)
    data = mail.instance_variable_get(:@_callback_data)
    unless data.nil?
      data[:user].email.create!
    end
  end
end

config/initializes/mail.rb

Mail.register_observer( DeliveryCallback.new )

And I replaced my record_email method:

class UserMailer < ActionMailer::Base

  after_action :record_email

  def record_email
    @_message.instance_variable_set(:@_callback_data, {:user => user}) 
  end
end

This seems to work, if the remote server is not available, the delivered_email callback is not invoked.

Is there a better way!?!?

Upvotes: 4

Dharam Gollapudi
Dharam Gollapudi

Reputation: 6438

Try the following:

class UserMailer < ActionMailer::Base

  # after_action :record_email

  def record_email
   Rails.logger.info("XYZZY: Recording Email")
   @user.emails.create!
  end

  def spam!(user)
    begin 
      @user = user
      Rails.logger.info("XYZZY: Sending spam!")
      m = mail(to: user.email, subject: 'SPAM!')
      Rails.logger.info("XYZZY: mail method finished")
      m
    rescue Errno::ECONNREFUSED
      record_email  
    end
  end
end

Upvotes: -1

Related Questions