Getting Rails 6 ActionMailer to send emails via an explicit ActiveJob (so you can specify Job behavior)
By default, if your Rails 6 app sends emails via ActionMailer it is “nicely integrated with Active Job” (in the words of the Rails guide) so whatever queuing backend you have setup on Rails will get used. So it “just works” as long as you’re content with the default queue.
And that default mailing behavior is probably a terrible idea. Because your default queue backend (like Sidekiq) probably has automatic retries. And automatic retries are evil when it comes to email… if an email is sent, yet the confirmation handshake times out or has another error, then the email gets re-sent. (I’ve posted elsewhere about my inbox flooded with such behavior from big guys (Microsoft) and small guys (JibJab) alike.)
The right way to do email in Rails is
- Configure ActionMailer to send your email jobs via an explicit ActiveJob that you can custom configure, not the implicit (magic) default connection to ActiveJob.
- For your Mailers, use a persistent queuing backend (that doesn't forget all your pending jobs in case of a server restart), lets you disable retries, and one that lets you inspect failed jobs, debug the issue, retry them. (No entry-level Sidekiq plans, in other words, where failed jobs cannot be inspected or retried once you correct the issue.)
- Configure your mailer Job.
For emails, I like DelayedJob, even if using Sidekiq for all the magic Hotwire and Stimulus goodies. Keeps them right in a database table.
Step 1. The hardest part of the whole thing was figuring out the (completely undocumented in the Rails guides) method to configure a ActionMailer to use an explicit ActiveJob using “delivery_job = JobName” It’s very simple:
# sample test mailer, lets you purposefully raise an exception for testing how queue handles it class TestMailer < ApplicationMailer self.delivery_job = TestMailerJob # this is the secret step 1 def test_poobah(arg) raise "error_test_1 in TestMailer" if arg == "error1" to = "sometestemail@yourdomain.com" mail( to: to, from: "your_sending_address@yourdomain.com", subject: "a test email (arg #{arg}) at #{Time.now}" ) end end
Step 2. I install gem 'delayed_job_active_record'
then add an initializer that establishes a queue for mailing that does not retry. Except for retries pretty much as documented on the gem’s homepage.
# config/initializers/delayed_job_config.rb Delayed::Worker.destroy_failed_jobs = false Delayed::Worker.max_attempts = 1 Delayed::Worker.read_ahead = 1 Delayed::Worker.default_queue_name = 'default_dj' Delayed::Worker.sleep_delay = 10
Step 3.
# sample test job that lets you see how job-level errors are surfaces class TestMailerJob < ApplicationJob self.queue_adapter = :delayed_job # specify which queue backend to use # sidekiq_options retry: false # IF using sidekiq disable retries HERE queue_as :default_dj # spcify the queue name (if you have more than one) def perform(arg) raise "error_test_2 in TestMailerJob" if arg == "error2" TestMailer.test_poobah(arg).deliver_now end end
To bypass the queue, send via:
TestMailerJob.perform_now("a test argument")
To send via the queue:
TestMailerJob.perform_later("a test argument")