How we like to deliver emails
tldr;
Steal ideas from our recent open-source TEDx app
You send email. A lot of it. It would be nice if…
- Developing HTML emails was easier
- Developing features around email didn’t feel disconnected from the rest of your app
- Emails were more object-oriented, and testable
- Devops didn’t have to worry about SMTP servers
Knowing that as a bonus…
- Admin users could view emails that went out
- Admin users could easily view delivery status of an email
- Users could have a quick/free “view this in your browser” link
All nice things. I’m sure there’s more loveliness that I’ve forgotten. Let’s start at delivery, and work backwards.
I want an object that performs the delivery, by POSTing JSON to the API of whichever email delivery service you’ve selected. I like Mandrill, but choices are great, and you have many. There’s probably a gem for each service, and they’re probably great. You don’t need it.
It’s time for an HTTParty!!!.
class EmailDeliverer
include HTTParty
attr_reader :email
def self.deliver(id)
new(id).deliver
end
def initialize(id)
@email = Email.find(id)
end
def deliver
self.class.post(url, options)
end
private
def url
"https://mandrillapp.com/api/1.0/messages/send.json"
end
def options
{
body: email.to_json,
headers: {
'Accept' => 'application/json',
'Content-type' => 'application/json'
}
}
end
end
I dare that class to look more re-usable. :-) It’s quickly beaten by its background worker.
class EmailDeliveryWorker
include Sidekiq::Worker
def perform(id)
EmailDeliverer.deliver(id)
end
end
Single responsibility principle, anyone?
Ok, so there’s an Email class somewhere, and it knows how to render itself to the JSON expected by the Mandrill API. I’ve suggested here that it probably belongs to a user - let’s not go duplicating the delivery address, name etc. We’ll come back to the token - it’s for the browser link.
class Email < ActiveRecord::Base
belongs_to :user
validates :event, :user, presence: true
validates :token, uniqueness: true
before_create :build_token
def to_name
user.full_name
end
def to_address
user.email_address
end
def deliver
EmailDeliveryWorker.perform_async(id)
end
def html
EmailContent.for(self)
end
def to_json
EmailSerializer.new(self).to_json
end
private
def build_token
self.token = BCrypt::Password.create("#{Time.now.to_f}_#{self.to_address}")
end
end
The serializer should come as no surprise, given my preference for tiny, reusable objects.
class EmailSerializer < ActiveModel::Serializer
attributes :key, :message
self.root = false
def key
MANDRILL.key
end
def message
{
html: object.html,
auto_text: true,
subject: "Message from TEDx",
from_email: "noreply@tedxbrisbane.com",
from_name: "TEDx Brisbane",
to: message_recipients
}
end
private
def message_recipients
[
{
email: object.to_address,
name: object.to_name
}
]
end
end
Let’s take a look at the source of the HTML. That’s a bit more interesting.
class EmailContent
attr_reader :email
def self.for(email)
self.new(email).content
end
def initialize(email)
@email = email
end
def content
if recognised_events.include?(email.event)
render_html
else
raise Exceptions::EmailEventNotRecognised
end
end
private
def user
email.user
end
def recognised_events
%w(register invite)
end
def render_html
EmailContentController.new.render_to_string 'emails/content',
layout: 'email', locals: { email: email, user: user }
end
end
class EmailContentController < ActionController::Base
include ApplicationHelper
end
The ‘magic’ here is in :render_to_string. Now we’re developing emails using HAML, helpers, decorators, your usual front-end weapons of choice.. Handy. Very handy.
Providing a 'view in browser’ link is now as simple as adding an EmailsController - and you’ll do it anyway, because of all the time it’ll save your designer. It removes any need to send an email to test the designs as they’re being developed - no more mailcatcher, no more fiddling in the console.
class EmailsController < ApplicationController
layout "email"
def content
if email
render('emails/content', locals: { email: email, attendee: email.attendee })
else
redirect_to '/', notice: message
end
end
private
def email
Email.where(token: decoded_token).first
end
def decoded_token
Base64.urlsafe_decode64(params[:token]) rescue "null_token"
end
def message
I18n.t("controllers.emails.invalid")
end
end
Finally, it would be nice to have a quick way of getting a link for each email.
class EmailLink
attr_reader :resource
def self.for(resource)
new(resource).for
end
def initialize(resource)
@resource = resource
end
def for
Addressable::URI.escape("#{host_name}/#{route}/#{token}")
end
private
def host_name
HOSTNAME.public_send(Rails.env)
end
def route
resource.class.to_s.downcase.pluralize
end
def token
Base64.urlsafe_encode64(resource.token)
end
end
And that’s about it. I’ve modified the code slightly from its original form to suit the example, but you can see the full source, and importantly, the test suite, over on a recent application that NetEngine built for TEDx.
How do you send email? I’d love to compare notes.