Deep in Rails: ActionMailer#deliver Part I

Posted by admin 15/11/2009 at 14h59

Welcome to the first in a series of articles that will delve deep into several commonly used methods of Rails. The first method we will look at is Actionmailer#deliver. Now, I am going to assume that you know what AM is and how to use. You’ve generated mailer models before and set up views and called these methods via something like: MyMailer.deliver_some_message(insert, your, params, here). What we are going to to look at is what happens when you make the deliver call.

We will be skipping what happens around rails (the ruby core goings-ons) and all the method stacking that goes on in ruby and focus on the rails part in this series. So let’s get started. (We will be using Rails 2.3.2 as the guide for the code)

What’s happening now?

When deliver_some_message is executed, the first method that is fired is AR’s class method method_missing. Here’s the method that gets executed:

(line 391 action_mailer/base.rb)
class << self
  ...
  def method_missing(method_symbol, *parameters) #:nodoc:
    if match = matches_dynamic_method?(method_symbol)
      case match[1]
        when 'create'  then new(match[2], *parameters).mail
        when 'deliver' then new(match[2], *parameters).deliver!
        when 'new'     then nil
      else super
      end
    else
      super
    end
  end
  ...
end

So when you call deliver_some_message(params), ruby passes all that info (method name included) to the method_missing method. I have created a simple Mailer for this series to help us see what’s going on throughout this entire chain of methods and to see each of the params for these methods.

Here is the quick mailer I created:

class MessageMailer < ActionMailer::Base
  def contact_form(msg_from, message)
    subject    'Im contacting you'
    recipients "someemail@somedomain.com"
    from       msg_from
    sent_on    Time.now
    body       :message => message
  end
end

So when I call

MessageMailer.deliver_contact_form("jake@somewhere.com", "Send this message")

the params end up being distributed like so:

method_symbol => :deliver_contact_form
parameters => ["jake@somewhere.com", "Send this message"]

Now that we know what’s being passed, let’s see what happens to it. The first line of the method is

if match = matches_dynamic_method?(method_symbol)

matches_dynamic_method? is a private method in AM and is defined as:

(line 441 action_mailer/base.rb)
private
  def matches_dynamic_method?(method_name) #:nodoc:
    method_name = method_name.to_s
    /^(create|deliver)_([_a-z]\w*)/.match(method_name) || /^(new)$/.match(method_name)
  end

This is pretty simple, we are doing a scan across the method name to match it against create_ or deliver_ and then capturing the rest of the method name or we are looking strictly for new at the beginning of our method name and with nothing else attached. The reason this is done is to disable AM’s initialize capabilities for outside referencing and only offer create and deliver. On line 254 in AR base.rb we see that we have privatize the method new with:

private_class_method :new #:nodoc:

So if someone tries to be clever and call MessageMailer.new, it will hit the method_missing (since the base new method has now been made private) and end up returning nil.

The match method returns a Match object that contains the captures. With our example the match variable would look like:

match.captures => ["deliver", "contact_form"]
match[1] => deliver
match[2] => contact_form

So now we got a match and we can continue thru the case statement. Since match[1] in our example returns deliver, we execute the line

when 'deliver' then new(match[2], *parameters).deliver!

Here, we call the private new method to initialize a new AR instance and we pass it the second part of our capture and all the params that came to method_missing originally. After initializing, the method deliver! is then called (which we will talk about after we talk about what happens during initialization).

Let’s go ahead and see what new looks like (or more precisely, initialize, since you dont create a new method, you create the initialize method which new calls). AR’s initialize method looks like this:

(line 452 action_mailer/base.rb)
def initialize(method_name=nil, *parameters) #:nodoc:
  create!(method_name, *parameters) if method_name
end

This initialize is quite simple, it calls create! if method_name is present. In our case it will be since we passed “contact_form” to it. So let’s go to create! which is also right below it in the source:

   
(line 458 action_mailer/base.rb)
def create!(method_name, *parameters) #:nodoc:
  initialize_defaults(method_name)
  __send__(method_name, *parameters)

  unless String === @body
    if @parts.empty?
      Dir.glob("#{template_path}/#{@template}.*").each do |path|
        template = template_root["#{mailer_name}/#{File.basename(path)}"]
        
        next unless template && template.multipart?
        
        @parts << Part.new(
          :content_type => template.content_type,
          :disposition => "inline",
          :charset => charset,
          :body => render_message(template, @body)
        )
      end
      unless @parts.empty?
        @content_type = "multipart/alternative" if @content_type !~ /^multipart/
        @parts = sort_parts(@parts, @implicit_parts_order)
      end
    end
    
    template_exists = @parts.empty?
    template_exists ||= template_root["#{mailer_name}/#{@template}"]
    @body = render_message(@template, @body) if template_exists
    
    if !@parts.empty? && String === @body
      @parts.unshift Part.new(:charset => charset, :body => @body)
      @body = nil
    end
  end
  
  @mime_version ||= "1.0" if !@parts.empty?
  
  @mail = create_mail
