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:
development
environment runs onlocalhost
staging
environment runs onstaging.example.com
production
environment 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_options
is expected to be aHash
and it's automatically initialized to an emptyHash
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.