Rails: Custom and Editable Mailer Templates in Markdown

Back | devise, rails, ruby | 9/9/2011 |

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

Lets 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.

  1. class MailTemplate
  2.   include Mongoid::Document
  3.  
  4.   field :class_name, :type => String
  5.   field :method_name, :type => String
  6.   field :subject, :type => String
  7.   field :content, :type => String
  8.   
  9.   attr_accessible :content, :subject
  10.  
  11.   def to_html(context)
  12.     template = ERB.new(content, 0, "%<>")
  13.     template_result = template.result(context)
  14.     Sanitize.clean(RDiscount.new(template_result).to_html.encode("UTF-8", undef: :replace),
  15.       Sanitize::Config::RELAXED)
  16.   end  
  17. end

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

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

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

  1. %div
  2.   = @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.

  1. module Mailers
  2.  
  3.   ALL = {
  4.     "Devise::Mailer" => [
  5.       "confirmation_instructions",
  6.       "reset_password_instructions",
  7.       "unlock_instructions",
  8.       "invitation_instructions"
  9.     ]
  10.   }
  11.  
  12.   def self.template_for(klass, method_name)
  13.     method_name = method_name.to_s.gsub("_email", "")
  14.     path = "#{Rails.root}/app/views/#{klass.to_s.underscore}/#{method_name.to_s}.md"
  15.     raise "missing #{path}" unless File.exist?(path)
  16.     File.read(path)
  17.   end
  18.   
  19.   def self.load_template(klass, method_name)
  20.     template = MailTemplate.new
  21.     template.class_name = klass.to_s
  22.     template.method_name = method_name.to_s
  23.     template.content = template_for(klass, method_name)
  24.     template
  25.   end
  26.   
  27.   def self.load(klass, method_name)
  28.     mail_template = MailTemplate.where(class_name: klass.to_s, method_name: method_name).first
  29.     mail_template ||= Mailers.load_template(klass, method_name)
  30.     mail_template
  31.   end
  32.   
  33.   def self.create_templates!
  34.     ALL.each_pair do |klass, method_names|
  35.       method_names.each do |method_name|
  36.         if MailTemplate.where(class_name: klass, method_name: method_name).blank?
  37.           Rails.logger.info("creating #{klass}/#{method_name} e-mail template")
  38.           Mailers.load_template(klass, method_name).save!
  39.         end
  40.       end
  41.     end
  42.   end
  43.  
  44. 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!

  1. Hello <%=@resource.email%>!
  2.  
  3. Someone has requested a link to change your password,
  4. and you can do this through the link below.
  5.  
  6. [Change my password](http://example.com/users/password/edit?reset_password_token=<%=@resource.reset_password_token%>)
  7.  
  8. If you didn't request a password reset, please ignore this email.
  9. 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.

  1. class DeviseMailer < Devise::Mailer
  2.  
  3.   def template_paths
  4.     "devise/mailer"
  5.   end
  6.   
  7.   def devise_mail(record, action)
  8.     initialize_from_record(record)
  9.     @context = binding
  10.     @mail_template = Mailers.load("Devise::Mailer", action)
  11.     headers = headers_for(action)
  12.     template_view = "mail_templates/show.html.haml"
  13.     headers[:subject] = @mail_template.subject unless @mail_template.subject.blank?
  14.     mail headers do |format|
  15.       format.html do
  16.         render template_view
  17.       end
  18.     end
  19.   end
  20.   
  21. end

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

  1. Devise.setup do |config|
  2.   config.mailer = "DeviseMailer"
  3.   ...
  4. 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.

  1. require "spec_helper"
  2.  
  3. describe DeviseMailer do
  4.   before(:each) do
  5.     @user = Fabricate(:user)
  6.     @user.stub!(:confirmation_token).and_return("confirmation-token")
  7.     @user.stub!(:unlock_token).and_return("unlock-token")
  8.   end
  9.   Mailers::ALL["Devise::Mailer"].each do |method_name|
  10.     it "should respond to #{method_name}" do
  11.       DeviseMailer.should respond_to method_name
  12.     end
  13.     it "#{method_name} should attempt to load a MailTemplate" do
  14.       mail_template = Fabricate(:mail_template)
  15.       Mailers.should_receive(:load).with("Devise::Mailer", method_name.to_sym).and_return(mail_template)
  16.       DeviseMailer.send(method_name, @user)
  17.     end
  18.     it "#{method_name} should have a proper body" do
  19.       mail = DeviseMailer.send(method_name, @user)
  20.       mail.to.should_not be_blank
  21.       mail.from.should_not be_blank
  22.     end
  23.   end
  24. 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.