PhoenixKit.Email.Layout (phoenix_kit v2.45.0)

Copy Markdown View Source

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.

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.

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_color setting holds a colour.

Variables

Every part sees:

placeholdervalue
{{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:

placeholdervalue
{{{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

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.

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

source()

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

Where one part of the chrome came from: a host file, or core's own.

sources()

@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

default_header_html(opts \\ [])

@spec default_header_html(keyword()) :: String.t()

Core's header: the logo when there is one, else the site's name.

Options

  • :logo — true when {{logo_url}} is set. Default false.

default_html(opts \\ [])

@spec default_html(keyword()) :: String.t()

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's lang attribute when it is a well-formed language tag (pt_BR is written pt-BR); omitted otherwise.
  • :accent_bar — true draws a 3px bar in {{accent_color}} on top of the card. Default false.

document?(html)

@spec document?(String.t()) :: boolean()

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.

header_name()

@spec header_name() :: String.t()

The reserved template name the header resolves under.

name()

@spec name() :: String.t()

The reserved template name the layout resolves under.

render(content, subject, opts \\ [])

@spec render(String.t(), String.t() | nil, keyword()) :: {String.t(), sources()}

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); nil for none.
  • :branding — the logo_url/accent_color variables, when the caller has already read them (an invalid value reads as no logo / the neutral colour); read from PhoenixKit.Email.Branding otherwise.
  • :accent_bar — whether core's layout draws its accent bar. Defaults to whether the email_accent_color setting holds a colour, so a site that never chose one looks as it did before the bar existed.

text_to_html(text)

@spec text_to_html(String.t()) :: String.t()

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.

valid_group?(group)

@spec valid_group?(term()) :: boolean()

Whether group is a usable group name: [a-z0-9-]+.

wrap(content, subject, opts \\ [])

@spec wrap(String.t(), String.t() | nil, keyword()) :: String.t()

Wraps an HTML fragment in the layout for this message's locale.

subject may be nil. See render/3 for the options.