Phlex for Rails Emails: Action Mailer without ERB

Rendering Action Mailer emails with Phlex components and layouts: Clean, Composable, and Completely Ruby

Published on February 27, 2025
Updated on March 02, 2025

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!

Project source code available at...
visini/rails-phlex-action-mailer

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

app/mailers/user_mailer.rb
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:

app/views/user_mailer/welcome.html.erb
<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:

app/views/user_mailer/welcome.text.erb
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.

app/views/layouts/mailer.html.erb
<!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:

app/views/layouts/mailer.text.erb
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.

test/mailers/user_mailer_test.rb
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:

app/views/mailers/users/welcome/html.rb
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:

app/views/mailers/users/welcome/text.rb
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:

app/views/user_mailer/welcome.html.erb
<%= render Views::Mailers::Users::Welcome::Html.new(user: @user) %>

And for the text version:

app/views/user_mailer/welcome.text.erb
<%= 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.

app/mailers/application_mailer.rb
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:

app/components/layouts/mailers/html.rb
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:

app/components/layouts/mailers/text.rb
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:

app/views/mailers/users/welcome/html.rb
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:

app/views/mailers/users/welcome/text.rb
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:

app/mailers/application_mailer.rb
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:

app/mailers/user_mailer.rb
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.

app/views/user_mailer/welcome.rb
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:

app/components/layouts/mailers/html.rb
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).

app/mailers/application_mailer.rb
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:

Project source code available at...
visini/rails-phlex-action-mailer

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!

Sign up for my personal newsletter
Enter your email address below in order to receive an email when I write a new article:
Note: You can unsubscribe at any time by clicking on the unsubscribe link included in every email.
© 2025 Camillo Visini
Imprint RSS