end

Whoa! Don’t worry, it’s not that bad. Let’s take it line by line. Note: I have removed the comments from the above snippet because they wouldnt format in textile properly.

First, let’s figure out what we have in the method:

method_name => "contact_form"
parameters => ["jake@somewhere.com", "Send this message"]

Cool, now we know what we have going in, so let’s step through it. First we have: initialize_defaults(method_name) which is a private method in AM

which looks like:

(line 537 action_mailer/base.rb)
def initialize_defaults(method_name)
  @charset ||= @@default_charset.dup
  @content_type ||= @@default_content_type.dup
  @implicit_parts_order ||= @@default_implicit_parts_order.dup
  @template ||= method_name
  @default_template_name = @action_name = @template
  @mailer_name ||= self.class.name.underscore
  @parts ||= []
  @headers ||= {}
  @body ||= {}
  @mime_version = @@default_mime_version.dup if @@default_mime_version
end

This method initializes several of the attribute accessors, or in this case advanced attribute accessors, with defaults and some class attribute accessors. The advanced attribute accessors are just like regular attribute accessors except they add one extra ability which you have seen several times in your use of ActionMailer models. Take a look at our simple mailer again:

class MessageMailer < ActionMailer::Base
  def contact_form(msg_from, message)
    subject    'Im contacting you'
    ...
  end
end

Do you see it? Notice, how we can make the call:

subject    'Im contacting you'

This is not a normal attribute accessor call. Normally, you can only call

obj.some_attribute

or

obj.some_attribute =  "some stuff"

The advanced attribute accessor gives you a third option of setting the attribute via a regular method call like:

obj.some_attribute("some stuff")

or, more elegantly:

obj.some_attribute "some stuff"

instead of the regular ol’

obj.some_attribute = "some stuff"

Let’s see how AM accomplishes this. Take a look at action_mailer/adv_attr_accessor.rb:

  
module ActionMailer
  module AdvAttrAccessor #:nodoc:
    def self.included(base)
      base.extend(ClassMethods)
    end

    module ClassMethods #:nodoc:
      def adv_attr_accessor(*names)
        names.each do |name|
          ivar = "@#{name}"

          define_method("#{name}=") do |value|
            instance_variable_set(ivar, value)
          end

          define_method(name) do |*parameters|
            raise ArgumentError, "expected 0 or 1 parameters" unless parameters.length <= 1
            if parameters.empty?
              if instance_variable_names.include?(ivar)
                instance_variable_get(ivar)
              end
            else
              instance_variable_set(ivar, parameters.first)
            end
          end
        end
      end
    end
  end
end

The main part of all of that is the adv_attr_accessor(*names) definition. What Rails is doing here is going over each name (or attribute) you want to create and then doing the following:

  • Dynamically creating an instance variable name.
  • Dynamically creating a method= method (or setter method) for the attribute.
  • Dynamically creating a method method (or getter method) for the attribute, but (and this is where the magic is) adding the ability to have 0 or 1 parameters (instead of the normal 0 parameters that Ruby normally provides). If the parameters to the method is 0 then it just gets the instance variable (attribute) as normal, if it is 1, then it sets the instance variable to the parameter (like the normal method= method does).

That’s how we get the sugar coated ability to write obj.some_attribute "some stuff" for our AM methods.

Ok, now back to initialize_defaults. So we see the attributes getting initialized, let’s see how our example would initialize:

method_name           => contact_form
charset               => "utf-8"
content_type          => "text/plain"
implicit_parts_order  => ["text/html", "text/enriched", "text/plain"]
template.inspect      => "contact_form"
default_template_name => "contact_form"
action_name           => contact_form
mailer_name           => "message_mailer"
parts                 => []
headers               => {}
body                  => {}
mime_version          => "1.0"

Cool, now that we got that all initialized, let’s keep going. Back to create!:

(line 458 action_mailer/base.rb)
def create!(method_name, *parameters) #:nodoc:
  initialize_defaults(method_name)
  __send__(method_name, *parameters)
  ...
end

The next line calls __send__ with the method_name and parameters that we passed in originally. The reason __send__ is called is just in case you override the normal send method. This is a nice lesson for you newcomers to Rails. DONT OVERRIDE CORE RUBY METHODS! Sorry for the yelling.

But, since we cant predict everything, more specifically, we cant predict what new rails developers will do with AM, we use the __send__ method the make sure we dynamically call method_name with the parameters and not an overridden send method in your customized AM class.

In our example, we have the following arguments to send:

