PhoenixKit.Email.Content (phoenix_kit v2.55.1)

Copy Markdown View Source

Resolves the content of one outbound message: subject, text, and optional HTML.

Every auth email in core goes through here, so the layering below is decided once rather than per message.

Layers, most specific first

  1. A database template from the emails package, when one is active under this name. Transitional — the template table is being retired (see dev_docs/plans/2026-09-06-filesystem-templates.md), but it is the only customization mechanism installs have today, and it must keep winning until the export task has shipped and operators have moved their edits to files. Removing it before then would silently revert every customized message.
  2. A host override file, resolved by PhoenixKit.Templates across the recipient's locale, its base language, and a locale-less fallback.
  3. Core's own default, a Gettext call evaluated in the recipient's locale.

Layers 2 and 3 combine per part, so a host that overrides only the body keeps core's translated subject.

What a host override looks like on disk

The template name is a directory; the files inside it are named for the part they supply, optionally carrying a locale. To rewrite the body of the new-login alert, a host adds:

<host>/priv/phoenix_kit_templates/
└── new_login_alert/            <- the template name (a directory)
    ├── text.txt                <- <part>.<ext>
    └── text.de.txt             <- <part>.<locale>.<ext>

subject, text and layout are .txt, html is .html, markdown is .md. That host now has its own body in German and a locale-less fallback for everyone else, while the subject still comes from core's Gettext default in all seven languages — parts resolve independently.

Roots come from override_paths/0. Full rules, including precedence and the path-safety constraints on name, live in PhoenixKit.Templates.

Which part makes which body

