Published on

HTML email templates in Phoenix

I have gone through a few iterations of email templates over the years and landed on a fairly straightforward setup using Phoenix templates. The idea is to treat emails as another part of the UI, using Gettext translations and verified routes just like the rest of the app.

First, we'll set up the mailer, then go through why we do it this way.

Set up the mailer

With a fresh Phoenix 1.8 project, you will already have Swoosh and the mailer installed:

# lib/my_app/mailer.ex
defmodule MyApp.Mailer do
  use Swoosh.Mailer, otp_app: :my_app
end

We'll use Premailex to process the HTML emails, so let's add it to our app:

# mix.exs
defmodule MyApp.MixProject do
  use Mix.Project

  # ...

  def deps do
    [
      # ...
      {:premailex, "~> 1.0"}
    ]
  end

  # ...
end

I consider email templates part of the UI rather than the business logic. They present information to the user so I keep them alongside the rest of the web components. We'll set up our mail module under mailers/ in web:

# lib/my_app_web/mailers/mail.ex
defmodule MyAppWeb.Mail do
  use MyAppWeb, :verified_routes
  use Gettext, backend: MyAppWeb.Gettext

  import Swoosh.Email

  alias MyApp.Mailer
  alias MyAppWeb.{Layouts, MailHTML}

  def send_example_email(user) do
    subject = gettext("Example email")

    assigns =
      %{
        subject: subject,
        user: user
      }

    html = render_email(:example, assigns)
    text = Premailex.to_text(html)

    new()
    |> from({"My App", "[email protected]"})
    |> reply_to({"My App", "[email protected]"})
    |> to(user.email)
    |> subject(subject)
    |> html_body(html)
    |> text_body(text)
    |> deliver(:example)
  end

  defp render_email(type, assigns) do
    assigns = Map.put(assigns, :layout, {Layouts, "mail"})

    MailHTML
    |> Phoenix.Template.render_to_string("#{type}", "html", assigns)
    |> Premailex.to_inline_css()
  end

  defp deliver(mail, type) do
    mail
    # Example of provider specific options
    |> put_provider_option(:tags, [
      %{name: "type", value: type},
      %{name: "app", value: "my_app_web"}
    ])
    |> Mailer.deliver()
  end
end

The render_email/2 function first renders the template, then inlines the styles using Premailex.to_inline_css/1. We then pass the resulting HTML to Premailex.to_text/1 to generate the plain text version of the email. I use Swoosh's put_provider_option/3 to set provider-specific tags to help identify each email type in the provider's dashboard.

The mail templates are handled similarly to the rest of our Phoenix HTML templates:

# lib/my_app_web/mailers/mail_html.ex
defmodule MyAppWeb.MailHTML do
  @moduledoc false
  use MyAppWeb, :html

  embed_templates "mail_html/*"
end
# lib/my_app_web/components/layouts/mail.html.heex
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>{@subject}</title>
    <style>
      /* CSS reset */
      body {
        width: 100% !important;
        margin: 0;
        padding: 0;
        -webkit-text-size-adjust: 100%;
      }

      table {
        border-collapse: collapse;
        border-spacing: 0;
      }

      img {
        display: block;
        border: 0;
        outline: none;
        text-decoration: none;
      }

      /* Styles */
      body {
        background-color: #f4f4f5;
        font-family: Helvetica, Arial, sans-serif;
        color: #18181b;
      }

      .email-container {
        max-width: 600px;
        margin: 0 auto;
        padding: 20px;
      }

      .logo-cell {
        padding: 16px 0 24px 0;
        text-align: center;
      }

      .logo {
        width: 48px;
        height: auto;
      }

      .card {
        width: 100%;
        background-color: #ffffff;
        border: 1px solid #e4e4e7;
        border-radius: 8px;
      }

      .card-cell {
        padding: 32px;
        font-size: 16px;
        line-height: 1.5;
      }

      .title {
        margin: 0 0 16px 0;
        font-size: 24px;
        font-weight: 700;
      }

      .cta-cell {
        padding: 24px 0;
        text-align: center;
      }

      .cta-button {
        display: inline-block;
        background-color: #fd4f00;
        color: #ffffff;
        padding: 12px 24px;
        text-decoration: none;
        border-radius: 6px;
        font-weight: 600;
      }

      .footer-cell {
        text-align: center;
        padding: 16px;
        font-size: 14px;
        color: #71717a;
      }

      @media only screen and (max-width: 480px) {
        .email-container {
          padding: 10px;
        }

        .card-cell {
          padding: 20px;
        }
      }
    </style>
  </head>
  <body>
    {@inner_content}
  </body>
