We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
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 inurlas 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