Screencast - ActionMailer

We are going to add e-mail capability to the “booksindex” application:

Sending Mail

The basic idea of sending mail is to create a model class which subclasses ActionMailer::Base, and some views that render the mail messages. Notice that mail delivery is commenced by calling a method on a model. A lot of people don’t think this is the best architecture, but let that be.

When sending e-mail we will do it just like the book, except that we will use a plugin to provide for collecting e-mail from GMail. GMail requires TLS (encryption) support, which is not provided in Rails until version 2.2, and then only if you’re on Ruby 1.8.7 (see http://guides.rubyonrails.org/2_2_release_notes.html#_action_mailer)

Mail settings can be “global” for all configurations, or we can define separate e-mail settings for development vs. production vs. test. We are going to set up e-mail for the development environment - the file containing our settings will be config/environments/development.rb. You would want to set up these parameters for production mode as well if you want mail to work in production. If you wanted to set up the same settings for all configurations, then you would put these config items in config/environment.rb (this is exactly what I did in the Restful Authentication screencast.)

For TLS support, we need to:


script/plugin install http://code.openrain.com/rails/action_mailer_tls/

After you do this, you should inspect the files. Plug-ins go under your “vendor” directory. Plugins are loaded by scanning each top-level plugin directory for a script called init.rb. This script typically loads classes, which is what happens with this TLS plugin. If we look at the script that gets loaded (lib/smtp_tls.rb) we can see that code augments Net::SMTP. If you like, you can compare this code with the original, which is in a directory such as /opt/local/lib/ruby/1.8/net/smtp.rb (look in your Ruby root on Windows). Basically, the new code checks the remote server to see if it wants to turn on TLS, and if it does, it sets that up. This is essentially a “monkeypatch” of the core code.

In config/environments/development.rb we need to add:


config.action_mailer.delivery_method = :smtp

config.action_mailer.smtp_settings = {
  :address        => 'smtp.gmail.com',
  :port           => 587,
  :authentication => :plain,
  :user_name      => 'USERNAME',
  :password       => 'PASSWORD'
}

config.action_mailer.default_url_options = { :host => "localhost", :port => 3000 }

The first bit tells ActionMailer to use SMTP, which means that we communicate with a remote outgoing mail server using the SMTP protocol (another option is sendmail, which will use the delivery service on your properly-configured Linux system; for sendmail, see AWDR). The smtp_settings Hash defines the configuration information for our server. If you want to follow along, create a GMail account, and then enter the username (without @gmail.com) and password here. The third item, setting the default_url_options, ensures that we can easily create links out of our e-mail with the url_for helper.

NOTE: To get the tests to pass, you must also add “config.action_mailer.default_url_options = { :host => “localhost”, :port => 3000 }” to config/environments/test.rb.

Now let’s generate an ActionMailer class that will handle all system e-mails:


script/generate mailer SystemMailer book_added system_started

This will create three key files for us (as well as tests, etc.) as follows:

[Note: I won't be discussing the "system_started" method or its template here, but take a look at the file config/initializers/system_started.rb: By this means we can get a message noting that the server was started or potentially restarted.]

As usual, the generator creates some stuff in system_mailer.rb for us: First of, a method like so: def book_added(sent_at = Time.now)

We can change the parameters to anything we want (the default strikes me as lame). Since we want this e-mail to be about a book, let’s pass in a book. We are also going to change the “to” and “from” fields. The body field is a Hash. We can set keys to anything we want, so in this case we’ll set the key :book to the book we passed into the method. Any keys set into this body Hash will become instance variables in the e-mail message templates. The result looks like this:


def book_added(book)
  subject    'Book added'
  recipients 'john@7fff.com'
  from       'john@7fff.com'
  sent_on    Time.now
  body       :book => book
end

Now we are going to add TWO templates, one for a plain text version, the other for an HTML e-mail. The HTML and plain versions will be bound together in the same outgoing e-mail (leveraging MIME Multipart). This is a great feature of Rails, and is really hard to get right in other frameworks.

To do this, we are going to take the generated views/system_mailer/book_added.erb and rename it to: book_added.text.plain.erb

The “text.plain” is for the “content type” that this template represents (see AWDR for details):


Hi.

A book was just added: <%= @book.title %> (<%= book_url @book, :only_path => false %>)

Notice the “only_path” tag on the book_url helper. Normally in a web page we don’t need “http://localhost:3000″ to prefix the link, because it will be relative to current page. However, a link in e-mail has no such context, so we need to force this. The host and port will be those set in the “default_url” options for ActionMailer we set in development.rb.

We will also add a file book_added.text.html.erb — this one can have fairly complete HTML. (Unfortunately, some e-mail clients support CSS badly, so you may have to resort to tables for a structured message — this topic is way beyond the scope of Rails, but you might enjoy (?) looking at http://www.email-standards.org/.) By providing both of these templates, we can still get our message to mail clients that don’t support HTML.

For our HTML e-mail, we’ll copy over the text version as a starter, and decorate it a book so that when we get the e-mail we can tell that it’s HTML. We’ll just add a proper link:


<p>Hi.</p>

<p>A book was just added: <%= link_to @book.title, book_url(@book) %></p>

Finally, we need to send to trigger the delivery of the e-mail to the administrator when a book is added. In books_controller.rb, we add a line right after we set the Flash to the message that the book has been created:


flash[:notice] = 'Book was successfully created.'
SystemMailer.deliver_book_added(@book)

Now let’s try it out.

Receiving e-mail

NOTE: One thing I don’t show enough in the recording is what is going on in the e-mail account that we are using for the incoming “commands.” By all means, log into this account and watch for yourself how the state of the message changes. I would strongly recommend this. Again, to make all of this work, you want to create your own GMail account that is especially for incoming mail handling for your app!

We are going to define a class called IncomingBookHandler, which will be very similar to the IncomingTicketHandler in AWDR. However, we will use IMAP in the manner described here:

http://wiki.rubyonrails.com/rails/pages/HowToReceiveEmailsWithActionMailer

with some hints from:

http://codeclimber.blogspot.com/2008/06/using-ruby-for-imap-with-gmail.html

Two big big tips:

(1) You really want to set up a separate e-mail account for your application’s incoming messages. Trust me on this one. You want the flexibility to delete all messages that have been processed without messing up existing important e-mail.

(2) If you want your application to respond to “commands,” have the commands appear in the SUBJECT line of the e-mail. This is much easier to decode that the body of the e-mail.

First we have some settings, again for config/environments/development.rb:


IMAP_SETTINGS = {
  :host => 'imap.gmail.com',
  :port => 993,
  :user => 'USERNAME',
  :password => 'PASSWORD'
}

Now we need to write a class to handle incoming mail, which will again subclass ActionMailer::Base. We could define these messages on our class that handles outgoing methods, but I think you will want sending and receiving to be in separate classes.


require 'net/imap'

class IncomingBookHandler < ActionMailer::Base

  FOLDER = 'INBOX'

  def receive(email)
    puts "got an e-mail with subject: #{email.subject}"
    case email.subject
    when /add:\s*?(.*?)\|(.*)/im
      title, author = $1.strip, $2.strip
      unless Book.create(:title => title, :author => author)
        puts "Couldn't create"
      end
    else
      puts "Can't parse: #{email.subject}"
    end
  end

  def self.check_mail
    imap = Net::IMAP.new(IMAP_SETTINGS[:host], IMAP_SETTINGS[:port], true)
    imap.login(IMAP_SETTINGS[:user], IMAP_SETTINGS[:password])
    imap.select(FOLDER)
    imap.search(['NOT', 'SEEN']).each do |message_id|
      msg = imap.fetch(message_id, 'RFC822')[0].attr['RFC822']
      IncomingBookHandler.receive(msg)
      imap.store(message_id, "+FLAGS", [:Seen])
    end
    imap.expunge()
    imap.logout()
    begin
      imap.disconnect()
    rescue Errno::ENOTCONN
      # ignore - raised on some IMAPs
    end
  end

end

The use of the Net::IMAP module is beyond the scope of this course, but I think you should be able to follow along. Here’s how this works: Somehow the class method check_mail is called occasionally. For example, on Linux you might set up a “cron” job to run it. On Windows, you would set up a “Scheduled Task.” This method checks for any unread messages, and if it finds them, it hands them over to the specific ActionMailer class with IncomingBookHandler.receive(msg). Notice that this is quite deceptive. Here we are calling a class method, which in turn delegates to the instance method (”receive”) of the same name, which is defined above.

In our receive method, we look at the subject of each e-mail message. If it starts with a command we understand, we take that subject apart, and use it as the parameters for Book.create. Notice, then, that back in check_mail, after we have processed the e-mail with receive, we set the e-mail to “Seen” — this means that next time it runs, it won’t re-process that e-mail. Also, during debugging, you can easily re-set the e-mail to unread, and have it re-processed.

In the screencast, you will notice that the e-mail to the administrator is NOT triggered with the new book is created. Why? Because originally we triggered that message in the books controller. But now we are creating books outside of the controller. To fix this, we will comment out the triggering code in the controller, and will send the e-mail out of the model. This means adding the following lines to the Book model. The reason we have this code to set an instance variable @created to true and/or false, has to do with the organization of ActiveRecord callbacks, which cannot tell during a save whether the item was originally created or updated. So we have to set flags to track that. Here’s the code for book.rb:


  def after_create
    @create = true
  end
  def after_update
    @create = false
  end
  def after_save
    if @create
      SystemMailer.deliver_book_added(self)
    end
  end

And that’s it. My advice to you is to take the original booksindex app and run through these steps in imitation of the screencast. If you can’t get it to work, download booksindex_actionmailer.

Sending - 1



Sending - 2



Receiving





blog comments powered by Disqus