Merge pull request #47630 from bensheldon/action_mailer_around_delivery

Add `*_deliver` callbacks for Action Mailer
This commit is contained in:
John Hawthorn 2023-04-04 14:58:15 -07:00 committed by GitHub
commit 6cd8dddb92
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 190 additions and 10 deletions

@ -1,3 +1,17 @@
* Added `*_deliver` callbacks to `ActionMailer::Base` that wrap mail message delivery.
Example:
```ruby
class EventsMailer < ApplicationMailer
after_deliver do
User.find_by(email: message.to.first).update(email_provider_id: message.message_id, emailed_at: Time.current)
end
end
```
*Ben Sheldon*
* Added `deliver_enqueued_emails` to `ActionMailer::TestHelper`. This method
delivers all enqueued email jobs.

@ -44,6 +44,7 @@ module ActionMailer
end
autoload :Base
autoload :Callbacks
autoload :DeliveryMethods
autoload :InlinePreviewInterceptor
autoload :MailHelper

@ -318,12 +318,14 @@ module ActionMailer
#
# = Callbacks
#
# You can specify callbacks using <tt>before_action</tt> and <tt>after_action</tt> for configuring your messages.
# This may be useful, for example, when you want to add default inline attachments for all
# messages sent out by a certain mailer class:
# You can specify callbacks using <tt>before_action</tt> and <tt>after_action</tt> for configuring your messages,
# and using <tt>before_deliver</tt> and <tt>after_deliver</tt> for wrapping the delivery process.
# For example, when you want to add default inline attachments and log delivery for all messages
# sent out by a certain mailer class:
#
# class NotifierMailer < ApplicationMailer
# before_action :add_inline_attachment!
# after_deliver :log_delivery
#
# def welcome
# mail
@ -333,9 +335,13 @@ module ActionMailer
# def add_inline_attachment!
# attachments.inline["footer.jpg"] = File.read('/path/to/filename.jpg')
# end
#
# def log_delivery
# Rails.logger.info "Sent email with message id '#{message.message_id}' at #{Time.current}."
# end
# end
#
# Callbacks in Action Mailer are implemented using
# Action callbacks in Action Mailer are implemented using
# AbstractController::Callbacks, so you can define and configure
# callbacks in the same manner that you would use callbacks in classes that
# inherit from ActionController::Base.
@ -468,6 +474,7 @@ module ActionMailer
# * <tt>deliver_later_queue_name</tt> - The queue name used by <tt>deliver_later</tt> with the default
# <tt>delivery_job</tt>. Mailers can set this to use a custom queue name.
class Base < AbstractController::Base
include Callbacks
include DeliveryMethods
include QueuedDelivery
include Rescuable

@ -0,0 +1,31 @@
# frozen_string_literal: true
module ActionMailer
module Callbacks
extend ActiveSupport::Concern
included do
include ActiveSupport::Callbacks
define_callbacks :deliver, skip_after_callbacks_if_terminated: true
end
module ClassMethods
# Defines a callback that will get called right before the
# message is sent to the delivery method.
def before_deliver(*filters, &blk)
set_callback(:deliver, :before, *filters, &blk)
end
# Defines a callback that will get called right after the
# message's delivery method is finished.
def after_deliver(*filters, &blk)
set_callback(:deliver, :after, *filters, &blk)
end
# Defines a callback that will get called around the message's deliver method.
def around_deliver(*filters, &blk)
set_callback(:deliver, :around, *filters, &blk)
end
end
end
end

@ -110,7 +110,9 @@ def deliver_later(options = {})
#
def deliver_now!
processed_mailer.handle_exceptions do
message.deliver!
processed_mailer.run_callbacks(:deliver) do
message.deliver!
end
end
end
@ -120,13 +122,15 @@ def deliver_now!
#
def deliver_now
processed_mailer.handle_exceptions do
message.deliver
processed_mailer.run_callbacks(:deliver) do
message.deliver
end
end
end
private
# Returns the processed Mailer instance. We keep this instance
# on hand so we can delegate exception handling to it.
# on hand so we can run callbacks and delegate exception handling to it.
def processed_mailer
@processed_mailer ||= @mailer_class.new.tap do |mailer|
mailer.process @action, *@args