</html>
# lib/my_app_web/mailers/mail_html/example.html.heex
<div class="email-container">
  <table role="presentation" width="100%">
    <tr>
      <td class="logo-cell">
        <img src={url(~p"/images/logo.png")} alt="My App" width="48" class="logo" />
      </td>
    </tr>
  </table>

  <table role="presentation" class="card">
    <tr>
      <td class="card-cell">
        <h1 class="title">{@subject}</h1>
        <p>{gettext("Hi %{email},", email: @user.email)}</p>
        <p>{gettext("This is an example email rendered from a Phoenix template.")}</p>
      </td>
    </tr>
    <tr>
      <td class="cta-cell">
        <a href={url(~p"/")} class="cta-button">
          {gettext("Visit My App")}
        </a>
      </td>
    </tr>
  </table>

  <table role="presentation" width="100%">
    <tr>
      <td class="footer-cell">
        {gettext("You received this email because you have an account with My App.")}
      </td>
    </tr>
  </table>
</div>
Tests for MyAppWeb.Mail.

A fresh Phoenix project should already have the Swoosh test adapter configured:

# config/test.exs
config :my_app, MyApp.Mailer, adapter: Swoosh.Adapters.Test
defmodule MyAppWeb.MailTest do
  use ExUnit.Case

  alias MyAppWeb.Mail

  test "send_example_email/1" do
    user = %{email: "[email protected]"}

    assert {:ok, _} = Mail.send_example_email(user)

    assert_received {:email, email}
    assert email.to == [{"", "[email protected]"}]
    assert email.from == {"My App", "[email protected]"}
    assert email.subject == "Example email"
    assert email.html_body =~ "Hi [email protected],"
    assert email.text_body =~ "Hi [email protected],"
  end
end

As your mailers expand, you may want to extract the send_*_email functions out and group them into modules like MyAppWeb.UserMailer. There are two important rules for HTML emails:

  • All ~p"/..." paths must be wrapped in url as they need to be absolute: url(~p"/...")
  • Prefer PNG or JPEG; SVG support varies a lot across email clients

Apart from these constraints, HTML email templates work similarly to the rest of our Phoenix templates, including support for Gettext and verified routes.

Email-compatible HTML

HTML emails are notoriously difficult to render consistently across email clients. There are services that let you preview how emails look in different clients. Part of the difficulty is inconsistent CSS support. Email clients often support only a subset of CSS, may strip or modify styles, and HTML emails have security and privacy concerns such as hidden text, obfuscated URLs, or tracking pixels.

The best way to improve consistency across email clients is to inline all styles and use basic HTML structures, such as tables for layout. We use Premailex for this, so for example, <td class="cta-cell"> becomes <td style="padding: 24px 0; text-align: center;">.

CSS frameworks

Frameworks like Tailwind use a lot of modern CSS, and many of their utilities aren't supported consistently across email clients. Some can be configured for email, but the result is often fragile. I wouldn't recommend using a CSS framework for HTML emails unless it's specifically designed for email rendering.

Plain text

As ubiquitous as HTML emails are, plain-text emails are still just as important. It can be easier for users to read emails without HTML formatting, and it also helps with some privacy or security concerns like obfuscated links and tracking pixels. I recommend always having plain-text versions with your transactional emails.

In the above example we use Premailex to convert our HTML email into plain text. The above HTML would read like this when converted to plain text with Premailex:

My App

*************
Example email
*************

Hi [email protected],

This is an example email rendered from a Phoenix template.

