Customizing emails with template files

Copy Markdown View Source

PhoenixKit's emails — account confirmation, password reset, magic link, the new-login alert, the welcome email (the full list), and anything a module sends through PhoenixKit.Mailer.send_from_template/4 — ship with translated default copy. A host changes that copy, or the HTML every email is wrapped in, by adding override files to its own application. No database rows, no template editor: the files deploy with the code. Only the branding — the project logo and the email_accent_color setting — lives in the database, set in the admin (see Branding), so it can change without a deploy. The admin also previews every email.

Where the files go

By default PhoenixKit looks in the host application's priv/phoenix_kit_templates/. To search other directories instead, list them (most specific first):

# config/config.exs
config :phoenix_kit, template_paths: [Path.expand("../priv/email_overrides", __DIR__)]

An empty list means "no overrides": every email uses PhoenixKit's defaults.

One directory per email

The email's name is a directory; the files inside are named for the part they supply, optionally with a locale:

priv/phoenix_kit_templates/
└── magic_link/
    ├── subject.txt        <- every language
    ├── subject.de.txt     <- German readers
    ├── text.txt
    ├── markdown.md        <- optional: the body in Markdown
    ├── html.html          <- optional: the body in HTML
    └── layout.txt         <- optional: the name of a layout group
partfile
subjectsubject[.locale].txtsubject line
texttext[.locale].txtplain-text body
markdownmarkdown[.locale].mdbody in Markdown, optional
htmlhtml[.locale].htmlbody in HTML, optional
layoutlayout.txtthe email's layout group, optional — never per language

The subject is one line: whitespace around it — the newline an editor leaves at the end of subject.txt included — is not part of it, and a line break inside it becomes a space.

Each part resolves on its own: for a reader in de-AT, text.de-AT.txt, then text.de.txt, then text.txt, then PhoenixKit's translated default. A host that overrides only text.txt keeps the translated subject in every language.

Placeholders are {{variable}}. In html and markdown a {{variable}} is HTML-escaped; {{{variable}}} (three braces) inserts the value raw and is only for a value that is already HTML.

Which file makes which body

Each part comes from your file if there is one, else from PhoenixKit's (or the module's) default. Each body then takes the first match:

HTML bodytext body
1your htmlyour text
2your markdownyour markdown, as plain text
3your text, escaped into paragraphs (addresses linked — see below)default text
4default htmldefault markdown, as plain text
5default markdown
6default text, escaped into paragraphs

For the HTML body every file of yours comes before every default. So one markdown.md is enough for both bodies, and it replaces the default copy in both — add text.txt only when the plain-text version should say something different. A text.txt on its own changes both bodies too: the HTML is built from your text (paragraphs, addresses linked, no buttons), not from the default Markdown that no longer says the same thing. To keep buttons — or a module's richer HTML, such as an invoice's table of lines — override markdown.md or html.html instead.

Earlier releases put a text.txt after the defaults for the HTML body. It made no difference while PhoenixKit's own defaults were plain text; now that they are Markdown, a host that had overridden text.txt would otherwise have been sent PhoenixKit's wording in the HTML version.

The exception is an email sent without the layout (layout: false): there your text.txt builds no HTML, so the HTML version still comes from the default html or markdown. Override markdown.md or html.html for such an email to change both versions.

An empty or whitespace-only file counts as missing for the bodies. It still hides the default of the same part — an empty text.txt does not bring back a default text — but the next part in line is used: for PhoenixKit's own emails, whose defaults are Markdown, an empty text.txt leaves the text version to the default Markdown.

Files are read once and cached; changing one takes a restart (a deploy).

Writing the body in Markdown

priv/phoenix_kit_templates/register/markdown.de.md:

Hallo {{user_email}},

bitte bestätigen Sie Ihr Konto:

[Konto bestätigen]({{confirmation_url}})

Wenn Sie sich nicht registriert haben, ignorieren Sie diese E-Mail einfach.

Headings, emphasis, lists, tables, strikethrough and links work as usual; quotes and dashes are typeset ("…" → “…”, -- → –).

