PhoenixKit.Email.Layout (phoenix_kit v2.55.1)

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.

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_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

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

parts()

@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, without content/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_url and logo_url are not escaped. Place them with two braces and substitute with escape: true; only header and footer take three. Any other key of a caller's :branding map is carried through as given (render/3 places it in the layout the same way); subject, site_name and site_url always win over a key of the same name.
  • sources — where each part came from ({:file, path} or :default), and under ignored the files passed over on the way ({:blank_file, path} for an empty one).

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.

render_parts(subject, opts \\ [])

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

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.

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.