Phlex for Rails Emails: Action Mailer without ERB
Rendering Action Mailer emails with Phlex components and layouts: Clean, Composable, and Completely Ruby
Writing Ruby all day is fun, but writing Ruby in HTML is not. In the past months, I found switching from Ruby to ERB views and partials to be interrupting my flow and therefore increasingly annoying. Enter Phlex, which is a Ruby library that allows you to write HTML (components, views, layouts) in pure Ruby. After already completely ditching ERB for Phlex in Rails views for multiple applications, I wanted to see if I could do the same for Action Mailer. ERB be gone!
Why might this be interesting, besides just trying it out for the sake of it, and to see if it’s possible? I see these benefits:
- No more ERB: Use Phlex all the way, even for mailers
- Manage the HTML and text version of the email in one place
- Use Phlex components for layouts and views
As for my part, I could gladly live without ERB in my Rails applications. After all, if it does not spark joy when you touch it, you should get rid of it.
Check out the repository for the full code. I hope you find this as exciting as I do!
Let’s begin by setting up a mailer in our Rails app so we can demonstrate how to move everything to Phlex.
The ERB Way
The UserMailer
class is our entry point into Action Mailer, which handles email delivery in Rails. Nothing special to see here, this is Rails 101. It’s basically what we get when we generate a mailer (e.g., rails g mailer UserMailer
).
class UserMailer < ApplicationMailer
def welcome
@user = params[:user]
mail(to: @user.email, subject: "Welcome")
end
end
We also need some corresponding view files or “email templates”. Not all email clients render HTML, so we need to provide both a HTML and a text version. We can use ERB to use Ruby code in the templates (for example, to access instance variables or to use helper methods). In terms of files, this means that we need one template for the HTML version:
<div>We are excited to have you join us! Thank you for signing up.</div>
<%= link_to 'Click here to sign in', 'https://example.com' %>
And one for the text version:
We are excited to have you join us! Thank you for signing up.
Click here to sign in: https://example.com
We also need to add some basic layouts. In the layout, you might add a logo at the top of the email, place links in a footer, add contact information, and so on.
Because these parts are in the layout, they become easy to manage: When you want to change the layout, you can change everything in one place. For now, we keep it simple, and just add the application name and a “signature” at the end, to demonstrate how the layout wraps around the email content.
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
</head>
<body>
<div><b>Application Name</b></div>
<%= yield %>
<div>Best Regards,</div>
<div>Company Name</div>
</body>
</html>
For the text version, we mirror the of the HTML version. Since we can’t use HTML, we need to keep it simple and work with plain text. We may use line breaks or adding horizontal rules to separate sections, but for now we keep it simple:
Application Name
<%= yield %>
Best Regards,
Company Name
The above template plus their layout produces the following email messages. First, the HTML version:
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
</head>
<body>
<div><b>Application Name</b></div>
<div>We are excited to have you join us! Thank you for signing up.</div>
<a href="https://example.com">Click here to sign in</a>
<div>Best Regards,</div>
<div>Company Name</div>
</body>
</html>
And the text version:
Application Name
We are excited to have you join us! Thank you for signing up.
Click here to sign in: https://example.com
Best Regards,
Company Name
As you can see, the email content is rendered with the layout wrapped around it.
By creating a test, we can assert the email content for the HTML and text version via fixtures. We can leverage the read_fixture
helper (see the Rails Guide on Testing) to load the fixture files and compare them to the email content. We create a simple helper method assert_mailer_fixtures
to conveniently assert both versions.
require "test_helper"
class UserMailerTest < ActionMailer::TestCase
test "welcome" do
user = User.new(email: "[email protected]")
email = UserMailer.with(user: user).welcome
assert_equal ["[email protected]"], email.from
assert_equal ["[email protected]"], email.to
assert_equal "Welcome", email.subject
assert_mailer_fixtures("welcome", email)
end
private
# This checks the email content against the fixtures.
# For example, for UserMailer#welcome, it checks against:
# test/fixtures/user_mailer/welcome.txt
# test/fixtures/user_mailer/welcome.html
def assert_mailer_fixtures(name, email)
email_body_html = email.html_part.body.to_s
email_body_text = email.text_part.body.to_s.strip
expected_html = read_fixture("#{name}.html").join.strip
expected_text = read_fixture("#{name}.txt").join.strip
fixture_html = Nokogiri::HTML.parse(expected_html).to_html.strip
email_html = Nokogiri::HTML.parse(email_body_html).to_html.strip
assert_dom_equal fixture_html, email_html
assert_equal expected_text, email_body_text
end
end
We now have a basic setup for sending emails with Action Mailer – the ERB way. The next step is to refactor the views and layouts to use Phlex. This test ensures that while we refactor, we produce exactly the same email content (HTML and text) while we move from ERB to Phlex.
Using Phlex Views for Email Templates
To install Phlex in our Rails app, we follow the installation instructions. In particular, we run the install generator to create base classes and directories for our views (the generator provides an opinionated starting point with a folder structure and naming convention for views and components).
Then, we can create the Phlex views for the email templates. We might place all mailer views under app/views/mailers
and create a subfolder for each mailer. Similar to with the ERB templates, we need to create a view for the HTML version and one for the text version. First, we create the HTML view:
class Views::Mailers::Users::Welcome::Html < Views::Base
def initialize(user)
@user = user
end
def view_template
div do
plain "We are excited to have you join us! Thank you for signing up."
end
a href: "https://example.com" do
plain "Click here to sign in"
end
end
end
And then the text view:
class Views::Mailers::Users::Welcome::Text < Views::Base
def initialize(user)
@user = user
end
def view_template
plain "We are excited to have you join us! Thank you for signing up."
plain "\n\n"
plain "Click here to sign in: https://example.com"
end
end
Inside the ERB templates, we render the Phlex views. For the HTML version:
<%= render Views::Mailers::Users::Welcome::Html.new(user: @user) %>
And for the text version:
<%= render Views::Mailers::Users::Welcome::Text.new(user: @user) %>
Now what? This seems like a tiny step forward. We have the same content, only now it’s rendered via Phlex views instead of ERB templates, but we still need the ERB files as an “entry point”. Even worse, we have gone from two files to four files for the templates, which adds overhead – not ideal! But we are not done yet. Let’s have a look at layouts next, Phlex style.
Setting up Phlex Layouts for Mailers
Similar to the ERB layouts, we can create Phlex layouts for our email templates. But first, let’s tell Action Mailer to not use any layouts, because we will compose the templates on our own from layouts and views with Phlex.
class ApplicationMailer < ActionMailer::Base
default from: "[email protected]"
layout false
end
We can delete app/views/layouts/mailer.html.erb
and app/views/layouts/mailer.text.erb
as they are no longer needed. Instead, we create Phlex layouts for the email templates – you could use a tool like phlexing.fun to convert HTML to Phlex. First, we create a layout for the HTML version:
class Components::Layouts::Mailers::Html < Phlex::HTML
def view_template(&block)
doctype
html do
head do
meta charset: "utf-8"
end
body do
div do
b { "Application Name" }
end
yield block
div { "Best Regards," }
div { "Company Name" }
end
end
end
end
And one for the text version:
class Components::Layouts::Mailers::Text < Phlex::HTML
def view_template(&block)
plain "Application Name"
plain "\n\n"
yield block
plain "\n\n\n"
plain "Best Regards,"
plain "\n"
plain "Company Name"
end
end
In the views, we use the around_template
hook to tell the view to render with the particular layout (see Phlex layout docs).
For the HTML variant, this means we need something like this:
class Views::Mailers::Users::Welcome::Html < Views::Base
include Components
def initialize(user)
@user = user
end
def around_template
render Components::Layouts::Mailers::Html.new do
super
end
end
def view_template
div do
plain "We are excited to have you join us! Thank you for signing up."
end
a href: "https://example.com" do
plain "Click here to sign in"
end
end
end
And for the text variant:
class Views::Mailers::Users::Welcome::Text < Views::Base
include Components
def initialize(user)
@user = user
end
def around_template
render Components::Layouts::Mailers::Text.new do
super
end
end
def view_template
plain "We are excited to have you join us! Thank you for signing up."
plain "\n\n"
plain "Click here to sign in: https://example.com"
end
end
Now we have the email content rendered with the layout wrapped around it. But, for each email template, we need to repeat a lot of the same code (e.g., explicitly defining around_template
hook for every single templates, duplicated initialize
for HTML and text variant). We’re not there yet, though. In order to DRY up the code, we need to refactor the views and how we’re rendering the emails.
Refactoring ApplicationMailer and Views
Here comes the cool part. We can refactor the ApplicationMailer
class to render the email content with the layout by way of a custom method render_email
(we use layouts through composition). This way, we can remove the around_template
hook which is cluttering up the views and thereby make them more compact. Also, we go from two separate views to one view class with two subclasses for the HTML and text version. No need to always remember to also change the text version when updating the HTML version – it’s all in one place and almost impossible to miss. Nice!
Let’s first have a look at the refactored ApplicationMailer
class:
class ApplicationMailer < ActionMailer::Base
default from: "[email protected]"
layout false
def render_email(view_class, subject:, to:, view_params: {})
mail({subject:, to:, content_type: "multipart/alternative"}) do |format|
format.html {
render Components::Layouts::Mailers::Html.new {
render view_class.const_get(:Html).new(**view_params)
}
}
format.text {
render Components::Layouts::Mailers::Text.new {
render view_class.const_get(:Text).new(**view_params)
}
}
end
end
end
The render_email
method takes the view class and additional arguments, forwarding them to the Action Mailer mail
method. In the block, it then renders the email content with the layout for the HTML and text versions.
Here’s how we render the email from the mailer class:
class UserMailer < ApplicationMailer
def welcome
@user = params[:user]
render_email(
Views::Mailers::Users::Welcome,
subject: "Welcome",
to: @user.email,
view_params: {user: @user}
)
end
end
In the views, we now centralize the HTML and text content in one class. It’s easy to manage and change the content in one place, no need to jump between different files.
class Views::Mailers::Users::Welcome < Views::Base
def initialize(user:)
@user = user
end
class Html < self
def view_template
div do
plain "We are excited to have you join us! Thank you for signing up."
end
a href: "https://example.com" do
plain "Click here to sign in"
end
end
end
class Text < self
def view_template
plain "We are excited to have you join us! Thank you for signing up."
plain "\n\n"
plain "Click here to sign in: https://example.com"
end
end
end
We can now delete these four files:
app/views/mailers/users/welcome/html.rb
app/views/mailers/users/welcome/text.rb
app/views/user_mailer/welcome.html.erb
app/views/user_mailer/welcome.text.erb
By deleting the email templates (and before that, the layouts), we have removed all ERB and now can write our emails in plain Ruby with Phlex.
Inlining Styles
Let’s add some finishing touches – after all, we want our emails to look good. But, email clients have limited support for CSS stylesheets, so we need to use inline styles. We can add a style
tag to our HTML layout to get started. Something like this:
class Components::Layouts::Mailers::Html < Phlex::HTML
def view_template(&block)
doctype
html do
head do
meta charset: "utf-8"
style do
plain <<~CSS
body {
font-family: Arial, sans-serif;
}
.highlight {
background-color: yellow;
}
.signature {
font-style: italic;
}
CSS
end
end
body do
div do
b { "Application Name" }
end
yield block
div { "Best Regards," }
div(class: "signature") { "Company Name" }
end
end
end
end
However, for emails, it’s generally recommended to inline the styles of all classes we use by adding the styles to the respective elements. Let’s add the roadie-rails gem to the mix, so that classes like highlight
and signature
are inlined with their actual style values (this works for both the layout, as shown above with signature
, as well as within the views, e.g., when you use highlight
for a particular element).
class ApplicationMailer < ActionMailer::Base
include Roadie::Rails::Automatic
default from: "[email protected]"
layout false
#...
end
With roadie-rails
in place, we can now use classes in our views and layouts, and the styles will be inlined automatically. Note that roadie-rails
inlines the styles only when delivering – use UserMailer.with(user: user).welcome.deliver
in Rails console to check the inlined styles. Here’s an example of the resulting HTML email content:
<html>
<!-- ... -->
<body style="font-family:Arial, sans-serif">
<div><b>Application Name</b></div>
<div class="highlight" style="background-color:yellow">We are excited to have you join us! Thank you for signing up.</div>
<a href="https://example.com">Click here to sign in</a>
<div>Best Regards,</div>
<div class="signature" style="font-style:italic">Company Name</div>
</body>
</html>
Conclusion
By moving from ERB to Phlex for Action Mailer, we can write our email templates in pure Ruby. We can manage the HTML and text version in one place, and use Phlex components for layouts and views. Not a single ERB file left in our entire Rails app! Check out the repository for the full code:
I hope this is helpful for those looking into using Phlex for mailers as the last piece of the puzzle to get rid of ERB completely. If you have any questions or feedback, feel free to reach out.
Keep Phlexing!