Phoenix email defaults: better templates using components

Switch out your Swoosh templates!

originally published Tue Nov 28 2023 00:00:00 GMT+0000 (Coordinated Universal Time)

Swoosh has been a part of default Phoenix installs for some time. There is a package called phoenix_swoosh that adds some templating stuff on top of swoosh, that is somewhat popular as far as I know. Modern Phoenix, with the addition of heex templates and Phoenix.Component , kind of changes up the game though.

With heex templates, I personally don’t see much of a need for phoenix_swoosh anymore. Instead, I’m going to show you what I’ve been doing in fresh Phoenix projects to get:

  • Nice enough looking transactional emails
  • Generating html and text bodies from one piece of content
  • Simple email layouts with components

Let’s check out an example from a project where I implemented a magic-link style login recently:

defmodule ExampleWeb.Users.UserNotifier do
  import Swoosh.Email

  # Add this:
  import Phoenix.Component

  alias ExampleWeb.Mailer

  # And then add a component for your email content:
  defp login_content(assigns) do
    ~H"""
    <h1>Hey there!</h1>

    <p>Please use this link to sign in to MyApp:</p>

    <a href={@url}><%= @url %></a>

    <p>If you didn't request this email, feel free to ignore this.</p>
    """
  end
end

We’ll start with a heex component that has our html content. Then we’ll add a function to construct the swoosh email, and turn this template into html:

defmodule ExampleWeb.Users.UserNotifier do
  import Swoosh.Email
  import Phoenix.Component

  alias ExampleWeb.Mailer

  defp login_content(assigns) do
    ~H"""
    <h1>Hey there!</h1>

    <p>Please use this link to sign in to MyApp:</p>

    <a href={@url}><%= @url %></a>

    <p>If you didn't request this email, feel free to ignore this.</p>
    """
  end

  # Pretty traditional swoosh-setup stuff.
  def deliver_magic_link(user, url) do
    template = login_content(%{url: url})
    html = heex_to_html(template)

    email =
      new()
      |> to(user.email)
      |> from({"MyApp", "[email protected]"})
      |> subject("Sign in to MyApp")
      |> html_body(html)

    with {:ok, _metadata} <- Mailer.deliver(email) do
      {:ok, email}
    end
  end

  # And a function to render our heex template.
  defp heex_to_html(template) do
    template
    |> Phoenix.HTML.Safe.to_iodata()
    |> IO.iodata_to_binary()
  end
end

So far so good. Pay attention to how we render the html: we need to provide the assigns map, which we construct when we call login_content . And we also use a helper function to render it down to a string. You could stop here if you want. But let’s also generate text bodies from the html. I’ll snip out the context now, but it goes in UserNotifier :

  def deliver_magic_link(user, url) do
    template = login_content(%{url: url})
    html = heex_to_html(template)
    text = html_to_text(html)

    email =
      new()
      |> to(user.email)
      |> from({"MyApp", "[email protected]"})
      |> subject("Sign in to MyApp")
      |> html_body(html)
      |> text_body(text)

    with {:ok, _metadata} <- Mailer.deliver(email) do
      {:ok, email}
    end
  end

  defp heex_to_html(template) do
    template
    |> Phoenix.HTML.Safe.to_iodata()
    |> IO.iodata_to_binary()
  end

  defp html_to_text(html) do
    html
    |> Floki.parse_document!()
    |> Floki.find("body")
    |> Floki.text(sep: "\n\n")
  end

Note: we’re selecting the body here when we parse the document because we don’t want to render anything in the head tag. Secondly, the sep: "\n\n" part tells Floki to insert two newlines between nodes when turning it into text. If you don’t do this, it becomes one big run-on paragraph.

Every new Phoenix app includes Floki, so you can just do this out of the box, however, you’re going to need to enable it in non-test environments. Remove the only: :test part from the Floki line in your mix.exs , so you have:

defp deps do
  [
    {:floki, "~> 0.35.0"}
  ]
end

Finally, lets use components to make a little layout system:

  defp email_layout(assigns) do
    ~H"""
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <style>
          body {
            font-family: system-ui, sans-serif;
            margin: 3em auto;
            overflow-wrap: break-word;
            word-break: break-all;
            max-width: 1024px;
            padding: 0 1em;
          }
        </style>
      </head>
      <body>
        <%= render_slot(@inner_block) %>
      </body>
    </html>
    """
  end

  def login_content(assigns) do
    ~H"""
    <.email_layout>
      <h1>Hey there!</h1>

      <p>Please use this link to sign in to MyApp:</p>

      <a href={@url}><%= @url %></a>

      <p>If you didn't request this email, feel free to ignore this.</p>
    </.email_layout>
    """
  end

I provided some basic styles here I kind of like. Experiment with it! Of course, you probably already know that CSS in email is… a minefield to say the least. If you use non-inline CSS in your emails, prepare for it to possibly not work in some environments.

For purposes of transactional emails, I think this approach is ok. I’m fine if the styles don’t work in some browsers. I just want to be able to log people in, or reset their passwords, or other stuff like that. I’m not doing email marketing in my Phoenix apps. If you are, I encourage you to look into more comprehensive solutions like MJML .

Here’s the final comprehensive solution:

defmodule ExampleWeb.Users.UserNotifier do
  import Swoosh.Email
  import Phoenix.Component

  alias ExampleWeb.Mailer

  defp email_layout(assigns) do
    ~H"""
    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <style>
          body {
            font-family: system-ui, sans-serif;
            margin: 3em auto;
            overflow-wrap: break-word;
            word-break: break-all;
            max-width: 1024px;
            padding: 0 1em;
          }
        </style>
      </head>
      <body>
        <%= render_slot(@inner_block) %>
      </body>
    </html>
    """
  end

  def login_content(assigns) do
    ~H"""
    <.email_layout>
      <h1>Hey there!</h1>

      <p>Please use this link to sign in to MyApp:</p>

      <a href={@url}><%= @url %></a>

      <p>If you didn't request this email, feel free to ignore this.</p>
    </.email_layout>
    """
  end

  def deliver_magic_link(user, url) do
    template = login_content(%{url: url})
    html = heex_to_html(template)
    text = html_to_text(html)

    email =
      new()
      |> to(user.email)
      |> from({"MyApp", "[email protected]"})
      |> subject("Sign in to MyApp")
      |> html_body(html)
      |> text_body(text)

    with {:ok, _metadata} <- Mailer.deliver(email) do
      {:ok, email}
    end
  end

  defp heex_to_html(template) do
    template
    |> Phoenix.HTML.Safe.to_iodata()
    |> IO.iodata_to_binary()
  end

  defp html_to_text(html) do
    html
    |> Floki.parse_document!()
    |> Floki.find("body")
    |> Floki.text(sep: "\n\n")
  end
end

And there it is! We have a pretty ok looking transactional email in our Phoenix app, with very little code required. Here’s what these styles more or less look like at a tablet-ish size:

Finished html example.

Finished html example.