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:

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.

 
1
Kudos
 
1
Kudos

Now read this

Running and installing Ruby in parallel using Rbenv with flock and Buildkite

I am maintaining a few Docker-images that contains all supported versions of Ruby, and they also ship with Jemalloc. Because I use Buildkite, it is very easy for me to test multiple versions at the same time, but they all run the same... Continue →