Five parts are resolved: subject, text, html, markdown and layout (the email's layout group, below). markdown and layout never leave this module — the result is always subject, text, html.

Each part comes from a host file if there is one, else from the caller's defaults. Each body then takes the first match, in this order:

HTML bodytext body
1host htmlhost text
2host markdown, renderedhost markdown, as text
3host text, escaped into paragraphsdefault text
4default htmldefault markdown, as text
5default markdown, rendered
6default text, escaped into paragraphs

For the HTML body every host part outranks every default: a host that overrides only text.txt gets its own words in the HTML too, rather than an HTML version built from the caller's markdown or html default that no longer says what the text does. Markdown feeds both bodies, so a host's markdown.md replaces the default copy in the HTML and in the text alike. A host's text builds the HTML only from escaped paragraphs (addresses linked, no buttons) — a host that wants buttons or a module's richer HTML overrides markdown.md or html.html instead. Markdown is rendered by PhoenixKit.Email.Markdown (buttons, accent-coloured links); text is escaped into paragraphs by Layout.text_to_html/1.

Earlier releases put a host's text after the defaults for the HTML body (html default, then markdown default). The order matters only for an email whose caller ships an html or markdown default and whose host overrides text alone — which core's own emails began to be when their defaults became Markdown.

With layout: false a host's text builds no HTML (see "The layout"), so its row is skipped and the HTML comes from the caller's html or markdown default, if there is one, while the text body is the host's. A caller that sends without the layout and ships an html or markdown default keeps the two versions in step only through a host's markdown.md or html.html.

A blank html, text or markdown (an empty override file, whitespace only) counts as no part, with or without the layout, so an empty html.html never sends an empty body: the next part in line is used.

Every part on this path also sees {{logo_url}} and {{accent_color}} (PhoenixKit.Email.Branding); a variable the caller passes under the same name wins when it is valid (a #rrggbb colour, an empty or http(s):// logo URL) and is replaced by the site's otherwise.

The layout

The HTML body is wrapped in the shared layout, PhoenixKit.Email.Layout, with its header and footer — each of which a host overrides with its own _layout, _header or _footer file. Only the html part changes; subject and text are returned as built above.

  • a fragment — it is wrapped.
  • a whole document (<!doctype … or <html …, after any BOM, whitespace, comments or XML prolog; Layout.document?/1) — it is left alone; it already carries its own chrome.
  • no html, markdown or text — nothing to wrap, html stays nil.
  • layout: false — nothing is wrapped, and a text-only message stays text-only (html is nil). A host's text then builds no HTML at all, so the HTML body may still come from the caller's html or markdown default (see "Which part makes which body").

Groups. layout: "billing" wraps the message in the billing group's chrome (_layout-billing, _header-billing, _footer-billing, each falling back to the shared one). Without the option, the email's own layout.txt names the group — its content is the group name, nothing else.

A database template (layer 1) is never wrapped.

Sources, for a preview

resolve_with_sources/5 resolves exactly as resolve/5 and also says where each part, the layout, the header and the footer came from.

Why the default is a function

defaults is a zero-arity function, not a map, because it is evaluated inside the recipient's locale — and because layer 1 short-circuits it entirely. Passing a map would translate content that a database template is about to discard.

Summary

Types

Resolved content plus the database template it came from, if any.

Where one part was found.

Where everything in one resolved message came from — for a preview screen.

Functions

Roots searched for host override files, most specific first.

Types

resolved()

@type resolved() :: %{
  subject: String.t() | nil,
  text: String.t() | nil,
  html: String.t() | nil,
  db_template: map() | nil
}

Resolved content plus the database template it came from, if any.

db_template is nil on the file/default path and is handed back so the caller can record usage against the row it used.

source()

@type source() :: :db | :default | {:file, Path.t()} | {:blank_file, Path.t()}

Where one part was found.

  • :db — the active database template.
  • {:file, path} — a host override file.
  • {:blank_file, path} — a host file that is empty or whitespace only, which counts as no part at all (it does not fall back to the default).
  • :default — the caller's default (core's Gettext copy, or a module's).

sources()

@type sources() :: %{
  subject: source() | nil,
  text: source() | nil,
  html: source() | nil,
  markdown: source() | nil,
  html_from: :html | :markdown | :text | nil,
  text_from: :text | :markdown | nil,
  group: String.t() | nil,
  group_from: :option | source() | nil,
  layout: PhoenixKit.Email.Layout.source() | nil,
  header: PhoenixKit.Email.Layout.source() | nil,
  footer: PhoenixKit.Email.Layout.source() | nil,
  ignored: [{:blank_file | :no_content, Path.t()}]
}

Where everything in one resolved message came from — for a preview screen.

  • subject, text, html, markdown — where each part was found; nil when nowhere.
  • html_from — what the HTML body was built from: :html, :markdown, :text (escaped into paragraphs), or nil (no HTML body).
  • text_from — what the text body is: :text, :markdown (converted), or nil.
  • group, group_from — the layout group and where it was named: :option (layout: "<group>"), or the source of the layout part.
  • layout, header, footer, ignored — see PhoenixKit.Email.Layout.sources/0; nil (and []) when nothing was wrapped.

Functions

override_paths()

@spec override_paths() :: [Path.t()]

Roots searched for host override files, most specific first.

config :phoenix_kit, template_paths: [...] wins; otherwise the host application's own priv/phoenix_kit_templates. An empty list means core's defaults are used verbatim, which is the correct answer for a host that has never written an override — and for a mix task running before the host application is loaded.

resolve(name, recipient, variables, defaults, opts \\ [])

@spec resolve(
  String.t(),
  term(),
  map(),
  (-> PhoenixKit.Templates.defaults()),
  keyword()
) :: resolved()

Resolves name for recipient.

recipient is anything PhoenixKit.Utils.RecipientLocale understands — a user struct, or a bare email string where no account exists yet. variables are substituted into whichever layer wins.

Options

  • :locale — overrides the locale resolved from recipient, for a caller whose recipient is a bare address that carries no preference, such as PhoenixKit.Mailer.send_from_template/4.
  • :paths — overrides the override roots, which is how a test points at a fixture directory; defaults to override_paths/0.
  • :layout — false skips the shared layout (see "The layout" above); a group name ("billing") wraps the message in that group's layout, over whatever the email's layout part names. Defaults to true.

resolve_with_sources(name, recipient, variables, defaults, opts \\ [])

@spec resolve_with_sources(
  String.t(),
  term(),
  map(),
  (-> PhoenixKit.Templates.defaults()),
  keyword()
) ::
  {resolved(), sources()}

resolve/5, plus where every part of the result came from.

The same resolution as a send, so a preview built on it cannot show a different source from the one a real email would use. See sources/0.