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

October 28th, 2009 at 4:50 pm • permalink19 comments

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.

Filed in Programming • Tags: , ,

Comments

I’ve always had luck putting the following in a before_filter in ApplicationController.

ActionMailer::Base.default_url_options[:host] = request.host_with_port

Granted this doesn’t solve the problem of sending emails outside of the website (ie. via runner or console) but it seems to work fine for emails triggered by web actions.

AFAIK, that solution should not be thread-safe. :(

Peter says:

Correct, you’ll get host names from some requests polluting host names in other requests. Not thread safe!

[...] How to pass request context to ActionMailer and supply the :host value to url_for, I actually had this exact problem [...]

Thank you for a good solution to this challenge! I needed this for one of my tasks this afternoon, and luckily I found this on reddit before re-inventing the wheel :)

john says:

What do you do with mail that needs to be sent outside of the context of a controller / request?

For example, you might want to write a class that sends mail from a script (script/runner…)

For example, I need to send an email each time a new record Message is created. Because I use an observer, the observer doesn’t know anything about the request context.

Justin George says:

I solved this by overriding the default_url_options method. You can then have it do whatever backflips you want to obtain the value, no duck typing or lambdas required.

I’ll submit a pull request after I convert your plugin to do that, it looks like a nice way to deal with that pain-in-the-ass problem.

Matthias says:

Hi, this looks like a very clean solution. Unfortunately, I still get the error message
“missing host to link to! Please provide :host parameter or set default_url_options[:host]”
(Rails 2.3.8, development env, in the log I found the plugin message ‘initialized properly’).

Any idea what reason it could be?

Thank you!
Matthias

I’m currently using it with Rails 3 and it works. Make sure the filter :store_request is in the filter chain.

Jamal says:

I just installed this version with rails 3

but when i type request.subdomain with actionmailer it doesn’t work?

undefined method `subdomain’ for nil:NilClass

any solution please?

Matthias says:

I took the version from 2010-02-23, that works with Rails 2.3.8.
Thanks.

Chris says:

Is there a way to add this as a gem?

Glenn says:

Excellent Simon – thanks! This solved the request issue for me in my Rails 3 Mailer in seconds!!

Chris, use Bundler in Rails 3 and then you can just add it into your Gemfile i.e.:

gem ‘actionmailer-with-request’

then do a “$ bundle install”

Really easy.

Daniel says:

Hello,

I’m trying to integrate version 0.2.1 of the plugin with Redmine 1.2.2 and Rails 2.3.14.
I must admit I am still a Ruby newbie, and am completely stuck.

“default_url_options” is defined as a method in the Redmine mailer class.
I was expecting that commenting this out would result in “default_url_options_with_current_request” being called by “url_for” in UrlWriter, but that does not seem to be the case.
Can anyone give me some advice on a starting point?

delph says:

I’m trying to use this gem for rails 3.1 app. But I’m stil not able to use request inside the mailer templates. I’m getting an error “undefined method ‘host’ for nil:NilClass when I use request.host inside an email template. Can someone please show some code for how to use this? Thanks.

You don’t need to call the request.host method directly. Its value is directly inject in the mailer settings by default.
https://github.com/weppos/actionmailer_with_request/blob/v0.2.1/lib/actionmailer_with_request.rb#L29-38

João Costa says:

I know my comment will add absolutely anything to this conversation, but…

Thank you!

Perumal says:

Thanks a lot………It saved a lot of time for me.
I just like to know whether it is Thread safe or not

Add a Comment




Follow Me
    Random Quote