Action Mailer and Phlex views
I think I might have come up with a design to use Phlex views from Rails’ Action Mailer in a way I’m happy with. This turned out to be quite simple to code in the end, but since ✨simplicity is hard✨, it took me a bit of reasoning to get there, so I want to share the results!
First of all, the interface. I wanted to be able to define an email message like this:
module Views::Mailers::Users::Welcome
include Mailable
def initialize(user:)
@user = user
end
class Html < Phlex::HTML
def view_template
p { "Welcome, #{@user.name}!" }
end
end
class Text < Phlex::HTML
def view_template
plain "Welcome, #{@user.name}!"
end
end
end
I was inspired by this blog post at first, but my final implementation ended up diverging quite heavily from what described there.
All mail parts are Phlex components namespaced to a single module representing the whole message. This allows for convenient editing of content when composing multipart emails.
On top of this, I wanted every method defined in the main module to be shared
and available to the nested component classes, to allow for DRYer code (super
useful e.g. for initialize
). Lastly, I wanted every component to be rendered
inside a default (but overridable) implicit layout.
The other side of the message interface is the shape of methods available to
calling code (e.g. ApplicationMailer
subclasses). My idea for that was
something like this:
class UsersMailer < ApplicationMailer
def welcome(user)
mail(to: user.email_address) do |format|
message = Views::Mailers::Users::Welcome
format.text { render message.text(user:) }
format.html { render message.html(user:) }
end
end
end
No surprises here, just module methods named along the parts content type,
mirroring the ones exposed by the format
parameter of the block. Behind the
scenes, they act as factories for mail part instances, so what they do is
basically just forward arguments and instantiate/return Phlex components.
Bonus point: the module exposes methods only for actually defined components
(e.g. no Html
component > no html
method). I like to be strict.
So, how did everything fit together? Well, all of the above is implemented by a
<30 lines concern that I called Mailable
:
module Mailable
extend ActiveSupport::Concern
class_methods do
private
def const_added(const_name)
return unless part = part_by_name(const_name)
part.include self
define_singleton_method(const_name.downcase) { |**kw| part.new(**kw) }
private_constant const_name
end
def part_by_name(name)
return unless name.in? %i[ Html Text ]
part = const_get(name, false)
part if part.instance_of?(Class) && part < Phlex::HTML
end
end
private
def around_template
part_name = self.class.name.demodulize
layout = "Components::Layouts::Mailers::#{part_name}".constantize
render layout.new { super }
end
end
It works by tapping into the const_added
hook and doing some metaprogramming
magic in order to make the including module a little bit smarter.
As of now it supports plain text and html content types, but the general idea can be extended. The code should be quite easy to follow, but if you have questions, please ask away.
I’m really happy about how little code went into the concern, and how tidy and self contained the overall solution feels like (at least to me).
I hope this helps! And if you have any opinions about this approach and want to discuss them, I’m here to chat 🎉
Until next time!