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:
- set a global value
- 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:
developmentenvironment runs onlocalhoststagingenvironment runs onstaging.example.comproductionenvironment runs onexample.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:
- It’s thread-safe
- Makes the request context available to action mailer
- Automatically extracts request host and port and pass them as default_url_options
- 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:
ActionMailer::Base.default_url_optionsis expected to be aHashand it’s automatically initialized to an emptyHashActionMailer::Base.default_url_optionsis 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.


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. :(
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 :)
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.
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.
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.
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?
I took the version from 2010-02-23, that works with Rails 2.3.8.
Thanks.
Is there a way to add this as a gem?
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.
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?
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.hostmethod 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
I know my comment will add absolutely anything to this conversation, but…
Thank you!
Thanks a lot………It saved a lot of time for me.
I just like to know whether it is Thread safe or not