@ -0,0 +1,80 @@
# frozen_string_literal: true
require "abstract_unit"
require "mailers/callback_mailer"
class ActionMailerCallbacksTest < ActiveSupport::TestCase
include ActiveJob::TestHelper
setup do
@previous_delivery_method = ActionMailer::Base.delivery_method
ActionMailer::Base.delivery_method = :test
CallbackMailer.rescue_from_error = nil
CallbackMailer.after_deliver_instance = nil
CallbackMailer.around_deliver_instance = nil
CallbackMailer.abort_before_deliver = nil
CallbackMailer.around_handles_error = nil
end
teardown do
ActionMailer::Base.deliveries.clear
ActionMailer::Base.delivery_method = @previous_delivery_method
CallbackMailer.rescue_from_error = nil
CallbackMailer.after_deliver_instance = nil
CallbackMailer.around_deliver_instance = nil
CallbackMailer.abort_before_deliver = nil
CallbackMailer.around_handles_error = nil
end
test "deliver_now should call after_deliver callback and can access sent message" do
mail_delivery = CallbackMailer.test_message
mail_delivery.deliver_now
assert_kind_of CallbackMailer, CallbackMailer.after_deliver_instance
assert_not_empty CallbackMailer.after_deliver_instance.message.message_id
assert_equal mail_delivery.message_id, CallbackMailer.after_deliver_instance.message.message_id
assert_equal "test-receiver@test.com", CallbackMailer.after_deliver_instance.message.to.first
end
test "deliver_now! should call after_deliver callback" do
CallbackMailer.test_message.deliver_now
assert_kind_of CallbackMailer, CallbackMailer.after_deliver_instance
end
test "before_deliver can abort the delivery and not run after_deliver callbacks" do
CallbackMailer.abort_before_deliver = true
mail_delivery = CallbackMailer.test_message
mail_delivery.deliver_now
assert_nil mail_delivery.message_id
assert_nil CallbackMailer.after_deliver_instance
end
test "deliver_later should call after_deliver callback and can access sent message" do
perform_enqueued_jobs { CallbackMailer.test_message.deliver_later }
assert_kind_of CallbackMailer, CallbackMailer.after_deliver_instance
assert_not_empty CallbackMailer.after_deliver_instance.message.message_id
end
test "around_deliver is called after rescue_from on action processing exceptions" do
CallbackMailer.around_handles_error = true
CallbackMailer.test_raise_action.deliver_now
assert CallbackMailer.rescue_from_error
end
test "around_deliver is called before rescue_from on deliver! exceptions" do
CallbackMailer.around_handles_error = true
stub_any_instance(Mail::TestMailer, instance: Mail::TestMailer.new({})) do |instance|
instance.stub(:deliver!, proc { raise "boom deliver exception" }) do
CallbackMailer.test_message.deliver_now
end
end
assert_kind_of CallbackMailer, CallbackMailer.after_deliver_instance
assert_nil CallbackMailer.rescue_from_error
end
end

@ -0,0 +1,37 @@
# frozen_string_literal: true
CallbackMailerError = Class.new(StandardError)
class CallbackMailer < ActionMailer::Base
cattr_accessor :rescue_from_error
cattr_accessor :after_deliver_instance
cattr_accessor :around_deliver_instance
cattr_accessor :abort_before_deliver
cattr_accessor :around_handles_error
rescue_from CallbackMailerError do |error|
@@rescue_from_error = error
end
before_deliver do
throw :abort if @@abort_before_deliver
end
after_deliver do
@@after_deliver_instance = self
end
around_deliver do |mailer, block|
@@around_deliver_instance = self
block.call
rescue StandardError
raise unless @@around_handles_error
end
def test_message(*)
mail(from: "test-sender@test.com", to: "test-receiver@test.com", subject: "Test Subject", body: "Test Body")
end
def test_raise_action
raise CallbackMailerError, "boom action processing"
end
end

@ -697,9 +697,10 @@ Action Mailer Callbacks
-----------------------
Action Mailer allows for you to specify a [`before_action`][], [`after_action`][] and
[`around_action`][].
[`around_action`][] to configure the message, and [`before_deliver`][], [`after_deliver`][] and
[`around_deliver`][] to control the delivery.
* Filters can be specified with a block or a symbol to a method in the mailer
* Callbacks can be specified with a block or a symbol to a method in the mailer
class similar to controllers.
* You could use a `before_action` to set instance variables, populate the mail
@ -776,11 +777,16 @@ class UserMailer < ApplicationMailer
end
```
* Mailer Filters abort further processing if body is set to a non-nil value.
* You could use an `after_delivery` to record the delivery of the message.
* Mailer callbacks abort further processing if body is set to a non-nil value. `before_deliver` can abort with `throw :abort`.
[`after_action`]: https://api.rubyonrails.org/classes/AbstractController/Callbacks/ClassMethods.html#method-i-after_action
[`after_deliver`]: https://api.rubyonrails.org/classes/ActionMailer/Callbacks/ClassMethods.html#method-i-after_deliver
[`around_action`]: https://api.rubyonrails.org/classes/AbstractController/Callbacks/ClassMethods.html#method-i-around_action
[`around_deliver`]: https://api.rubyonrails.org/classes/ActionMailer/Callbacks/ClassMethods.html#method-i-around_deliver
[`before_action`]: https://api.rubyonrails.org/classes/AbstractController/Callbacks/ClassMethods.html#method-i-before_action
[`before_deliver`]: https://api.rubyonrails.org/classes/ActionMailer/Callbacks/ClassMethods.html#method-i-before_deliver
Using Action Mailer Helpers
---------------------------