Daniel Doubrovkine bio photo

Daniel Doubrovkine

aka dB., @awscloud, former CTO @artsy, +@vestris, NYC

Email Twitter LinkedIn Github Strava
Creative Commons License

You love markdown? We do too.

I’m going to show you how to enable dynamic authoring of mail templates in your Rails app in Markdown. This includes Devise mailers. You can already customize templates with files in app/views, but we’ll take an extra step and expose an editable model in the database and allow our application administrators to author and edit templates in Markdown.

Template Model

Let’s add a MailTemplate that can render itself to HTML using Sanitize and RDiscount. The class name will be the mailer and the method name, the mail action.

class MailTemplate
  include Mongoid::Document

  field :class_name, :type => String
  field :method_name, :type => String
  field :subject, :type => String
  field :content, :type => String

  attr_accessible :content, :subject

  def to_html(context)
    template = ERB.new(content, 0, "%<>")
    template_result = template.result(context)
    Sanitize.clean(RDiscount.new(template_result).to_html.encode("UTF-8", undef: :replace), Sanitize::Config::RELAXED)
  end
end

We need to be able to render this in a mailer, so add a simple controller in app/controllers/mail_templates_controller.rb.

class MailTemplatesController < ApplicationController
  def show
    @mail_template = MailTemplate.find(params[:id])
  end
end

And a simple view, app/views/mail_templates/show.html.haml.

%div
  = @mail_template.to_html(@context).html_safe

You can add a form to edit such a mail template, left as an exercise.

There’re a lot of mailers, so having to create each template by hand doesn’t make a lot of sense. There’s no easy way to enumerate all mailers, so lets define the in some central location and write a Rake task to create these in the database. I wrote app/mailers/mailers.rb.

module Mailers

  ALL = {
    "Devise::Mailer" => [
      "confirmation_instructions",
      "reset_password_instructions",
      "unlock_instructions",
      "invitation_instructions"
    ]
  }

  def self.template_for(klass, method_name)
    method_name = method_name.to_s.gsub("_email", "")
    path = "#{Rails.root}/app/views/#{klass.to_s.underscore}/#{method_name.to_s}.md"
    raise "missing #{path}" unless File.exist?(path)
    File.read(path)
  end

  def self.load_template(klass, method_name)
    template = MailTemplate.new
    template.class_name = klass.to_s
    template.method_name = method_name.to_s
    template.content = template_for(klass, method_name)
    template
  end

  def self.load(klass, method_name)
    mail_template = MailTemplate.where(class_name: klass.to_s, method_name: method_name).first
    mail_template ||= Mailers.load_template(klass, method_name)
    mail_template
  end

  def self.create_templates!
    ALL.each_pair do |klass, method_names|
      method_names.each do |method_name|
        if MailTemplate.where(class_name: klass, method_name: method_name).blank?
          Rails.logger.info("creating #{klass}/#{method_name} e-mail template")
          Mailers.load_template(klass, method_name).save!
        end
      end
    end
  end

end

It’s all pretty straightforward. You can add other mailers to Mailers.ALL. A rake task can invoke Mailers.create_templates! so that we get a record in the database with the default data. Here’s an example of app/views/devise/mailer/reset_password_instructions.md. Note that this is already written in markdown!

Hello <%=@resource.email%>!

Someone has requested a link to change your password, and you can do this through the link below.

[Change my password](https://example.com/users/password/edit?reset_password_token=<%=@resource.reset_password_token%>)

If you didn't request a password reset, please ignore this email.
Your password won't change until you access the link above and create a new one.

Overriding Devise

We want to override the Devise mailer and fetch the mail template, if available. Add app/mailers/devise_mailer.rb. The mailer will fetch a template from Mailers and render it.

class DeviseMailer < Devise::Mailer

  def template_paths
    "devise/mailer"
  end

  def devise_mail(record, action)
    initialize_from_record(record)
    @context = binding
    @mail_template = Mailers.load("Devise::Mailer", action)
    headers = headers_for(action)
    template_view = "mail_templates/show.html.haml"
    headers[:subject] = @mail_template.subject unless @mail_template.subject.blank?
    mail headers do |format|
      format.html do
        render template_view
      end
    end
  end

end

Declare the mailer in config/initializers/devise.rb.

Devise.setup do |config|
  config.mailer = "DeviseMailer"
  ...
end

Tests

A simple test is to make sure we can actually render an e-mail. Here’s what my spec/mailers/devise_mailer_spec.rb looks like.

require "spec_helper"

describe DeviseMailer do
  before(:each) do
    @user = Fabricate(:user)
    @user.stub!(:confirmation_token).and_return("confirmation-token")
    @user.stub!(:unlock_token).and_return("unlock-token")
  end
  Mailers::ALL["Devise::Mailer"].each do |method_name|
    it "should respond to #{method_name}" do
      DeviseMailer.should respond_to method_name
    end
    it "#{method_name} should attempt to load a MailTemplate" do
      mail_template = Fabricate(:mail_template)
      Mailers.should_receive(:load).with("Devise::Mailer", method_name.to_sym).and_return(mail_template)
      DeviseMailer.send(method_name, @user)
    end
    it "#{method_name} should have a proper body" do
      mail = DeviseMailer.send(method_name, @user)
      mail.to.should_not be_blank
      mail.from.should_not be_blank
    end
  end
end

Next steps?

You tell me. I think we should be able to sink a lot of the functionality implemented in the Mailers module in various parts of the system. Starting with the .md rendering.