Buttons. A top-level paragraph that is exactly one [label](url) link becomes a button in the accent colour — a coloured table cell, which every email client draws, Outlook included. The text on it is white on a dark accent and near-black on a light one. Any other link is a plain link in the accent colour — including a link alone in a list item or a quote. A bare address alone on a line stays a link — a bare {{url}} placeholder does not (it is a word until it is filled), so write [label]({{url}}).

Links. Placeholders in a link target are filled in after the Markdown is rendered, so [Confirm]({{confirmation_url}}) opens the real address. Only http://, https:// and mailto: targets become links (images: http(s) only); any other — javascript:, a relative path, a placeholder nothing filled — leaves the label as plain text. This is checked on the address after the placeholder is filled.

Plain text. Headings and paragraphs become lines, list items start with - (1. when numbered), [label](url) becomes label: url, an image becomes its alt text, and emphasis marks are dropped.

The address written out. [{{url}}]({{url}}) is a link whose label is the address itself; the plain-text version shows the address once. PhoenixKit's own emails put one under each button — If the button doesn't work, open this link: [{{confirmation_url}}]({{confirmation_url}}) — for a reader whose client does not draw the button.

Optional paragraphs. A paragraph of plain text whose placeholders all come out empty is left out of both versions, so a value with nothing to say (the new-login alert's {{failed_attempts}}) leaves no empty paragraph or gap. Write such a placeholder on a line of its own.

A block of HTML. A paragraph that is exactly one {{{variable}}} is the value alone, with no <p> around it — the way a caller places a table it built between Markdown paragraphs. The plain-text version inserts the value as is, so an email that does this needs a text of its own.

HTML inside Markdown is not rendered — it is dropped. A body that needs markup of its own goes in html.html.

PhoenixKit's own emails

Each one is a directory name under priv/phoenix_kit_templates/. Their default copy is Markdown — the main action is a button, with the address written out under it — translated into German, English, Spanish, Estonian, French, Italian, Polish and Russian. Every email also sees {{logo_url}} and {{accent_color}} (see Branding).

namesent whenvariables
registerafter registration, to confirm the addressuser_email, confirmation_url
reset_passwordsomeone asks to reset a forgotten passworduser_email, reset_url
update_emaila user changes their address (sent to the new one)user_email, update_url
magic_linksigning in with a one-time linkuser_email, magic_link_url
magic_link_registrationregistering with a one-time linkuser_email, registration_url
organization_invitationan address is invited to an organizationuser_email, organization_name, registration_url
new_login_alerta sign-in from a device the account has not useduser_email, login_time, ip_address, location, browser_os, failed_attempts, security_url
failed_login_alertrepeated failed sign-insuser_email, attempt_count, window_hours, security_url
welcomeonce, after the address is confirmed — off by defaultuser_email, site_name, site_url
notificationa notification the reader routes to emailsubject, text, url

failed_attempts is a sentence ending in a blank line, or empty when there were none; location already says "(approximate)". security_url is the account settings page. notification has no translated copy: its subject (the notification's title, or the start of its text), text and url (empty when the notification has no link) arrive already in the reader's language; its default is text, {{text}} and then {{url}}.

The welcome email

Off until you switch it on: Settings → Emails Transactional → Branding → Welcome email (the email_welcome_enabled setting). It is then sent once to each user, after they confirm their own address — by the link in the confirmation email, by signing in with a magic link or with an OAuth provider that verified the address while the account was unconfirmed, by registering with a magic link, or by correcting an address they had not confirmed yet ("Wrong email?"). An administrator confirming an account by hand sends nothing.

Only a real change from unconfirmed to confirmed counts: an old confirmation link clicked after an administrator confirmed the account sends nothing.

The confirmation enqueues a background job (Oban, queue notifications) — in the confirmation's own transaction, or, for magic-link registration, right after the new account is confirmed. The job sends the email once the confirmation has committed, and a confirmation that is rolled back never sends one. So the email needs Oban running on some node, as notification delivery already does. Enqueueing it never fails a confirmation that would otherwise succeed: on a node without Oban, when the job cannot be stored, or when oban_jobs is locked for more than two seconds (a migration, VACUUM FULL), the account is confirmed and the log says why the welcome email was not enqueued.

The email is sent at most once. The account is marked (welcome_email_sent_at in its custom fields) before the email goes out, so a confirmation repeated later never sends a second one. The mark is cleared and the job retried (up to three attempts) only when the email is known not to have gone out: building it failed, or the mailer reported that it was not sent. A failure during delivery, or a node that dies between the mark and the send, leaves it unknown whether the email went out; then it is not sent again. A lost welcome email is the smaller harm.

Tests in your app. With Oban's testing: :inline a job runs the moment it is inserted — inside the confirmation's transaction — so a welcome email is sent even for a confirmation the test then rolls back. That only concerns a test environment; use :manual and run the jobs to see what production does.

The button leads to {{site_url}} — the site URL the footer shows. To say more, or link elsewhere, override welcome/markdown.md like any other email.

Names PhoenixKit uses

The names in the table above belong to PhoenixKit — welcome and notification among them. Give your own emails other names: a file directory under one of these names rewrites PhoenixKit's email, and an active database template of the emails module with one of these names replaces it outright.

The layout every email is wrapped in

Every email built from a file or a default is sent with an HTML body inside a shared layout, made of three parts: the layout (the document), the header and the footer. PhoenixKit's own are deliberately plain — the logo (or, without one, the site's name) above the message, the name and a link to the site below it, and — once the email_accent_color setting holds a colour — a thin bar in that colour on top; table markup with inline styles and no words of their own, so they need no translation. The layout's <html lang> is the reader's locale (pt_BR written as pt-BR).

  • An email with only a text part gets its HTML body built from the text: every character escaped, a blank line starts a paragraph, a line break becomes <br>, and http:///https:// addresses become links (no other scheme does, and neither does an address longer than 2 KB). The text body is sent unchanged next to it. An empty or whitespace-only part counts as missing, with or without the layout.
  • An html part that is a fragment (<p>…</p>), or the HTML a markdown part renders to, is placed inside the layout.
  • An html part that is a whole document — starting with <!doctype or <html, after any byte-order mark, whitespace, comments or <?xml ?> prolog — is sent as it is; it already has its own chrome.
  • Emails still coming from database templates (the phoenix_kit_emails package) are never wrapped.

Each is an override like any other, under a reserved name (names starting with _ hold shared parts, never an email of their own):

priv/phoenix_kit_templates/
├── _header/
│   └── html.html          <- the header of every email
├── _footer/
│   ├── html.html          <- the footer, every language
│   └── html.de.html       <- … and for German readers
└── _layout/
    └── html.html          <- the whole document

Each is resolved for the same reader and from the same directories as the email it wraps, and only html is read. A host that wants its own header writes _header/html.html and keeps PhoenixKit's layout and footer; an empty or whitespace-only header or footer file counts as missing.

A header:

<!-- priv/phoenix_kit_templates/_header/html.html -->
<a href="{{site_url}}"><img src="{{logo_url}}" alt="{{site_name}}" height="32"></a>
<span style="color:{{accent_color}};">Customer service</span>

Placeholders have no conditions, so a header written like this shows a broken image while no logo is set — set the logo first, or keep PhoenixKit's header, which falls back to the site's name.

A layout with no content placeholder — an empty file, or a typo such as {{{contnet}}} — would drop the body of every email, the password reset included. PhoenixKit refuses it: it uses the next layout in line (a group's falls back to _layout, _layout to PhoenixKit's own) until the file is fixed, and logs a warning the first time (once per layout, directory list and language until the next restart). A header or footer needs no placeholder.

Variables available to the layout, the header and the footer:

placeholdervalue
{{subject}}the email's subject, e.g. for <title>
{{site_name}}the project title (the project_title setting, else config :phoenix_kit, project_title:)
{{site_url}}the site URL used in email links (the site_url setting, else the endpoint's URL) — as configured; PhoenixKit's own footer links it only when it is http(s)://
{{logo_url}}the project logo — see Branding; empty without one
{{accent_color}}the accent colour, #rrggbb

and to the layout only:

placeholdervalue
{{{content}}}the email's HTML body
{{{header}}}the rendered header
{{{footer}}}the rendered footer

Write {{{content}}}, {{{header}}} and {{{footer}}} with three braces. They are already HTML, escaped when they were built; with two braces they would be escaped a second time and the reader would see the tags as text. Use two braces for everything else, so a subject or a site name containing < or & cannot break the markup. A layout that places no header or footer simply has none.

The layout, header and footer see only the variables above — not the email's own ({{user_email}}, {{confirmation_url}}): they are shared by every email, so a placeholder only some emails bind would stay visible in the others.

A minimal layout:

<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>{{subject}}</title></head>
<body style="margin:0;padding:24px;font-family:Arial,sans-serif;">
  {{{header}}}
  {{{content}}}
  <div style="font-size:12px;color:#666;">{{{footer}}}</div>
</body>
</html>

Email clients ignore most of what a browser supports: keep styles inline, lay out with tables, and do not rely on external stylesheets or web fonts.

Layout groups

Some emails — invoices, say — want chrome of their own. A group is a name ([a-z0-9-]+, e.g. billing) with its own layout, header and footer:

priv/phoenix_kit_templates/
├── _layout-billing/html.html     <- the billing group's document
├── _header-billing/html.html     <- the billing group's header
├── _footer-billing/html.html     <- the billing group's footer
└── invoice_paid/
    └── layout.txt                <- contains the single word: billing

Two different things are called "layout" here — keep them apart:

what it iswhat it contains
invoice_paid/layout.txta file inside an email's directorythe name of the group the email belongs to — billing — and nothing else
_layout-billing/, _header-billing/, _footer-billing/directories next to the emailsthe group's markup, in html[.locale].html, like _layout/

Each of the group's parts falls back on its own: no _header-billing → the shared _header → PhoenixKit's header. A group may therefore replace only its footer. layout.txt is read whole and trimmed, never per language: a group belongs to the email, not to a translation.

Code can pick the group too, which wins over layout.txt:

PhoenixKit.Mailer.send_from_template("invoice_paid", email, vars,
  defaults: fn -> %{subject: gettext("Invoice paid"), markdown: gettext("…")} end,
  layout: "billing"
)

A group name that is not [a-z0-9-]+ is ignored (the shared layout is used) and logged as a warning.

Sending one email without the layout

PhoenixKit.Mailer.send_from_template("export_ready", email, vars,
  defaults: fn -> %{subject: gettext("Your export"), text: gettext("…")} end,
  layout: false
)

With layout: false a text-only email is sent as plain text, an html part is sent exactly as written, and a markdown part is sent as the bare HTML it renders to. Your text.txt does not become HTML here, so if the email has a default html or markdown, that still makes the HTML version — override markdown.md or html.html to change both.

Code that builds its own document — a module's newsletter wrapper, say — can still show the site's header and footer. PhoenixKit.Email.Layout.render_parts/2 renders those two parts on their own. It chooses them the same way the layout does: the group's file, then the shared one, then PhoenixKit's; the reader's language file first; an empty file counts as missing. It takes the layout's :locale, :paths, :group and :branding options. Without :paths it reads the same directories as every email (config :phoenix_kit, template_paths:, else the host app's priv/phoenix_kit_templates); pass paths: [] for PhoenixKit's own parts only.

alias PhoenixKit.Email.Layout
alias PhoenixKit.Templates.Substitution

parts = Layout.render_parts(subject, locale: "de", group: "newsletters")

variables =
  Map.merge(parts.variables, %{
    "header" => parts.header,
    "footer" => parts.footer,
    "content" => body_html
  })

Substitution.substitute(wrapper_html, variables, escape: true)
  • parts.header and parts.footer are HTML. Place them with three braces ({{{header}}}, {{{footer}}}).
  • parts.variables are the variables the parts were rendered with, from the table above: subject, site_name, site_url, logo_url, accent_color, plus any other key of a :branding map you pass, as given. They are raw text, not HTML: nothing in them is escaped. Write them with two braces ({{site_name}}) and substitute with escape: true, as above. Only header and footer take three.
  • parts.sources says which file each part came from ({:file, path}), or :default for PhoenixKit's own. Its ignored list names the files passed over on the way, such as an empty _header.

A wrapped email's header and footer are chosen by the same code, so the two cannot differ.

Branding: logo and accent colour

Two variables carry the site's branding into every part — the layout, the header, the footer and the body:

  • {{logo_url}} — the project logo already set under Settings (the project logo, else the site icon), as an absolute URL with a permanent signed token, so it still loads in an email opened weeks later. Empty when there is no logo, when the file is in the trash or no longer exists, and when the logo is stored in a private library — such a file only gets URLs that expire, which would break in an old email. PhoenixKit's header shows the site's name instead.
  • {{accent_color}} — the email_accent_color setting, a six-digit hex colour (#1d4ed8). Anything else, or nothing, reads as the neutral #18181b. Markdown buttons and links use it, and PhoenixKit's layout draws its top bar in it — only while the setting holds a colour, so a site that never set one keeps the look it had.

Both are set in the admin, on Settings → Emails Transactional → Branding (/admin/settings/email-sending): the accent colour is a field there (#RRGGBB, checked when saved; blank means the neutral default), and the logo, the site name and the site URL are edited under Settings → General, which that tab links to.

Both are read on every send: a new logo or colour shows in the next email without a restart. The code sending an email may pass either variable itself; its value is used only when valid — a #rrggbb colour, an empty or http(s):// logo URL — and the site's otherwise, in every part.

Which file of the logo. The first finished one that every email client shows (PNG, JPEG or GIF), trying the sizes smallest first: small, medium, large, then the original. A transparent logo's sizes are written as PNG — or as WebP when the host sets config :phoenix_kit, :variant_alpha_format, "webp", which Outlook for Windows does not show; such a size is passed over, usually for the original when that is a PNG. A logo with no such file at all (an SVG whose sizes are WebP, or sizes still being made) gives no URL. Sizes made before transparent images were written as PNG are JPEG: such a logo arrives on a white background until its sizes are regenerated.

Previewing emails

Settings → Emails Transactional → Branding → Preview emails (/admin/settings/email-sending/preview) lists every email the site knows and renders the chosen one in any enabled language, with sample values: the subject, the HTML (in a sandboxed frame) and the text, exactly as a send would build them. For every part — subject, HTML, Markdown, text, the layout group, the layout, the header and the footer — it says where it came from:

  • Database template — an active row of the emails module answers the name; it wins over files until it is deactivated.
  • Host file, with the file's path.
  • Empty file, ignored — a file was found but is blank, so it counts as missing.
  • Built-in default — PhoenixKit's (or the module's) own copy.
  • Set by the sending code — for the layout group: the code passes layout: "<group>", which wins over any layout.txt, so no file changes it.
  • Not used — the part plays no role in this email: no such file or default, or, for the layout group, layout, header and footer, the email is sent without the layout (layout: false, or a whole HTML document).

Next to each part it names the file to create to override it, as a path in the host application's source tree (priv/phoenix_kit_templates/…): the file ships with the code — one added on the server is lost on the next deploy. Placeholders that no sample value binds are listed, so a typo such as {{confirm_url}} shows before a reader sees it.

A module adds its own emails to the list with the optional email_templates/0 callback of PhoenixKit.Module — the template name it sends, a label, the same defaults function its send uses, sample variables and the layout option it passes (see PhoenixKit.Email.Catalog).

A complete example

priv/phoenix_kit_templates/
├── _header/
│   └── html.html                 <- the logo and a tagline, every email
├── _footer/
│   ├── html.html                 <- address and unsubscribe note
│   └── html.de.html              <- … in German
├── _layout-billing/
│   └── html.html                 <- invoices: a wider document
├── _footer-billing/
│   └── html.html                 <- invoices: legal details
├── register/
│   ├── subject.de.txt            <- "Bitte bestätigen Sie Ihr Konto"
│   ├── markdown.md               <- English body, with a button
│   └── markdown.de.md            <- German body, with a button
└── invoice_paid/
    ├── layout.txt                <- billing
    └── markdown.md

A German reader of register gets the German subject and body (HTML with a button, plain text with Konto bestätigen: https://…), the shared header, the German footer and PhoenixKit's layout. invoice_paid gets the billing layout and footer, and still the shared header — there is no _header-billing.