# `PhoenixKit.Email.Layout`
[🔗](https://github.com/BeamLabEU/phoenix_kit/blob/v2.55.1/lib/phoenix_kit/email/layout.ex#L1)

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_color`
    setting 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.

# `parts`

```elixir
@type parts() :: %{
  header: String.t(),
  footer: String.t(),
  variables: %{required(String.t()) =&gt; 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`

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

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

# `sources`

```elixir
@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}}}`.

# `default_footer_html`

```elixir
@spec default_footer_html(keyword()) :: String.t()
```

Core's footer: the site's name and a link to the site.

## Options

  * `:link` — `false` prints `{{site_url}}` as text instead of a link.
    Default `true`.

# `default_header_html`

```elixir
@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`

```elixir
@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?`

```elixir
@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`.

# `footer_name`

```elixir
@spec footer_name() :: String.t()
```

The reserved template name the footer resolves under.

# `header_name`

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

The reserved template name the header resolves under.

# `name`

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

The reserved template name the layout resolves under.

# `render`

```elixir
@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`

```elixir
@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 `t: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`

```elixir
@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?`

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

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

# `wrap`

```elixir
@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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
