The shared HTML layout every email built from a file or a default is wrapped in.
PhoenixKit.Email.Content.resolve/5 calls render/3 on the file/default
path, so an email reaches the reader as HTML even when all it has is text:
core's auth emails and the defaults a module passes to
PhoenixKit.Mailer.send_from_template/4 ship no html part of their own.
Content from a database template is never wrapped — it carries its own
chrome.
Three parts: layout, header, footer
The layout is the document around the body; the header and footer are rendered on their own and handed to it, so a host can replace just the header without copying the rest:
<host>/priv/phoenix_kit_templates/
├── _layout/html.html <- the document; must place {{{content}}}
├── _header/html.html <- placed as {{{header}}}
└── _footer/html.html <- placed as {{{footer}}}Each is resolved like any other template (PhoenixKit.Templates): the
message's own locale and override roots, html.de.html before html.html.
Only the html part is read, and only the variables below are bound — not
the message's own, since every email shares these parts.
render_parts/2 renders the header and footer alone, chosen the same way,
for a caller that builds its own document around them.
An empty or whitespace-only _header/_footer file counts as missing.
Neither needs a placeholder. A _layout with no content placeholder (an
empty file, a typo such as {{{contnet}}}) would drop the body of every
email, so it is refused and the next layout in line is used; a warning is
logged once per layout name, override roots and locale for the life of the
VM.
Groups
A group of emails can have chrome of its own: _layout-<group>,
_header-<group>, _footer-<group>. Each falls back on its own — to the
shared _layout/_header/_footer, then to core's — so a billing group
may replace only its footer. The group is the :group option here;
Content.resolve/5 takes it from layout: "<group>" or from the email's
own layout.txt. A group name is [a-z0-9-]+; any other is ignored, with a
warning.
Core's defaults
default_html/1, default_header_html/1 and default_footer_html/1 are
core's own parts, kept in code for the same reason the message defaults are
Gettext calls rather than files: core ships no files of its own. They are
deliberately neutral — table markup with inline styles, no external
resources but the site's own logo — and carry no words of their own,
so they need no translation.
- The header shows the logo (
{{logo_url}}, from Settings) when there is one, else the site's name. - The footer shows the site's name and a link to the site.
- The layout is a card with the header, the body and the footer, and a
thin bar in the accent colour on top once the
email_accent_colorsetting holds a colour.
Variables
Every part sees:
| placeholder | value |
|---|---|
{{subject}} | the message subject, for <title> |
{{site_name}} | PhoenixKit.Settings.get_project_title/0 |
{{site_url}} | PhoenixKit.Utils.Routes.base_url/0 |
{{logo_url}} | PhoenixKit.Email.Branding — "" without a logo |
{{accent_color}} | PhoenixKit.Email.Branding — #rrggbb |
and the layout also:
| placeholder | value |
|---|---|
{{{content}}} | the message body, already HTML |
{{{header}}} | the rendered header, already HTML |
{{{footer}}} | the rendered footer, already HTML |
site_url is whatever the site is configured with. Core's footer links it
only when it is an http(s):// address and prints it as text otherwise.
content, header and footer must be written with three braces.
The html part escapes every {{variable}}, which is right for subject
and the site's name but would print the body's markup as visible text.
Triple braces are the escaping opt-out, and these values are safe to insert
raw because they were escaped when they were built.
Opting out
layout: false to Content.resolve/5 or Mailer.send_from_template/4
leaves the message exactly as resolved. An html part that is already a
whole document is never wrapped either — an export of an old database
template is one. It is a document when, after any byte-order mark,
whitespace, <!-- comments --> and <?xml … ?> prolog, it starts with
<!doctype or an <html tag (<html>, <html lang="…">), in any case.
Summary
Types
The header and footer of one message, rendered on their own (see
render_parts/2).
Where one part of the chrome came from: a host file, or core's own.
Where the layout, header and footer of one render came from. header and
footer are nil when the layout used does not place them. ignored
lists the files that were found but passed over: {:blank_file, path} for
an empty header or footer, {:no_content, path} for a layout that does not
place {{{content}}}.
Functions
Core's footer: the site's name and a link to the site.
Core's header: the logo when there is one, else the site's name.
Core's layout: a card with the header, the body and the footer — and a bar in the accent colour on top when the site has chosen one.
Whether html is a whole document rather than a fragment to wrap.
The reserved template name the footer resolves under.
The reserved template name the header resolves under.
The reserved template name the layout resolves under.
Wraps an HTML fragment in the layout, and says where the layout, header and footer came from.
The header and footer for a message, rendered on their own, without the layout around them.
Plain text as an HTML fragment.
Whether group is a usable group name: [a-z0-9-]+.
Wraps an HTML fragment in the layout for this message's locale.
Types
@type parts() :: %{ header: String.t(), footer: String.t(), variables: %{required(String.t()) => term()}, sources: %{ header: source(), footer: source(), ignored: [{:blank_file, Path.t()}] } }
The header and footer of one message, rendered on their own (see
render_parts/2).
header,footer— already HTML, to be placed with three braces.variables— the variables the parts were rendered with (see "Variables" above, withoutcontent/header/footer), for a caller whose own wrapper names{{site_name}},{{accent_color}}and the like. These are raw text, not HTML:subject,site_name,site_urlandlogo_urlare not escaped. Place them with two braces and substitute withescape: true; onlyheaderandfootertake three. Any other key of a caller's:brandingmap is carried through as given (render/3places it in the layout the same way);subject,site_nameandsite_urlalways win over a key of the same name.sources— where each part came from ({:file, path}or:default), and underignoredthe files passed over on the way ({:blank_file, path}for an empty one).
@type source() :: {:file, Path.t()} | :default
Where one part of the chrome came from: a host file, or core's own.
@type sources() :: %{ layout: source(), header: source() | nil, footer: source() | nil, ignored: [{:blank_file | :no_content, Path.t()}] }
Where the layout, header and footer of one render came from. header and
footer are nil when the layout used does not place them. ignored
lists the files that were found but passed over: {:blank_file, path} for
an empty header or footer, {:no_content, path} for a layout that does not
place {{{content}}}.
Functions
Core's header: the logo when there is one, else the site's name.
Options
:logo—truewhen{{logo_url}}is set. Defaultfalse.
Core's layout: a card with the header, the body and the footer — and a bar in the accent colour on top when the site has chosen one.
Options
:locale— becomes the document'slangattribute when it is a well-formed language tag (pt_BRis writtenpt-BR); omitted otherwise.:accent_bar—truedraws a 3px bar in{{accent_color}}on top of the card. Defaultfalse.
Whether html is a whole document rather than a fragment to wrap.
Leading byte-order marks, whitespace (non-breaking included), comments and
an XML prolog are skipped before looking for <!doctype or <html.
@spec header_name() :: String.t()
The reserved template name the header resolves under.
@spec name() :: String.t()
The reserved template name the layout resolves under.
Wraps an HTML fragment in the layout, and says where the layout, header and footer came from.
Options
:locale,:paths— the message's own, so the chrome resolves from the same override roots and for the same reader as the message.:group— the email's group (see "Groups" above);nilfor none.:branding— thelogo_url/accent_colorvariables, when the caller has already read them (an invalid value reads as no logo / the neutral colour); read fromPhoenixKit.Email.Brandingotherwise.:accent_bar— whether core's layout draws its accent bar. Defaults to whether theemail_accent_colorsetting holds a colour, so a site that never chose one looks as it did before the bar existed.
The header and footer for a message, rendered on their own, without the layout around them.
For a caller that builds its own document but wants the site's chrome in
it — a newsletter's wrapper, say, placing {{{header}}} and {{{footer}}}
around its own body. The parts are chosen exactly as render/3 chooses
them — both use the same private resolution: per group, locale and
override root, an empty file counting as missing, core's own part last
(see "Groups" and "Core's defaults" above).
Takes render/3's :locale, :paths, :group and :branding options;
subject may be nil. One default differs: without :paths (or with
paths: nil) the host's override roots are read,
PhoenixKit.Email.Content.override_paths/0 — the same roots
PhoenixKit.Email.Content.resolve/5 reads — so a caller gets the host's
_header/_footer files without naming them. Pass paths: [] for core's
parts only. Returns parts/0:
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, variables, escape: true)header and footer are HTML: place them with three braces. Everything
in variables is raw text: place it with two braces, under escape: true.
Plain text as an HTML fragment.
Every character of text is escaped. A blank line separates paragraphs,
each a <p>; a single line break becomes <br>. An http:// or https://
address becomes a link. It ends at <, >, ", any Unicode whitespace or
separator, any invisible format character (zero-width spaces and joiners,
the soft hyphen, the byte-order mark, bidirectional controls — which could
otherwise make a link display a different address from the one it opens),
guillemets, the em dash and the punctuation after it up to the ellipsis
(U+2014–U+2027: typographic quotes, … and the like), and CJK/fullwidth
punctuation. Hyphens and the en dash do not end it, since real addresses
carry them. Trailing sentence punctuation and unbalanced closing
parentheses stay outside it. No other scheme becomes a link, so
javascript: and friends remain text, and neither does an address longer
than 2048 bytes.
Whether group is a usable group name: [a-z0-9-]+.
Wraps an HTML fragment in the layout for this message's locale.
subject may be nil. See render/3 for the options.