How to pass request context to ActionMailer and supply the :host value to url_for

The epic battle me against ActionMailer has finally come to an end and I'm quite satisfied with the final result.

Have you ever tried to generate URLs within an ActionMailer template? If you did at least once, then you are probably familiar with the following error:

ActionView::TemplateError (Missing host to link to! Please provide :host parameter or set default_url_options[:host])

This happens because ActionMailer instance doesn't have any context about the incoming request so you'll need to provide the :host, :controller, and :action:. If you use a named route, ActionPack provides controller and action names for you. Otherwise, with the url_for helper you need to pass all the parameters.

<%= message_url %>
<%= url_for :controller => "messages", :action => "index" %>

Regardless your choice, you always need to provide the host option to generate an URL in ActionMailer. As shown by the ActionMailer guide, you basically have two ways to pass the host value to ActionMailer:

  1. set a global value
  2. pass the option each time you generate an URL

This works for almost the most part of basic Rails applications but never really worked for me.

Scenario

You have a medium complex Rails application and you need to send emails in different environments including development, staging and production. Each environment is usually hosted on a specific domain:

  • development environment runs on localhost
  • staging environment runs on staging.example.com
  • production environment runs on example.com

A single application instance serves different languages. Each language is hosted on a specific subdomain. So, for example

  • www.example.com (English)
  • it.example.com (Italian)
  • fr.example.com (French)

Of course, each environment follows the same conventions. This is the staging environment

  • www.staging.example.com (English)
  • it.staging.example.com (Italian)
  • fr.staging.example.com (French)

And here's the development environment. In this case, the locale is passed via querystring instead of using a subdomain.

  • localhost?locale=en (English)
  • localhost?locale=it (Italian)
  • localhost?locale=fr (French)

The locale detection system is quite complex but I'm not going to show it here. It doesn't play a key role in this article.

Problem

As you can guess, none of the solutions mentioned in the guide work for this scenario. The problem here is that I can't provide a default option because the host vary depending on external variables. Also, I don't want to manually pass the host option each time I generate an URL because it would require to pass the request object as email argument each time.

I tried at least 5 different solutions in the past but, unfortunately, each of them has some problem. The "almost perfect one" was to store the request object as an ApplicationController class variable each time a visitor requested a page, unfortunately this solution didn't work in a multithreaded environment. The same issue affect an other similar solution based on Ruby global variables.

Solution

The final solution I worked on is now available as a plugin. I haven't written any test yet because I just extracted it from a real world application. The plugin provides the following features:

  1. It's thread-safe
  2. Makes the request context available to action mailer
  3. Automatically extracts request host and port and pass them as default_url_options
  4. Works with other existing default_url_options

If you just need it, install the plugin and enjoy the power of Rails.

ruby script/plugin install git://github.com/weppos/actionmailer_with_request.git

If you want to know something more about how it works, continue to read.

The idea behind the plugin is to store the request instance somewhere and then access it from ActionMailer.

The first part of the problem is quite simple. The only thread-safe place where the instance can be saved is the current thread itself. This is almost straightforward, you just need to append a new before_filter at the lowest-level of your application, that is ApplicationController.

module ControllerMixin

  def self.included(base)
    base.class_eval do
      before_filter :store_request
    end
  end

  def store_request
    Thread.current[:request] = request
  end

end

The second part of the problem is this quite complex because:

  1. ActionMailer::Base.default_url_options is expected to be a Hash and it's automatically initialized to an empty Hash
  2. ActionMailer::Base.default_url_options is a class variable and is shared across the entire application.

You need a way to convert Hash value into a runtime-evaluated expression. Of course a lambda would be perfect, but ActionMailer::Base.default_url_options can't be a lambda!

For this reason I created an options proxy taking advantage of Ruby duck typing. default_url_options doesn't necessary need to be a Hash, in order to work it just need to acts like a Hash.

The OptionsProxy class is basically a proxy for a Hash instance. The only different between the Hash class and OptionsProxy is that the latter merges some default values to the base Hash each time on each method call. Where do these default values come from? But from the OptionsProxy.defaults labmda, of course!

Ok, let me show you an example.

hash = { :foo => "1" }
hash.keys # => [:foo]

hash = OptionsProxy.new({ :foo => "1" })
OptionsProxy.defaults = lambda { Hash.new }
hash.keys # => [:foo]
OptionsProxy.defaults = lambda { Hash.new(:bar => 2) }
hash.keys # => [:foo, :bar]

Now let's go back to our Rails application. Each time a method is called on the OptionsProxy instance, OptionsProxy automatically merges the result of OptionsProxy.defaults and finally executes the method on the resulting Hash. Because OptionsProxy.defaults is evaluated at runtime, you can access the current thread and read extract the default url options from the request context.

class OptionsProxy

  mattr_accessor :defaults

  self.defaults = lambda do
    host = Thread.current[:request].try(:host) || "www.example.com"
    port = Thread.current[:request].try(:port) || 80

    returning({}) do |params|
      params[:host] = host
      params[:port] = port if port != 80
    end
  end

  def initialize(params = {})
    @params = params
  end

  def method_missing(name, *args, &block)
    @params.merge(defaults.call).send(name, *args, &block)
  end

end

The last step is as easy as drinking a glass of water. You need to replace the ActionMailer::Base.default_url_options Hash with an OptionsProxy instance. This can be done in any Rails environment file, but because I wanted the plugin to be atomic I decided to let the plugin inject itself into ActionMailer.

module MailerMonkeyPatch

  def self.included(base)
    base.default_url_options = ActionMailerWithRequest::OptionsProxy.new(base.default_url_options)
  end

end

The final result is available here. Feel free to post here your feedback. Patches welcome.