diff options
Diffstat (limited to 'actionmailer/lib')
21 files changed, 802 insertions, 327 deletions
diff --git a/actionmailer/lib/action_mailer.rb b/actionmailer/lib/action_mailer.rb index 291a8c1e34..69eae65d60 100644 --- a/actionmailer/lib/action_mailer.rb +++ b/actionmailer/lib/action_mailer.rb @@ -1,5 +1,7 @@ +# frozen_string_literal: true + #-- -# Copyright (c) 2004-2015 David Heinemeier Hansson +# Copyright (c) 2004-2018 David Heinemeier Hansson # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the @@ -21,15 +23,16 @@ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. #++ -require 'abstract_controller' -require 'action_mailer/version' +require "abstract_controller" +require "action_mailer/version" # Common Active Support usage in Action Mailer -require 'active_support/rails' -require 'active_support/core_ext/class' -require 'active_support/core_ext/module/attr_internal' -require 'active_support/core_ext/string/inflections' -require 'active_support/lazy_load_hooks' +require "active_support" +require "active_support/rails" +require "active_support/core_ext/class" +require "active_support/core_ext/module/attr_internal" +require "active_support/core_ext/string/inflections" +require "active_support/lazy_load_hooks" module ActionMailer extend ::ActiveSupport::Autoload @@ -42,10 +45,25 @@ module ActionMailer autoload :DeliveryMethods autoload :InlinePreviewInterceptor autoload :MailHelper + autoload :Parameterized autoload :Preview - autoload :Previews, 'action_mailer/preview' + autoload :Previews, "action_mailer/preview" autoload :TestCase autoload :TestHelper autoload :MessageDelivery autoload :DeliveryJob + + def self.eager_load! + super + + require "mail" + Mail.eager_autoload! + end +end + +autoload :Mime, "action_dispatch/http/mime_type" + +ActiveSupport.on_load(:action_view) do + ActionView::Base.default_formats ||= Mime::SET.symbols + ActionView::Template::Types.delegate_to Mime end diff --git a/actionmailer/lib/action_mailer/base.rb b/actionmailer/lib/action_mailer/base.rb index 7022c04a9a..509d859ac3 100644 --- a/actionmailer/lib/action_mailer/base.rb +++ b/actionmailer/lib/action_mailer/base.rb @@ -1,10 +1,13 @@ -require 'mail' -require 'action_mailer/collector' -require 'active_support/core_ext/string/inflections' -require 'active_support/core_ext/hash/except' -require 'active_support/core_ext/module/anonymous' +# frozen_string_literal: true -require 'action_mailer/log_subscriber' +require "mail" +require "action_mailer/collector" +require "active_support/core_ext/string/inflections" +require "active_support/core_ext/hash/except" +require "active_support/core_ext/module/anonymous" + +require "action_mailer/log_subscriber" +require "action_mailer/rescuable" module ActionMailer # Action Mailer allows you to send email from your application using a mailer model and views. @@ -56,7 +59,7 @@ module ActionMailer # The hash passed to the mail method allows you to specify any header that a <tt>Mail::Message</tt> # will accept (any valid email header including optional fields). # - # The mail method, if not passed a block, will inspect your views and send all the views with + # The +mail+ method, if not passed a block, will inspect your views and send all the views with # the same name as the method, so the above action would send the +welcome.text.erb+ view # file as well as the +welcome.html.erb+ view file in a +multipart/alternative+ email. # @@ -86,7 +89,7 @@ module ActionMailer # Like Action Controller, each mailer class has a corresponding view directory in which each # method of the class looks for a template with its name. # - # To define a template to be used with a mailing, create an <tt>.erb</tt> file with the same + # To define a template to be used with a mailer, create an <tt>.erb</tt> file with the same # name as the method in your mailer model. For example, in the mailer defined above, the template at # <tt>app/views/notifier_mailer/welcome.text.erb</tt> would be used to generate the email. # @@ -132,6 +135,11 @@ module ActionMailer # # config.action_mailer.default_url_options = { host: "example.com" } # + # You can also define a <tt>default_url_options</tt> method on individual mailers to override these + # default settings per-mailer. + # + # By default when <tt>config.force_ssl</tt> is +true+, URLs generated for hosts will use the HTTPS protocol. + # # = Sending mail # # Once a mailer action and template are defined, you can deliver your message or defer its creation and @@ -142,7 +150,7 @@ module ActionMailer # mail.deliver_now # generates and sends the email now # # The <tt>ActionMailer::MessageDelivery</tt> class is a wrapper around a delegate that will call - # your method to generate the mail. If you want direct access to delegator, or <tt>Mail::Message</tt>, + # your method to generate the mail. If you want direct access to the delegator, or <tt>Mail::Message</tt>, # you can call the <tt>message</tt> method on the <tt>ActionMailer::MessageDelivery</tt> object. # # NotifierMailer.welcome(User.first).message # => a Mail::Message object @@ -161,7 +169,7 @@ module ActionMailer # # Multipart messages can also be used implicitly because Action Mailer will automatically detect and use # multipart templates, where each template is named after the name of the action, followed by the content - # type. Each such detected template will be added as a separate part to the message. + # type. Each such detected template will be added to the message, as a separate part. # # For example, if the following templates exist: # * signup_notification.text.erb @@ -205,6 +213,19 @@ module ActionMailer # end # end # + # You can also send attachments with html template, in this case you need to add body, attachments, + # and custom content type like this: + # + # class NotifierMailer < ApplicationMailer + # def welcome(recipient) + # attachments["free_book.pdf"] = File.read("path/to/file.pdf") + # mail(to: recipient, + # subject: "New account information", + # content_type: "text/html", + # body: "<html><body>Hello there</body></html>") + # end + # end + # # = Inline Attachments # # You can also specify that a file should be displayed inline with other HTML. This is useful @@ -272,22 +293,21 @@ module ActionMailer # content_description: 'This is a description' # end # - # Finally, Action Mailer also supports passing <tt>Proc</tt> objects into the default hash, so you - # can define methods that evaluate as the message is being generated: + # Finally, Action Mailer also supports passing <tt>Proc</tt> and <tt>Lambda</tt> objects into the default hash, + # so you can define methods that evaluate as the message is being generated: # # class NotifierMailer < ApplicationMailer - # default 'X-Special-Header' => Proc.new { my_method } + # default 'X-Special-Header' => Proc.new { my_method }, to: -> { @inviter.email_address } # # private - # # def my_method # 'some complex call' # end # end # - # Note that the proc is evaluated right at the start of the mail message generation, so if you - # set something in the default using a proc, and then set the same thing inside of your - # mailer method, it will get over written by the mailer method. + # Note that the proc/lambda is evaluated right at the start of the mail message generation, so if you + # set something in the default hash using a proc, and then set the same thing inside of your + # mailer method, it will get overwritten by the mailer method. # # It is also possible to set these default options that will be used in all mailers through # the <tt>default_options=</tt> configuration in <tt>config/application.rb</tt>: @@ -296,7 +316,7 @@ module ActionMailer # # = Callbacks # - # You can specify callbacks using before_action and after_action for configuring your messages. + # 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: # @@ -308,7 +328,6 @@ module ActionMailer # end # # private - # # def add_inline_attachment! # attachments.inline["footer.jpg"] = File.read('/path/to/filename.jpg') # end @@ -387,13 +406,13 @@ module ActionMailer # to use it. Defaults to <tt>true</tt>. # * <tt>:openssl_verify_mode</tt> - When using TLS, you can set how OpenSSL checks the certificate. This is # really useful if you need to validate a self-signed and/or a wildcard certificate. You can use the name - # of an OpenSSL verify constant (<tt>'none'</tt>, <tt>'peer'</tt>, <tt>'client_once'</tt>, - # <tt>'fail_if_no_peer_cert'</tt>) or directly the constant (<tt>OpenSSL::SSL::VERIFY_NONE</tt>, - # <tt>OpenSSL::SSL::VERIFY_PEER</tt>, ...). + # of an OpenSSL verify constant (<tt>'none'</tt> or <tt>'peer'</tt>) or directly the constant + # (<tt>OpenSSL::SSL::VERIFY_NONE</tt> or <tt>OpenSSL::SSL::VERIFY_PEER</tt>). + # <tt>:ssl/:tls</tt> Enables the SMTP connection to use SMTP/TLS (SMTPS: SMTP over direct TLS connection) # # * <tt>sendmail_settings</tt> - Allows you to override options for the <tt>:sendmail</tt> delivery method. # * <tt>:location</tt> - The location of the sendmail executable. Defaults to <tt>/usr/sbin/sendmail</tt>. - # * <tt>:arguments</tt> - The command line arguments. Defaults to <tt>-i -t</tt> with <tt>-f sender@address</tt> + # * <tt>:arguments</tt> - The command line arguments. Defaults to <tt>-i</tt> with <tt>-f sender@address</tt> # added automatically before the message is sent. # # * <tt>file_settings</tt> - Allows you to override options for the <tt>:file</tt> delivery method. @@ -414,9 +433,11 @@ module ActionMailer # * <tt>deliveries</tt> - Keeps an array of all the emails sent out through the Action Mailer with # <tt>delivery_method :test</tt>. Most useful for unit and functional testing. # - # * <tt>deliver_later_queue_name</tt> - The name of the queue used with <tt>deliver_later</tt> + # * <tt>deliver_later_queue_name</tt> - The name of the queue used with <tt>deliver_later</tt>. Defaults to +mailers+. class Base < AbstractController::Base include DeliveryMethods + include Rescuable + include Parameterized include Previews abstract! @@ -428,6 +449,7 @@ module ActionMailer include AbstractController::Translation include AbstractController::AssetPaths include AbstractController::Callbacks + include AbstractController::Caching include ActionView::Layouts @@ -439,10 +461,9 @@ module ActionMailer helper ActionMailer::MailHelper - private_class_method :new #:nodoc: - - class_attribute :default_params - self.default_params = { + class_attribute :delivery_job, default: ::ActionMailer::DeliveryJob + class_attribute :parameterized_delivery_job, default: ::ActionMailer::Parameterized::DeliveryJob + class_attribute :default_params, default: { mime_version: "1.0", charset: "UTF-8", content_type: "text/plain", @@ -455,40 +476,60 @@ module ActionMailer observers.flatten.compact.each { |observer| register_observer(observer) } end + # Unregister one or more previously registered Observers. + def unregister_observers(*observers) + observers.flatten.compact.each { |observer| unregister_observer(observer) } + end + # Register one or more Interceptors which will be called before mail is sent. def register_interceptors(*interceptors) interceptors.flatten.compact.each { |interceptor| register_interceptor(interceptor) } end + # Unregister one or more previously registered Interceptors. + def unregister_interceptors(*interceptors) + interceptors.flatten.compact.each { |interceptor| unregister_interceptor(interceptor) } + end + # Register an Observer which will be notified when mail is delivered. # Either a class, string or symbol can be passed in as the Observer. # If a string or symbol is passed in it will be camelized and constantized. def register_observer(observer) - delivery_observer = case observer - when String, Symbol - observer.to_s.camelize.constantize - else - observer - end + Mail.register_observer(observer_class_for(observer)) + end - Mail.register_observer(delivery_observer) + # Unregister a previously registered Observer. + # Either a class, string or symbol can be passed in as the Observer. + # If a string or symbol is passed in it will be camelized and constantized. + def unregister_observer(observer) + Mail.unregister_observer(observer_class_for(observer)) end # Register an Interceptor which will be called before mail is sent. # Either a class, string or symbol can be passed in as the Interceptor. # If a string or symbol is passed in it will be camelized and constantized. def register_interceptor(interceptor) - delivery_interceptor = case interceptor - when String, Symbol - interceptor.to_s.camelize.constantize - else - interceptor - end + Mail.register_interceptor(observer_class_for(interceptor)) + end + + # Unregister a previously registered Interceptor. + # Either a class, string or symbol can be passed in as the Interceptor. + # If a string or symbol is passed in it will be camelized and constantized. + def unregister_interceptor(interceptor) + Mail.unregister_interceptor(observer_class_for(interceptor)) + end - Mail.register_interceptor(delivery_interceptor) + def observer_class_for(value) # :nodoc: + case value + when String, Symbol + value.to_s.camelize.constantize + else + value + end end + private :observer_class_for - # Returns the name of current mailer. This method is also being used as a path for a view lookup. + # Returns the name of the current mailer. This method is also being used as a path for a view lookup. # If this is an anonymous mailer, this method will return +anonymous+ instead. def mailer_name @mailer_name ||= anonymous? ? "anonymous" : name.underscore @@ -545,50 +586,47 @@ module ActionMailer end end - def respond_to?(method, include_private = false) #:nodoc: - super || action_methods.include?(method.to_s) - end - - protected - - def set_payload_for_mail(payload, mail) #:nodoc: - payload[:mailer] = name - payload[:message_id] = mail.message_id - payload[:subject] = mail.subject - payload[:to] = mail.to - payload[:from] = mail.from - payload[:bcc] = mail.bcc if mail.bcc.present? - payload[:cc] = mail.cc if mail.cc.present? - payload[:date] = mail.date - payload[:mail] = mail.encoded + private + + def set_payload_for_mail(payload, mail) + payload[:mailer] = name + payload[:message_id] = mail.message_id + payload[:subject] = mail.subject + payload[:to] = mail.to + payload[:from] = mail.from + payload[:bcc] = mail.bcc if mail.bcc.present? + payload[:cc] = mail.cc if mail.cc.present? + payload[:date] = mail.date + payload[:mail] = mail.encoded + payload[:perform_deliveries] = mail.perform_deliveries end - def method_missing(method_name, *args) # :nodoc: + def method_missing(method_name, *args) if action_methods.include?(method_name.to_s) MessageDelivery.new(self, method_name, *args) else super end end + + def respond_to_missing?(method, include_all = false) + action_methods.include?(method.to_s) || super + end end attr_internal :message - # Instantiate a new mailer object. If +method_name+ is not +nil+, the mailer - # will be initialized according to the named method. If not, the mailer will - # remain uninitialized (useful when you only need to invoke the "receive" - # method, for instance). - def initialize(method_name=nil, *args) + def initialize super() @_mail_was_called = false @_message = Mail.new - process(method_name, *args) if method_name end def process(method_name, *args) #:nodoc: payload = { mailer: self.class.name, - action: method_name + action: method_name, + args: args } ActiveSupport::Notifications.instrument("process.action_mailer", payload) do @@ -598,10 +636,10 @@ module ActionMailer end class NullMail #:nodoc: - def body; '' end + def body; "" end def header; {} end - def respond_to?(string, include_all=false) + def respond_to?(string, include_all = false) true end @@ -661,21 +699,21 @@ module ActionMailer # # mail.attachments['filename.jpg'] = File.read('/path/to/filename.jpg') # - # If you do this, then Mail will take the file name and work out the mime type - # set the Content-Type, Content-Disposition, Content-Transfer-Encoding and - # base64 encode the contents of the attachment all for you. + # If you do this, then Mail will take the file name and work out the mime type. + # It will also set the Content-Type, Content-Disposition, Content-Transfer-Encoding + # and encode the contents of the attachment in Base64. # # You can also specify overrides if you want by passing a hash instead of a string: # - # mail.attachments['filename.jpg'] = {mime_type: 'application/x-gzip', + # mail.attachments['filename.jpg'] = {mime_type: 'application/gzip', # content: File.read('/path/to/filename.jpg')} # - # If you want to use a different encoding than Base64, you can pass an encoding in, - # but then it is up to you to pass in the content pre-encoded, and don't expect - # Mail to know how to decode this data: + # If you want to use encoding other than Base64 then you will need to pass encoding + # type along with the pre-encoded content as Mail doesn't know how to decode the + # data: # # file_content = SpecialEncode(File.read('/path/to/filename.jpg')) - # mail.attachments['filename.jpg'] = {mime_type: 'application/x-gzip', + # mail.attachments['filename.jpg'] = {mime_type: 'application/gzip', # encoding: 'SpecialEncoding', # content: file_content } # @@ -797,151 +835,183 @@ module ActionMailer # end # def mail(headers = {}, &block) - return @_message if @_mail_was_called && headers.blank? && !block - - m = @_message + return message if @_mail_was_called && headers.blank? && !block # At the beginning, do not consider class default for content_type content_type = headers[:content_type] - # Call all the procs (if any) - default_values = {} - self.class.default.each do |k,v| - default_values[k] = v.is_a?(Proc) ? instance_eval(&v) : v - end - - # Handle defaults - headers = headers.reverse_merge(default_values) - headers[:subject] ||= default_i18n_subject + headers = apply_defaults(headers) # Apply charset at the beginning so all fields are properly quoted - m.charset = charset = headers[:charset] + message.charset = charset = headers[:charset] # Set configure delivery behavior - wrap_delivery_behavior!(headers.delete(:delivery_method), headers.delete(:delivery_method_options)) + wrap_delivery_behavior!(headers[:delivery_method], headers[:delivery_method_options]) - # Assign all headers except parts_order, content_type and body - assignable = headers.except(:parts_order, :content_type, :body, :template_name, :template_path) - assignable.each { |k, v| m[k] = v } + assign_headers_to_message(message, headers) # Render the templates and blocks responses = collect_responses(headers, &block) @_mail_was_called = true - create_parts_from_responses(m, responses) + create_parts_from_responses(message, responses) # Setup content type, reapply charset and handle parts order - m.content_type = set_content_type(m, content_type, headers[:content_type]) - m.charset = charset + message.content_type = set_content_type(message, content_type, headers[:content_type]) + message.charset = charset - if m.multipart? - m.body.set_sort_order(headers[:parts_order]) - m.body.sort_parts! + if message.multipart? + message.body.set_sort_order(headers[:parts_order]) + message.body.sort_parts! end - m + message end - protected - - # Used by #mail to set the content type of the message. - # - # It will use the given +user_content_type+, or multipart if the mail - # message has any attachments. If the attachments are inline, the content - # type will be "multipart/related", otherwise "multipart/mixed". - # - # If there is no content type passed in via headers, and there are no - # attachments, or the message is multipart, then the default content type is - # used. - def set_content_type(m, user_content_type, class_default) - params = m.content_type_parameters || {} - case - when user_content_type.present? - user_content_type - when m.has_attachments? - if m.attachments.detect(&:inline?) - ["multipart", "related", params] + private + + # Used by #mail to set the content type of the message. + # + # It will use the given +user_content_type+, or multipart if the mail + # message has any attachments. If the attachments are inline, the content + # type will be "multipart/related", otherwise "multipart/mixed". + # + # If there is no content type passed in via headers, and there are no + # attachments, or the message is multipart, then the default content type is + # used. + def set_content_type(m, user_content_type, class_default) # :doc: + params = m.content_type_parameters || {} + case + when user_content_type.present? + user_content_type + when m.has_attachments? + if m.attachments.detect(&:inline?) + ["multipart", "related", params] + else + ["multipart", "mixed", params] + end + when m.multipart? + ["multipart", "alternative", params] else - ["multipart", "mixed", params] + m.content_type || class_default end - when m.multipart? - ["multipart", "alternative", params] - else - m.content_type || class_default end - end - # Translates the +subject+ using Rails I18n class under <tt>[mailer_scope, action_name]</tt> scope. - # If it does not find a translation for the +subject+ under the specified scope it will default to a - # humanized version of the <tt>action_name</tt>. - # If the subject has interpolations, you can pass them through the +interpolations+ parameter. - def default_i18n_subject(interpolations = {}) - mailer_scope = self.class.mailer_name.tr('/', '.') - I18n.t(:subject, interpolations.merge(scope: [mailer_scope, action_name], default: action_name.humanize)) - end + # Translates the +subject+ using Rails I18n class under <tt>[mailer_scope, action_name]</tt> scope. + # If it does not find a translation for the +subject+ under the specified scope it will default to a + # humanized version of the <tt>action_name</tt>. + # If the subject has interpolations, you can pass them through the +interpolations+ parameter. + def default_i18n_subject(interpolations = {}) # :doc: + mailer_scope = self.class.mailer_name.tr("/", ".") + I18n.t(:subject, interpolations.merge(scope: [mailer_scope, action_name], default: action_name.humanize)) + end + + # Emails do not support relative path links. + def self.supports_path? # :doc: + false + end + + def apply_defaults(headers) + default_values = self.class.default.map do |key, value| + [ + key, + compute_default(value) + ] + end.to_h + + headers_with_defaults = headers.reverse_merge(default_values) + headers_with_defaults[:subject] ||= default_i18n_subject + headers_with_defaults + end - def collect_responses(headers) #:nodoc: - responses = [] + def compute_default(value) + return value unless value.is_a?(Proc) + + if value.arity == 1 + instance_exec(self, &value) + else + instance_exec(&value) + end + end + + def assign_headers_to_message(message, headers) + assignable = headers.except(:parts_order, :content_type, :body, :template_name, + :template_path, :delivery_method, :delivery_method_options) + assignable.each { |k, v| message[k] = v } + end + + def collect_responses(headers) + if block_given? + collector = ActionMailer::Collector.new(lookup_context) { render(action_name) } + yield(collector) + collector.responses + elsif headers[:body] + collect_responses_from_text(headers) + else + collect_responses_from_templates(headers) + end + end - if block_given? - collector = ActionMailer::Collector.new(lookup_context) { render(action_name) } - yield(collector) - responses = collector.responses - elsif headers[:body] - responses << { + def collect_responses_from_text(headers) + [{ body: headers.delete(:body), - content_type: self.class.default[:content_type] || "text/plain" - } - else - templates_path = headers.delete(:template_path) || self.class.mailer_name - templates_name = headers.delete(:template_name) || action_name + content_type: headers[:content_type] || "text/plain" + }] + end - each_template(Array(templates_path), templates_name) do |template| - self.formats = template.formats + def collect_responses_from_templates(headers) + templates_path = headers[:template_path] || self.class.mailer_name + templates_name = headers[:template_name] || action_name - responses << { + each_template(Array(templates_path), templates_name).map do |template| + self.formats = template.formats + { body: render(template: template), content_type: template.type.to_s } end end - responses - end + def each_template(paths, name, &block) + templates = lookup_context.find_all(name, paths) + if templates.empty? + raise ActionView::MissingTemplate.new(paths, name, paths, false, "mailer") + else + templates.uniq(&:formats).each(&block) + end + end - def each_template(paths, name, &block) #:nodoc: - templates = lookup_context.find_all(name, paths) - if templates.empty? - raise ActionView::MissingTemplate.new(paths, name, paths, false, 'mailer') - else - templates.uniq(&:formats).each(&block) + def create_parts_from_responses(m, responses) + if responses.size == 1 && !m.has_attachments? + responses[0].each { |k, v| m[k] = v } + elsif responses.size > 1 && m.has_attachments? + container = Mail::Part.new + container.content_type = "multipart/alternative" + responses.each { |r| insert_part(container, r, m.charset) } + m.add_part(container) + else + responses.each { |r| insert_part(m, r, m.charset) } + end end - end - def create_parts_from_responses(m, responses) #:nodoc: - if responses.size == 1 && !m.has_attachments? - responses[0].each { |k,v| m[k] = v } - elsif responses.size > 1 && m.has_attachments? - container = Mail::Part.new - container.content_type = "multipart/alternative" - responses.each { |r| insert_part(container, r, m.charset) } - m.add_part(container) - else - responses.each { |r| insert_part(m, r, m.charset) } + def insert_part(container, response, charset) + response[:charset] ||= charset + part = Mail::Part.new(response) + container.add_part(part) end - end - def insert_part(container, response, charset) #:nodoc: - response[:charset] ||= charset - part = Mail::Part.new(response) - container.add_part(part) - end + # This and #instrument_name is for caching instrument + def instrument_payload(key) + { + mailer: mailer_name, + key: key + } + end - # Emails do not support relative path links. - def self.supports_path? - false - end + def instrument_name + "action_mailer" + end - ActiveSupport.run_load_hooks(:action_mailer, self) + ActiveSupport.run_load_hooks(:action_mailer, self) end end diff --git a/actionmailer/lib/action_mailer/collector.rb b/actionmailer/lib/action_mailer/collector.rb index e8883a8235..888410fa75 100644 --- a/actionmailer/lib/action_mailer/collector.rb +++ b/actionmailer/lib/action_mailer/collector.rb @@ -1,6 +1,8 @@ -require 'abstract_controller/collector' -require 'active_support/core_ext/hash/reverse_merge' -require 'active_support/core_ext/array/extract_options' +# frozen_string_literal: true + +require "abstract_controller/collector" +require "active_support/core_ext/hash/reverse_merge" +require "active_support/core_ext/array/extract_options" module ActionMailer class Collector diff --git a/actionmailer/lib/action_mailer/delivery_job.rb b/actionmailer/lib/action_mailer/delivery_job.rb index 52772af2d3..40f26d8ad1 100644 --- a/actionmailer/lib/action_mailer/delivery_job.rb +++ b/actionmailer/lib/action_mailer/delivery_job.rb @@ -1,13 +1,36 @@ -require 'active_job' +# frozen_string_literal: true + +require "active_job" module ActionMailer # The <tt>ActionMailer::DeliveryJob</tt> class is used when you # want to send emails outside of the request-response cycle. + # + # Exceptions are rescued and handled by the mailer class. class DeliveryJob < ActiveJob::Base # :nodoc: queue_as { ActionMailer::Base.deliver_later_queue_name } + rescue_from StandardError, with: :handle_exception_with_mailer_class + def perform(mailer, mail_method, delivery_method, *args) #:nodoc: mailer.constantize.public_send(mail_method, *args).send(delivery_method) end + + private + # "Deserialize" the mailer class name by hand in case another argument + # (like a Global ID reference) raised DeserializationError. + def mailer_class + if mailer = Array(@serialized_arguments).first || Array(arguments).first + mailer.constantize + end + end + + def handle_exception_with_mailer_class(exception) + if klass = mailer_class + klass.handle_exception exception + else + raise exception + end + end end end diff --git a/actionmailer/lib/action_mailer/delivery_methods.rb b/actionmailer/lib/action_mailer/delivery_methods.rb index 4758b55a2a..5cd62307e6 100644 --- a/actionmailer/lib/action_mailer/delivery_methods.rb +++ b/actionmailer/lib/action_mailer/delivery_methods.rb @@ -1,4 +1,6 @@ -require 'tmpdir' +# frozen_string_literal: true + +require "tmpdir" module ActionMailer # This module handles everything related to mail delivery, from registering @@ -7,25 +9,18 @@ module ActionMailer extend ActiveSupport::Concern included do - class_attribute :delivery_methods, :delivery_method - # Do not make this inheritable, because we always want it to propagate - cattr_accessor :raise_delivery_errors - self.raise_delivery_errors = true - - cattr_accessor :perform_deliveries - self.perform_deliveries = true - - cattr_accessor :deliver_later_queue_name - self.deliver_later_queue_name = :mailers + cattr_accessor :raise_delivery_errors, default: true + cattr_accessor :perform_deliveries, default: true + cattr_accessor :deliver_later_queue_name, default: :mailers - self.delivery_methods = {}.freeze - self.delivery_method = :smtp + class_attribute :delivery_methods, default: {}.freeze + class_attribute :delivery_method, default: :smtp add_delivery_method :smtp, Mail::SMTP, address: "localhost", port: 25, - domain: 'localhost.localdomain', + domain: "localhost.localdomain", user_name: nil, password: nil, authentication: nil, @@ -35,8 +30,8 @@ module ActionMailer location: defined?(Rails.root) ? "#{Rails.root}/tmp/mails" : "#{Dir.tmpdir}/mails" add_delivery_method :sendmail, Mail::Sendmail, - location: '/usr/sbin/sendmail', - arguments: '-i -t' + location: "/usr/sbin/sendmail", + arguments: "-i" add_delivery_method :test, Mail::TestMailer end @@ -51,15 +46,15 @@ module ActionMailer # # add_delivery_method :sendmail, Mail::Sendmail, # location: '/usr/sbin/sendmail', - # arguments: '-i -t' - def add_delivery_method(symbol, klass, default_options={}) + # arguments: '-i' + def add_delivery_method(symbol, klass, default_options = {}) class_attribute(:"#{symbol}_settings") unless respond_to?(:"#{symbol}_settings") send(:"#{symbol}_settings=", default_options) self.delivery_methods = delivery_methods.merge(symbol.to_sym => klass).freeze end - def wrap_delivery_behavior(mail, method=nil, options=nil) # :nodoc: - method ||= self.delivery_method + def wrap_delivery_behavior(mail, method = nil, options = nil) # :nodoc: + method ||= delivery_method mail.delivery_handler = self case method diff --git a/actionmailer/lib/action_mailer/gem_version.rb b/actionmailer/lib/action_mailer/gem_version.rb index ac79788cf0..72eb5d61e8 100644 --- a/actionmailer/lib/action_mailer/gem_version.rb +++ b/actionmailer/lib/action_mailer/gem_version.rb @@ -1,11 +1,13 @@ +# frozen_string_literal: true + module ActionMailer - # Returns the version of the currently loaded Action Mailer as a <tt>Gem::Version</tt> + # Returns the version of the currently loaded Action Mailer as a <tt>Gem::Version</tt>. def self.gem_version Gem::Version.new VERSION::STRING end module VERSION - MAJOR = 5 + MAJOR = 6 MINOR = 0 TINY = 0 PRE = "alpha" diff --git a/actionmailer/lib/action_mailer/inline_preview_interceptor.rb b/actionmailer/lib/action_mailer/inline_preview_interceptor.rb index 6d02b39225..2b97ac5b94 100644 --- a/actionmailer/lib/action_mailer/inline_preview_interceptor.rb +++ b/actionmailer/lib/action_mailer/inline_preview_interceptor.rb @@ -1,9 +1,11 @@ -require 'base64' +# frozen_string_literal: true + +require "base64" module ActionMailer # Implements a mailer preview interceptor that converts image tag src attributes - # that use inline cid: style urls to data: style urls so that they are visible - # when previewing a HTML email in a web browser. + # that use inline cid: style URLs to data: style URLs so that they are visible + # when previewing an HTML email in a web browser. # # This interceptor is enabled by default. To disable it, delete it from the # <tt>ActionMailer::Base.preview_interceptors</tt> array: @@ -11,7 +13,7 @@ module ActionMailer # ActionMailer::Base.preview_interceptors.delete(ActionMailer::InlinePreviewInterceptor) # class InlinePreviewInterceptor - PATTERN = /src=(?:"cid:[^"]+"|'cid:[^']+')/i + PATTERN = /src=(?:"cid:[^"]+"|'cid:[^']+')/i include Base64 @@ -26,7 +28,7 @@ module ActionMailer def transform! #:nodoc: return message if html_part.blank? - html_source.gsub!(PATTERN) do |match| + html_part.body = html_part.decoded.gsub(PATTERN) do |match| if part = find_part(match[9..-2]) %[src="#{data_url(part)}"] else @@ -38,24 +40,18 @@ module ActionMailer end private - def message - @message - end + attr_reader :message def html_part @html_part ||= message.html_part end - def html_source - html_part.body.raw_source - end - def data_url(part) "data:#{part.mime_type};base64,#{strict_encode64(part.body.raw_source)}" end def find_part(cid) - message.all_parts.find{ |p| p.attachment? && p.cid == cid } + message.all_parts.find { |p| p.attachment? && p.cid == cid } end end end diff --git a/actionmailer/lib/action_mailer/log_subscriber.rb b/actionmailer/lib/action_mailer/log_subscriber.rb index c2f671fdac..25c99342c2 100644 --- a/actionmailer/lib/action_mailer/log_subscriber.rb +++ b/actionmailer/lib/action_mailer/log_subscriber.rb @@ -1,4 +1,6 @@ -require 'active_support/log_subscriber' +# frozen_string_literal: true + +require "active_support/log_subscriber" module ActionMailer # Implements the ActiveSupport::LogSubscriber for logging notifications when @@ -7,8 +9,13 @@ module ActionMailer # An email was delivered. def deliver(event) info do - recipients = Array(event.payload[:to]).join(', ') - "\nSent mail to #{recipients} (#{event.duration.round(1)}ms)" + perform_deliveries = event.payload[:perform_deliveries] + recipients = Array(event.payload[:to]).join(", ") + if perform_deliveries + "Sent mail to #{recipients} (#{event.duration.round(1)}ms)" + else + "Skipped sending mail to #{recipients} as `perform_deliveries` is false" + end end debug { event.payload[:mail] } @@ -16,7 +23,7 @@ module ActionMailer # An email was received. def receive(event) - info { "\nReceived mail (#{event.duration.round(1)}ms)" } + info { "Received mail (#{event.duration.round(1)}ms)" } debug { event.payload[:mail] } end @@ -25,11 +32,11 @@ module ActionMailer debug do mailer = event.payload[:mailer] action = event.payload[:action] - "\n#{mailer}##{action}: processed outbound mail in #{event.duration.round(1)}ms" + "#{mailer}##{action}: processed outbound mail in #{event.duration.round(1)}ms" end end - # Use the logger configured for ActionMailer::Base + # Use the logger configured for ActionMailer::Base. def logger ActionMailer::Base.logger end diff --git a/actionmailer/lib/action_mailer/mail_helper.rb b/actionmailer/lib/action_mailer/mail_helper.rb index 239974e7b1..e7bed41f8d 100644 --- a/actionmailer/lib/action_mailer/mail_helper.rb +++ b/actionmailer/lib/action_mailer/mail_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActionMailer # Provides helper methods for ActionMailer::Base that can be used for easily # formatting messages, accessing mailer or message instances, and the @@ -54,7 +56,7 @@ module ActionMailer sentences = [[]] text.split.each do |word| - if sentences.first.present? && (sentences.last + [word]).join(' ').length > len + if sentences.first.present? && (sentences.last + [word]).join(" ").length > len sentences << [word] else sentences.last << word diff --git a/actionmailer/lib/action_mailer/message_delivery.rb b/actionmailer/lib/action_mailer/message_delivery.rb index ff2cb0fd01..a57980c322 100644 --- a/actionmailer/lib/action_mailer/message_delivery.rb +++ b/actionmailer/lib/action_mailer/message_delivery.rb @@ -1,9 +1,10 @@ -require 'delegate' +# frozen_string_literal: true -module ActionMailer +require "delegate" +module ActionMailer # The <tt>ActionMailer::MessageDelivery</tt> class is used by - # <tt>ActionMailer::Base</tt> when creating a new mailer. + # ActionMailer::Base when creating a new mailer. # <tt>MessageDelivery</tt> is a wrapper (+Delegator+ subclass) around a lazy # created <tt>Mail::Message</tt>. You can get direct access to the # <tt>Mail::Message</tt>, deliver the email or schedule the email to be sent @@ -14,25 +15,35 @@ module ActionMailer # Notifier.welcome(User.first).deliver_later # enqueue email delivery as a job through Active Job # Notifier.welcome(User.first).message # a Mail::Message object class MessageDelivery < Delegator - def initialize(mailer, mail_method, *args) #:nodoc: - @mailer = mailer - @mail_method = mail_method - @args = args + def initialize(mailer_class, action, *args) #:nodoc: + @mailer_class, @action, @args = mailer_class, action, args + + # The mail is only processed if we try to call any methods on it. + # Typical usage will leave it unloaded and call deliver_later. + @processed_mailer = nil + @mail_message = nil end + # Method calls are delegated to the Mail::Message that's ready to deliver. def __getobj__ #:nodoc: - @obj ||= @mailer.send(:new, @mail_method, *@args).message + @mail_message ||= processed_mailer.message end - def __setobj__(obj) #:nodoc: - @obj = obj + # Unused except for delegator internals (dup, marshalling). + def __setobj__(mail_message) #:nodoc: + @mail_message = mail_message end - # Returns the Mail::Message object + # Returns the resulting Mail::Message def message __getobj__ end + # Was the delegate loaded, causing the mailer action to be processed? + def processed? + @processed_mailer || @mail_message + end + # Enqueues the email to be delivered through Active Job. When the # job runs it will send the email using +deliver_now!+. That means # that the message will be sent bypassing checking +perform_deliveries+ @@ -47,7 +58,15 @@ module ActionMailer # * <tt>:wait</tt> - Enqueue the email to be delivered with a delay # * <tt>:wait_until</tt> - Enqueue the email to be delivered at (after) a specific date / time # * <tt>:queue</tt> - Enqueue the email on the specified queue - def deliver_later!(options={}) + # + # By default, the email will be enqueued using <tt>ActionMailer::DeliveryJob</tt>. Each + # <tt>ActionMailer::Base</tt> class can specify the job to use by setting the class variable + # +delivery_job+. + # + # class AccountRegistrationMailer < ApplicationMailer + # self.delivery_job = RegistrationDeliveryJob + # end + def deliver_later!(options = {}) enqueue_delivery :deliver_now!, options end @@ -60,10 +79,18 @@ module ActionMailer # # Options: # - # * <tt>:wait</tt> - Enqueue the email to be delivered with a delay - # * <tt>:wait_until</tt> - Enqueue the email to be delivered at (after) a specific date / time - # * <tt>:queue</tt> - Enqueue the email on the specified queue - def deliver_later(options={}) + # * <tt>:wait</tt> - Enqueue the email to be delivered with a delay. + # * <tt>:wait_until</tt> - Enqueue the email to be delivered at (after) a specific date / time. + # * <tt>:queue</tt> - Enqueue the email on the specified queue. + # + # By default, the email will be enqueued using <tt>ActionMailer::DeliveryJob</tt>. Each + # <tt>ActionMailer::Base</tt> class can specify the job to use by setting the class variable + # +delivery_job+. + # + # class AccountRegistrationMailer < ApplicationMailer + # self.delivery_job = RegistrationDeliveryJob + # end + def deliver_later(options = {}) enqueue_delivery :deliver_now, options end @@ -73,7 +100,9 @@ module ActionMailer # Notifier.welcome(User.first).deliver_now! # def deliver_now! - message.deliver! + processed_mailer.handle_exceptions do + message.deliver! + end end # Delivers an email: @@ -81,14 +110,35 @@ module ActionMailer # Notifier.welcome(User.first).deliver_now # def deliver_now - message.deliver + processed_mailer.handle_exceptions do + message.deliver + end end private + # Returns the processed Mailer instance. We keep this instance + # on hand so we can delegate exception handling to it. + def processed_mailer + @processed_mailer ||= @mailer_class.new.tap do |mailer| + mailer.process @action, *@args + end + end - def enqueue_delivery(delivery_method, options={}) - args = @mailer.name, @mail_method.to_s, delivery_method.to_s, *@args - ActionMailer::DeliveryJob.set(options).perform_later(*args) + def enqueue_delivery(delivery_method, options = {}) + if processed? + ::Kernel.raise "You've accessed the message before asking to " \ + "deliver it later, so you may have made local changes that would " \ + "be silently lost if we enqueued a job to deliver it. Why? Only " \ + "the mailer method *arguments* are passed with the delivery job! " \ + "Do not access the message in any way if you mean to deliver it " \ + "later. Workarounds: 1. don't touch the message before calling " \ + "#deliver_later, 2. only touch the message *within your mailer " \ + "method*, or 3. use a custom Active Job instead of #deliver_later." + else + args = @mailer_class.name, @action.to_s, delivery_method.to_s, *@args + job = @mailer_class.delivery_job + job.set(options).perform_later(*args) + end end end end diff --git a/actionmailer/lib/action_mailer/parameterized.rb b/actionmailer/lib/action_mailer/parameterized.rb new file mode 100644 index 0000000000..0fe417affe --- /dev/null +++ b/actionmailer/lib/action_mailer/parameterized.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +module ActionMailer + # Provides the option to parameterize mailers in order to share instance variable + # setup, processing, and common headers. + # + # Consider this example that does not use parameterization: + # + # class InvitationsMailer < ApplicationMailer + # def account_invitation(inviter, invitee) + # @account = inviter.account + # @inviter = inviter + # @invitee = invitee + # + # subject = "#{@inviter.name} invited you to their Basecamp (#{@account.name})" + # + # mail \ + # subject: subject, + # to: invitee.email_address, + # from: common_address(inviter), + # reply_to: inviter.email_address_with_name + # end + # + # def project_invitation(project, inviter, invitee) + # @account = inviter.account + # @project = project + # @inviter = inviter + # @invitee = invitee + # @summarizer = ProjectInvitationSummarizer.new(@project.bucket) + # + # subject = "#{@inviter.name.familiar} added you to a project in Basecamp (#{@account.name})" + # + # mail \ + # subject: subject, + # to: invitee.email_address, + # from: common_address(inviter), + # reply_to: inviter.email_address_with_name + # end + # + # def bulk_project_invitation(projects, inviter, invitee) + # @account = inviter.account + # @projects = projects.sort_by(&:name) + # @inviter = inviter + # @invitee = invitee + # + # subject = "#{@inviter.name.familiar} added you to some new stuff in Basecamp (#{@account.name})" + # + # mail \ + # subject: subject, + # to: invitee.email_address, + # from: common_address(inviter), + # reply_to: inviter.email_address_with_name + # end + # end + # + # InvitationsMailer.account_invitation(person_a, person_b).deliver_later + # + # Using parameterized mailers, this can be rewritten as: + # + # class InvitationsMailer < ApplicationMailer + # before_action { @inviter, @invitee = params[:inviter], params[:invitee] } + # before_action { @account = params[:inviter].account } + # + # default to: -> { @invitee.email_address }, + # from: -> { common_address(@inviter) }, + # reply_to: -> { @inviter.email_address_with_name } + # + # def account_invitation + # mail subject: "#{@inviter.name} invited you to their Basecamp (#{@account.name})" + # end + # + # def project_invitation + # @project = params[:project] + # @summarizer = ProjectInvitationSummarizer.new(@project.bucket) + # + # mail subject: "#{@inviter.name.familiar} added you to a project in Basecamp (#{@account.name})" + # end + # + # def bulk_project_invitation + # @projects = params[:projects].sort_by(&:name) + # + # mail subject: "#{@inviter.name.familiar} added you to some new stuff in Basecamp (#{@account.name})" + # end + # end + # + # InvitationsMailer.with(inviter: person_a, invitee: person_b).account_invitation.deliver_later + module Parameterized + extend ActiveSupport::Concern + + included do + attr_accessor :params + end + + module ClassMethods + # Provide the parameters to the mailer in order to use them in the instance methods and callbacks. + # + # InvitationsMailer.with(inviter: person_a, invitee: person_b).account_invitation.deliver_later + # + # See Parameterized documentation for full example. + def with(params) + ActionMailer::Parameterized::Mailer.new(self, params) + end + end + + class Mailer # :nodoc: + def initialize(mailer, params) + @mailer, @params = mailer, params + end + + private + def method_missing(method_name, *args) + if @mailer.action_methods.include?(method_name.to_s) + ActionMailer::Parameterized::MessageDelivery.new(@mailer, method_name, @params, *args) + else + super + end + end + + def respond_to_missing?(method, include_all = false) + @mailer.respond_to?(method, include_all) + end + end + + class MessageDelivery < ActionMailer::MessageDelivery # :nodoc: + def initialize(mailer_class, action, params, *args) + super(mailer_class, action, *args) + @params = params + end + + private + def processed_mailer + @processed_mailer ||= @mailer_class.new.tap do |mailer| + mailer.params = @params + mailer.process @action, *@args + end + end + + def enqueue_delivery(delivery_method, options = {}) + if processed? + super + else + args = @mailer_class.name, @action.to_s, delivery_method.to_s, @params, *@args + job = @mailer_class.parameterized_delivery_job + job.set(options).perform_later(*args) + end + end + end + + class DeliveryJob < ActionMailer::DeliveryJob # :nodoc: + def perform(mailer, mail_method, delivery_method, params, *args) + mailer.constantize.with(params).public_send(mail_method, *args).send(delivery_method) + end + end + end +end diff --git a/actionmailer/lib/action_mailer/preview.rb b/actionmailer/lib/action_mailer/preview.rb index 25ad7ee721..500b3bede0 100644 --- a/actionmailer/lib/action_mailer/preview.rb +++ b/actionmailer/lib/action_mailer/preview.rb @@ -1,4 +1,6 @@ -require 'active_support/descendants_tracker' +# frozen_string_literal: true + +require "active_support/descendants_tracker" module ActionMailer module Previews #:nodoc: @@ -15,13 +17,12 @@ module ActionMailer # # config.action_mailer.show_previews = true # - # Defaults to true for development environment + # Defaults to +true+ for development environment # mattr_accessor :show_previews, instance_writer: false # :nodoc: - mattr_accessor :preview_interceptors, instance_writer: false - self.preview_interceptors = [ActionMailer::InlinePreviewInterceptor] + mattr_accessor :preview_interceptors, instance_writer: false, default: [ActionMailer::InlinePreviewInterceptor] end module ClassMethods @@ -30,29 +31,53 @@ module ActionMailer interceptors.flatten.compact.each { |interceptor| register_preview_interceptor(interceptor) } end + # Unregister one or more previously registered Interceptors. + def unregister_preview_interceptors(*interceptors) + interceptors.flatten.compact.each { |interceptor| unregister_preview_interceptor(interceptor) } + end + # Register an Interceptor which will be called before mail is previewed. # Either a class or a string can be passed in as the Interceptor. If a - # string is passed in it will be <tt>constantize</tt>d. + # string is passed in it will be constantized. def register_preview_interceptor(interceptor) - preview_interceptor = case interceptor + preview_interceptor = interceptor_class_for(interceptor) + + unless preview_interceptors.include?(preview_interceptor) + preview_interceptors << preview_interceptor + end + end + + # Unregister a previously registered Interceptor. + # Either a class or a string can be passed in as the Interceptor. If a + # string is passed in it will be constantized. + def unregister_preview_interceptor(interceptor) + preview_interceptors.delete(interceptor_class_for(interceptor)) + end + + private + + def interceptor_class_for(interceptor) + case interceptor when String, Symbol interceptor.to_s.camelize.constantize else interceptor end - - unless preview_interceptors.include?(preview_interceptor) - preview_interceptors << preview_interceptor end - end end end class Preview extend ActiveSupport::DescendantsTracker + attr_reader :params + + def initialize(params = {}) + @params = params + end + class << self - # Returns all mailer preview classes + # Returns all mailer preview classes. def all load_previews if descendants.empty? descendants @@ -61,54 +86,54 @@ module ActionMailer # Returns the mail object for the given email name. The registered preview # interceptors will be informed so that they can transform the message # as they would if the mail was actually being delivered. - def call(email) - preview = self.new + def call(email, params = {}) + preview = new(params) message = preview.public_send(email) inform_preview_interceptors(message) message end - # Returns all of the available email previews + # Returns all of the available email previews. def emails public_instance_methods(false).map(&:to_s).sort end - # Returns true if the email exists + # Returns +true+ if the email exists. def email_exists?(email) emails.include?(email) end - # Returns true if the preview exists + # Returns +true+ if the preview exists. def exists?(preview) - all.any?{ |p| p.preview_name == preview } + all.any? { |p| p.preview_name == preview } end - # Find a mailer preview by its underscored class name + # Find a mailer preview by its underscored class name. def find(preview) - all.find{ |p| p.preview_name == preview } + all.find { |p| p.preview_name == preview } end - # Returns the underscored name of the mailer preview without the suffix + # Returns the underscored name of the mailer preview without the suffix. def preview_name - name.sub(/Preview$/, '').underscore + name.sub(/Preview$/, "").underscore end - protected - def load_previews #:nodoc: + private + def load_previews if preview_path - Dir["#{preview_path}/**/*_preview.rb"].each{ |file| require_dependency file } + Dir["#{preview_path}/**/*_preview.rb"].sort.each { |file| require_dependency file } end end - def preview_path #:nodoc: + def preview_path Base.preview_path end - def show_previews #:nodoc: + def show_previews Base.show_previews end - def inform_preview_interceptors(message) #:nodoc: + def inform_preview_interceptors(message) Base.preview_interceptors.each do |interceptor| interceptor.previewing_email(message) end diff --git a/actionmailer/lib/action_mailer/railtie.rb b/actionmailer/lib/action_mailer/railtie.rb index bebcf4de01..bb6141b406 100644 --- a/actionmailer/lib/action_mailer/railtie.rb +++ b/actionmailer/lib/action_mailer/railtie.rb @@ -1,4 +1,6 @@ -require 'active_job/railtie' +# frozen_string_literal: true + +require "active_job/railtie" require "action_mailer" require "rails" require "abstract_controller/railties/routes_helpers" @@ -16,13 +18,19 @@ module ActionMailer paths = app.config.paths options = app.config.action_mailer + if app.config.force_ssl + options.default_url_options ||= {} + options.default_url_options[:protocol] ||= "https" + end + options.assets_dir ||= paths["public"].first options.javascripts_dir ||= paths["public/javascripts"].first options.stylesheets_dir ||= paths["public/stylesheets"].first options.show_previews = Rails.env.development? if options.show_previews.nil? + options.cache_store ||= Rails.cache if options.show_previews - options.preview_path ||= defined?(Rails.root) ? "#{Rails.root}/test/mailers/previews" : nil + options.preview_path ||= defined?(Rails.root) ? "#{Rails.root}/test/mailers/previews" : nil end # make sure readers methods get compiled @@ -38,14 +46,12 @@ module ActionMailer register_preview_interceptors(options.delete(:preview_interceptors)) register_observers(options.delete(:observers)) - options.each { |k,v| send("#{k}=", v) } + options.each { |k, v| send("#{k}=", v) } + end - if options.show_previews - app.routes.prepend do - get '/rails/mailers' => "rails/mailers#index" - get '/rails/mailers/*path' => "rails/mailers#preview" - end - end + ActiveSupport.on_load(:action_dispatch_integration_test) do + include ActionMailer::TestHelper + include ActionMailer::TestCase::ClearTestDeliveries end end @@ -55,9 +61,24 @@ module ActionMailer end end - config.after_initialize do - if ActionMailer::Base.preview_path - ActiveSupport::Dependencies.autoload_paths << ActionMailer::Base.preview_path + initializer "action_mailer.eager_load_actions" do + ActiveSupport.on_load(:after_initialize) do + ActionMailer::Base.descendants.each(&:action_methods) if config.eager_load + end + end + + config.after_initialize do |app| + options = app.config.action_mailer + + if options.show_previews + app.routes.prepend do + get "/rails/mailers" => "rails/mailers#index", internal: true + get "/rails/mailers/*path" => "rails/mailers#preview", internal: true + end + + if options.preview_path + ActiveSupport::Dependencies.autoload_paths << options.preview_path + end end end end diff --git a/actionmailer/lib/action_mailer/rescuable.rb b/actionmailer/lib/action_mailer/rescuable.rb new file mode 100644 index 0000000000..5b567eb500 --- /dev/null +++ b/actionmailer/lib/action_mailer/rescuable.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module ActionMailer #:nodoc: + # Provides +rescue_from+ for mailers. Wraps mailer action processing, + # mail job processing, and mail delivery. + module Rescuable + extend ActiveSupport::Concern + include ActiveSupport::Rescuable + + class_methods do + def handle_exception(exception) #:nodoc: + rescue_with_handler(exception) || raise(exception) + end + end + + def handle_exceptions #:nodoc: + yield + rescue => exception + rescue_with_handler(exception) || raise + end + + private + def process(*) + handle_exceptions do + super + end + end + end +end diff --git a/actionmailer/lib/action_mailer/test_case.rb b/actionmailer/lib/action_mailer/test_case.rb index 766215ce96..ee5a864847 100644 --- a/actionmailer/lib/action_mailer/test_case.rb +++ b/actionmailer/lib/action_mailer/test_case.rb @@ -1,16 +1,35 @@ -require 'active_support/test_case' -require 'rails-dom-testing' +# frozen_string_literal: true + +require "active_support/test_case" +require "rails-dom-testing" module ActionMailer class NonInferrableMailerError < ::StandardError def initialize(name) - super "Unable to determine the mailer to test from #{name}. " + - "You'll need to specify it using tests YourMailer in your " + + super "Unable to determine the mailer to test from #{name}. " \ + "You'll need to specify it using tests YourMailer in your " \ "test case definition" end end class TestCase < ActiveSupport::TestCase + module ClearTestDeliveries + extend ActiveSupport::Concern + + included do + setup :clear_test_deliveries + teardown :clear_test_deliveries + end + + private + + def clear_test_deliveries + if ActionMailer::Base.delivery_method == :test + ActionMailer::Base.deliveries.clear + end + end + end + module Behavior extend ActiveSupport::Concern @@ -24,6 +43,7 @@ module ActionMailer setup :initialize_test_deliveries setup :set_expected_mail teardown :restore_test_deliveries + ActiveSupport.run_load_hooks(:action_mailer_test_case, self) end module ClassMethods @@ -39,7 +59,7 @@ module ActionMailer end def mailer_class - if mailer = self._mailer_class + if mailer = _mailer_class mailer else tests determine_default_mailer(name) @@ -55,18 +75,18 @@ module ActionMailer end end - protected + private def initialize_test_deliveries set_delivery_method :test @old_perform_deliveries = ActionMailer::Base.perform_deliveries ActionMailer::Base.perform_deliveries = true + ActionMailer::Base.deliveries.clear end def restore_test_deliveries restore_delivery_method ActionMailer::Base.perform_deliveries = @old_perform_deliveries - ActionMailer::Base.deliveries.clear end def set_delivery_method(method) @@ -75,17 +95,16 @@ module ActionMailer end def restore_delivery_method + ActionMailer::Base.deliveries.clear ActionMailer::Base.delivery_method = @old_delivery_method end def set_expected_mail @expected = Mail.new @expected.content_type ["text", "plain", { "charset" => charset }] - @expected.mime_version = '1.0' + @expected.mime_version = "1.0" end - private - def charset "UTF-8" end @@ -95,7 +114,7 @@ module ActionMailer end def read_fixture(action) - IO.readlines(File.join(Rails.root, 'test', 'fixtures', self.class.mailer_class.name.underscore, action)) + IO.readlines(File.join(Rails.root, "test", "fixtures", self.class.mailer_class.name.underscore, action)) end end diff --git a/actionmailer/lib/action_mailer/test_helper.rb b/actionmailer/lib/action_mailer/test_helper.rb index 4d03a616d2..a4751916af 100644 --- a/actionmailer/lib/action_mailer/test_helper.rb +++ b/actionmailer/lib/action_mailer/test_helper.rb @@ -1,8 +1,10 @@ -require 'active_job' +# frozen_string_literal: true + +require "active_job" module ActionMailer # Provides helper methods for testing Action Mailer, including #assert_emails - # and #assert_no_emails + # and #assert_no_emails. module TestHelper include ActiveJob::TestHelper @@ -26,13 +28,13 @@ module ActionMailer # # assert_emails 2 do # ContactMailer.welcome.deliver_now - # ContactMailer.welcome.deliver_now + # ContactMailer.welcome.deliver_later # end # end - def assert_emails(number) + def assert_emails(number, &block) if block_given? original_count = ActionMailer::Base.deliveries.size - yield + perform_enqueued_jobs(only: [ActionMailer::DeliveryJob, ActionMailer::Parameterized::DeliveryJob], &block) new_count = ActionMailer::Base.deliveries.size assert_equal number, new_count - original_count, "#{number} emails expected, but #{new_count - original_count} were sent" else @@ -40,7 +42,7 @@ module ActionMailer end end - # Assert that no emails have been sent. + # Asserts that no emails have been sent. # # def test_emails # assert_no_emails @@ -58,7 +60,7 @@ module ActionMailer # # Note: This assertion is simply a shortcut for: # - # assert_emails 0 + # assert_emails 0, &block def assert_no_emails(&block) assert_emails 0, &block end @@ -88,7 +90,49 @@ module ActionMailer # end # end def assert_enqueued_emails(number, &block) - assert_enqueued_jobs number, only: ActionMailer::DeliveryJob, &block + assert_enqueued_jobs number, only: [ ActionMailer::DeliveryJob, ActionMailer::Parameterized::DeliveryJob ], &block + end + + # Asserts that a specific email has been enqueued, optionally + # matching arguments. + # + # def test_email + # ContactMailer.welcome.deliver_later + # assert_enqueued_email_with ContactMailer, :welcome + # end + # + # def test_email_with_arguments + # ContactMailer.welcome("Hello", "Goodbye").deliver_later + # assert_enqueued_email_with ContactMailer, :welcome, args: ["Hello", "Goodbye"] + # end + # + # If a block is passed, that block should cause the specified email + # to be enqueued. + # + # def test_email_in_block + # assert_enqueued_email_with ContactMailer, :welcome do + # ContactMailer.welcome.deliver_later + # end + # end + # + # If +args+ is provided as a Hash, a parameterized email is matched. + # + # def test_parameterized_email + # assert_enqueued_email_with ContactMailer, :welcome, + # args: {email: 'user@example.com'} do + # ContactMailer.with(email: 'user@example.com').welcome.deliver_later + # end + # end + def assert_enqueued_email_with(mailer, method, args: nil, queue: "mailers", &block) + if args.is_a? Hash + job = ActionMailer::Parameterized::DeliveryJob + args = [mailer.to_s, method.to_s, "deliver_now", args] + else + job = ActionMailer::DeliveryJob + args = [mailer.to_s, method.to_s, "deliver_now", *args] + end + + assert_enqueued_with(job: job, args: args, queue: queue, &block) end # Asserts that no emails are enqueued for later delivery. @@ -107,7 +151,7 @@ module ActionMailer # end # end def assert_no_enqueued_emails(&block) - assert_no_enqueued_jobs only: ActionMailer::DeliveryJob, &block + assert_no_enqueued_jobs only: [ ActionMailer::DeliveryJob, ActionMailer::Parameterized::DeliveryJob ], &block end end end diff --git a/actionmailer/lib/action_mailer/version.rb b/actionmailer/lib/action_mailer/version.rb index 06f80a8fdc..4549d6eb57 100644 --- a/actionmailer/lib/action_mailer/version.rb +++ b/actionmailer/lib/action_mailer/version.rb @@ -1,4 +1,6 @@ -require_relative 'gem_version' +# frozen_string_literal: true + +require_relative "gem_version" module ActionMailer # Returns the version of the currently loaded Action Mailer as a diff --git a/actionmailer/lib/rails/generators/mailer/mailer_generator.rb b/actionmailer/lib/rails/generators/mailer/mailer_generator.rb index 3ec7d3d896..c37a74c762 100644 --- a/actionmailer/lib/rails/generators/mailer/mailer_generator.rb +++ b/actionmailer/lib/rails/generators/mailer/mailer_generator.rb @@ -1,24 +1,37 @@ +# frozen_string_literal: true + module Rails module Generators class MailerGenerator < NamedBase - source_root File.expand_path("../templates", __FILE__) + source_root File.expand_path("templates", __dir__) argument :actions, type: :array, default: [], banner: "method method" check_class_collision suffix: "Mailer" def create_mailer_file - template "mailer.rb", File.join('app/mailers', class_path, "#{file_name}_mailer.rb") - if self.behavior == :invoke - template "application_mailer.rb", 'app/mailers/application_mailer.rb' + template "mailer.rb", File.join("app/mailers", class_path, "#{file_name}_mailer.rb") + + in_root do + if behavior == :invoke && !File.exist?(application_mailer_file_name) + template "application_mailer.rb", application_mailer_file_name + end end end hook_for :template_engine, :test_framework - protected - def file_name - @_file_name ||= super.gsub(/\_mailer/i, '') + private + def file_name # :doc: + @_file_name ||= super.sub(/_mailer\z/i, "") + end + + def application_mailer_file_name + @_application_mailer_file_name ||= if mountable_engine? + "app/mailers/#{namespaced_path}/application_mailer.rb" + else + "app/mailers/application_mailer.rb" + end end end end diff --git a/actionmailer/lib/rails/generators/mailer/templates/application_mailer.rb b/actionmailer/lib/rails/generators/mailer/templates/application_mailer.rb deleted file mode 100644 index d25d8892dd..0000000000 --- a/actionmailer/lib/rails/generators/mailer/templates/application_mailer.rb +++ /dev/null @@ -1,4 +0,0 @@ -class ApplicationMailer < ActionMailer::Base - default from: "from@example.com" - layout 'mailer' -end diff --git a/actionmailer/lib/rails/generators/mailer/templates/application_mailer.rb.tt b/actionmailer/lib/rails/generators/mailer/templates/application_mailer.rb.tt new file mode 100644 index 0000000000..00fb9bd48f --- /dev/null +++ b/actionmailer/lib/rails/generators/mailer/templates/application_mailer.rb.tt @@ -0,0 +1,6 @@ +<% module_namespacing do -%> +class ApplicationMailer < ActionMailer::Base + default from: 'from@example.com' + layout 'mailer' +end +<% end %> diff --git a/actionmailer/lib/rails/generators/mailer/templates/mailer.rb b/actionmailer/lib/rails/generators/mailer/templates/mailer.rb.tt index 348d314758..348d314758 100644 --- a/actionmailer/lib/rails/generators/mailer/templates/mailer.rb +++ b/actionmailer/lib/rails/generators/mailer/templates/mailer.rb.tt |