Visit My App (http://localhost:4000/)

You received this email because you have an account with My App.

While Premailex plain-text conversion works in most cases, be aware that with some complex email layouts it can be better to write the plain text yourself.

If you don’t need images or styled text, I recommend foregoing HTML entirely and using plain text. You can use the same rendering structure for plain-text emails. First, add a text interface to your web module:

# lib/my_app_web.ex
defmodule MyAppWeb do
  # ...

  def text do
    quote do
      use Gettext, backend: MyAppWeb.Gettext

      unquote(verified_routes())
    end
  end

  # ...
end

For the renderer module, we could use embed_templates with .text.eex files, but because plain-text emails are often compact, it reads better to define the functions directly:

# lib/my_app_web/mailers/mail_text.ex
defmodule MyAppWeb.MailText do
  @moduledoc false
  use MyAppWeb, :text

  def example(%{subject: subject, user: user}) do
    gettext(
      """
      %{subject}

      Hi %{email},

      This is an example email rendered from a Phoenix template.

      Visit My App: %{url}

      --
      You received this email because you have an account with My App.
      """,
      subject: subject,
      email: user.email,
      url: url(~p"/")
    )
  end
end
# lib/my_app_web/mailers/mail.ex
defmodule MyAppWeb.Mail do
  use MyAppWeb, :verified_routes
  use Gettext, backend: MyAppWeb.Gettext

  import Swoosh.Email

  alias MyApp.Mailer
  alias MyAppWeb.MailText

  def send_example_email(user) do
    subject = gettext("Example email")

    assigns =
      %{
        subject: subject,
        user: user
      }

    text = render_email(:example, assigns)

    new()
    |> from({"My App", "[email protected]"})
    |> reply_to({"My App", "[email protected]"})
    |> to(user.email)
    |> subject(subject)
    |> text_body(text)
    |> deliver(:example)
  end

  defp render_email(type, assigns) do
    Phoenix.Template.render_to_string(MailText, "#{type}", "text", assigns)
  end

  defp deliver(mail, type) do
    mail
    # Example of provider specific options
    |> put_provider_option(:tags, [
      %{name: "type", value: type},
      %{name: "app", value: "my_app_web"}
    ])
    |> Mailer.deliver()
  end
end

Preview emails

/dev/mailbox is good for local development tests where you need a sign-in code and such, but when iterating on a template you need a way to easily preview it. Similar to Phoenix.LiveViewTest.open_browser/1, we can write the HTML and plain-text bodies to temp files and open both in the browser:

# test/support/mail_helpers.ex
defmodule MyAppWeb.MailHelpers do
  @moduledoc false

  def open_browser(%Swoosh.Email{html_body: html, text_body: text} = email) do
    dir = Path.join(System.tmp_dir!(), "mail-#{System.unique_integer([:positive])}")

    File.mkdir_p!(dir)

    bodies = [
      html && {"html.html", local_assets(html)},
      text && {"text.html", text_page(text)}
    ]

    for {name, content} <- bodies, is_binary(content) do
      path = Path.join(dir, name)
      File.write!(path, content)
      open(path)
    end

    email
  end

  defp open(path) do
    {cmd, args} =
      case :os.type() do
        {:unix, :darwin} -> {"open", [path]}
        {:win32, _} -> {"cmd", ["/c", "start", path]}
        _ -> {"xdg-open", [path]}
      end

    System.cmd(cmd, args)
  end

  defp local_assets(html) do
    static = Application.app_dir(:my_app, "priv/static")

    String.replace(html, ~s(src="#{MyAppWeb.Endpoint.url()}/), ~s(src="file://#{static}/))
  end

  defp text_page(text) do
    escaped = text |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string()

    ~s(<pre style="font-family: monospace; white-space: pre-wrap;">#{escaped}</pre>)
  end
end

Import the helper and call it in the email test where you want to preview the email:

defmodule MyAppWeb.MailTest do
  use ExUnit.Case

  import MyAppWeb.MailHelpers

  alias MyAppWeb.Mail

  test "send_example_email/1" do
    user = %{email: "[email protected]"}

    assert {:ok, _} = Mail.send_example_email(user)

    assert_received {:email, email}
    # Temporarily open the email while developing
    open_browser(email)
  end
end

Why use Premailex

I maintain Premailex. In the example above, it lets us maintain one simple HTML template of each email while delivering both HTML with inlined CSS and plain text. If you prefer not to maintain email templates, you could instead use a third-party service that accepts template variables through their API. I prefer keeping email templates next to the rest of my UI so the UI concerns move in lockstep.

I recently overhauled Premailex for its v1 release. Average CSS inlining is more than 30x faster in my benchmarks, and all third-party dependencies are now optional. This came from adding a proper DOM abstraction, replacing regular-expression-based CSS parsing with binary parsing, and using :xmerl as an HTML parser fallback. If this sounds interesting and you want to know how it works, I recommend checking out the source: https://github.com/danschultzer/premailex

Hi, I'm Dan Schultzer, I write in this blog, work a lot in Elixir, maintain several open source projects, and help companies streamline their development process