Daniel Doubrovkine bio photo

Daniel Doubrovkine

aka dB., CTO at artsy.net, fun at playplay.io, NYC

Email Twitter LinkedIn Github

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](http://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.