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

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 body | text body |
|---|---|---|
| 1 | host `html` | host `text` |
| 2 | host `markdown`, rendered | host `markdown`, as text |
| 3 | host `text`, escaped into paragraphs | default `text` |
| 4 | default `html` | default `markdown`, as text |
| 5 | default `markdown`, rendered | |
| 6 | default `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.

# `resolved`

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

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

```elixir
@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
    `t:PhoenixKit.Email.Layout.sources/0`; `nil` (and `[]`) when nothing
    was wrapped.

# `override_paths`

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

```elixir
@spec resolve(
  String.t(),
  term(),
  map(),
  (-&gt; 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`

```elixir
@spec resolve_with_sources(
  String.t(),
  term(),
  map(),
  (-&gt; 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 `t:sources/0`.

---

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