method_name => "contact_form"
parameters => ["jake@somewhere.com", "Send this message"]

So Ruby sends the message “contact_form” to our instantiated AM model and since we defined contact_form in our mailer model Ruby will execute this method now. Let’s look at our mailer again:

class MessageMailer < ActionMailer::Base
  def contact_form(msg_from, message)
    subject    'Im contacting you'
    recipients "someemail@somedomain.com"
    from       msg_from
    sent_on    Time.now
    body       :message => message
  end
end

Now that we have seen how the adv_attr_accessors work we can see that all that our contact_form method (or any AM deliver method for that matter) is doing is setting all the proper attributes. Your view is just an HTML or plain text version of your email with some fancy ERB parsing to help make it dynamic.

Let’s keep going. We have just set several attributes using the dynamic __send__ method to call our contact_form method in our AM model. Now we are back to the create! method again:

   
(line 458 action_mailer/base.rb)
def create!(method_name, *parameters) #:nodoc:
  initialize_defaults(method_name)
  __send__(method_name, *parameters)

  unless String === @body
    if @parts.empty?
      Dir.glob("#{template_path}/#{@template}.*").each do |path|
        template = template_root["#{mailer_name}/#{File.basename(path)}"]
        
        next unless template && template.multipart?
        
        @parts << Part.new(
          :content_type => template.content_type,
          :disposition => "inline",
          :charset => charset,
          :body => render_message(template, @body)
        )
      end
      unless @parts.empty?
        @content_type = "multipart/alternative" if @content_type !~ /^multipart/
        @parts = sort_parts(@parts, @implicit_parts_order)
      end
    end
    ...
  end
  ...
end

The next thing we do is check to see whether our body attribute is a String. If not, we are going to do some processing. Let’s take a look and see what attributes we have set with our example:

@parts           => nil
@template        => "contact_form"

Now we know that (in our example) parts is empty and we are going to enter into that conditional statement. Now we are going to find all template views that match our template “contact_form”. template_root is a class method on AM that returns the view_path root (usually apps/views/). This method is copied from ActionView::Base and the root is set there.

Next, we will set the variable template to be the value of template_root when the key is the full path (with the mailer_name, in this case “message_mailer”) to the erb or rhtml file. What we get back, is an instance of ActionView::ReloadableTemplate wrapped around our view file. Let’s see that with our example:

template  => 
  #<ActionView::ReloadableTemplate:0x34a4a68 
    @base_path="message_mailer", 
    @template_path="message_mailer/contact_form.erb", 
    @_memoized_path=["message_mailer/contact_form.erb"], 
    @name="contact_form", 
    @filename="/example_app/app/views/message_mailer/contact_form.erb", 
    ...
    @load_path="/example_app/app/views">

Next, we see that we skip over any further processing unless we have a multipart message or have multiple views for our mailer method (thereby making it a multipart message). That takes us out of the loop (for our example) and down to

unless @parts.empty?
  @content_type = "multipart/alternative" if @content_type !~ /^multipart/
  @parts = sort_parts(@parts, @implicit_parts_order)
end

In our example, @parts is empty so we are pretty much done with this area. Let’s take a look and see what happens next:


def create!(method_name, *parameters) #:nodoc: ... unless String === @body if @parts.empty? ... end template_exists = @parts.empty? template_exists ||= template_root["#{mailer_name}/#{@template}"] @body = render_message(@template, @body) if template_exists ... end end

Basically, what we are doing here is making sure we have either an implied template or dynamically generated template. This is mainly for multipart messages. In our example, since there are still no parts, template_exist gets set to true. Since it is already true, the next line gets skipped and we go directly to rendering the message into our protected attribute @body. Let’s see what render_message looks like:

(line 553 action_mailer/base.rb)
def render_message(method_name, body)
  if method_name.respond_to?(:content_type)
    @current_template_content_type = method_name.content_type
  end
  render :file => method_name, :body => body
ensure
  @current_template_content_type = nil
end

When we call render_message in create!, we call it with @template and @body, let’s see what those get set to inside of render_message for our example:

@template   = method_name     => "contact_form"
@body       = body            => {:message=>"Send this message"}

At the beginning of render_message @body is set to the hash of instance variables that we set in our contact_form method in our mailer. Once we are finished processing, that hash will be gone and @body will be replaced with the full rendered message.

That’s it for now. Stay tuned for next week for Part II where we will pick back up and continue our trek through ActionMailer#deliver and get real deep into rendering.

This entry was posted on 15/11/2009 at 14h59 and Posted in . You can follow any response to this entry through the Atom feed. You can leave a comment or a trackback from your own site.

Tags , , , ,


Trackbacks

Use the following link to trackback from your own site:
http://blog.flvorful.com/trackbacks?article_id=12

Comments

Leave a comment