Enforcing host parameter usage for URLs in e-mail templates with Hamlcop
At Billetto we have multiple domains for our ticketing website depending on the market, as an example our danish organisation lives at billetto.dk and our swedish counterpart lives at billetto.se.
All of our organisations shares the same infrastructure and the domains routes to the same Ruby on Rails application.
This means that when we send e-mails to our event organisers and ticket buyers, and our e-mails include links back to our websites, we need to make sure that we use the correct domain.
Ruby on Rails have a setting that sets the default domain for links used in e-mails called config.action_mailer.default_url_options
:
config.action_mailer.default_url_options = {host: "billetto.dk"}
And this is an example of a haml-template used in our mailers:
%p
= link_to("Link to order", order_url(id: @order.id))
This means that when host:
isn’t set in the template, it defaults to billetto.dk and will render like this:
<p>
<a href="https://billetto.dk/order/1337">Link to order</a>
</p>
If the order was placed on billetto.dk, we’re in luck! The defaults saved us, and we were mistakenly correct! However, if the order was placed on billetto.se, or any other of our domains, the ticket buyer now have a wrong link, bummer! :(
The fix is to include the host:
attribute:
%p
= link_to("Link to order", order_url(id: @order.id, host: @order.domain))
I can fix it manually by going through all the templates, but that’s quite tedious, I wonder if there is a better way?
Hamlcop to the rescue #
Hamlcop builds on Rubocop, it lets you define rules that scans your code for violations. Hamlcop let’s you scan .haml
-files for violations.
So our mission is clear, we need to:
- Scan all
.haml
files in our mailers (all of our mailers lives in directories ending with_mailer
). - Find all method-calls that ends with
_url
. - Check if the method-call have a
host:
parameter.
This looks like this:
module RuboCop
module Cop
module ActionMailerUrlHostParameter
# This cop enforces that every invocation of a method ending in "_url" in a Rails mailer
# contains a "host:" parameter.
class RequireHostParameter < RuboCop::Cop::Cop
MSG = 'Method ending in "_url" should contain a "host:" parameter.'.freeze
def on_send(node)
return unless in_mailer?(node)
return unless node.method_name.end_with?('_url')
return if node.method_name == :asset_url
return if node.arguments.any? { |arg| arg.hash_type? && arg.pairs.any? { |pair| pair.key.value == :host } }
add_offense(node, location: :selector)
end
private
def in_mailer?(node)
containing_file = processed_source.buffer.name
return false unless containing_file
containing_directory = File.dirname(containing_file)
containing_directory.end_with?('_mailer')
end
end
end
end
end
And the .hamlcop.yml
file looks like this:
require:
- ./lib/custom_cops/action_mailer_url_host_parameter.rb
AllCops:
TargetRubyVersion: 3.1
DisabledByDefault: true
ActionMailerUrlHostParameter/RequireHostParameter:
Enabled: true
And then hamlcop can be run like this:
$ bundle exec hamlcop
Offenses:
app/views/order_mailer/order.html.haml:20:80: C: ActionMailerUrlHostParameter/RequireHostParameter: Method ending in "_url" should contain a "host:" parameter.
= link_to("Link to order", order_url(id: @order.id))
^^^^^^^^^
This script can now be run in your continuous integration suite before you deploy to make sure that you don’t re-introduce the bug by accident.