PhoenixKitTemplatesEditor
A PhoenixKit module that edits your
app's email template files — the phoenix_kit_templates
overrides PhoenixKit renders its emails from — in a tab of
Settings › Emails Transactional.
Your templates stay yours: one directory of files in your app, deployed with your code. This package is only the tool, plus a block of config that tells it which templates are yours and how to show them.
Status: 0.1.0, unreleased.
Installation
# mix.exs
{:phoenix_kit_templates_editor, "~> 0.1"}
Then mix deps.get. PhoenixKit discovers the module by itself — no config line
in :phoenix_kit, :modules is needed. Switch it on at Admin › Modules
(Templates editor); it is off by default, and a switched-off module shows no
tab.
Requires phoenix_kit 2.57.0 or later.
Let the tab know who is acting
PhoenixKit mounts the tab with nothing but its id, so the module needs a hook in PhoenixKit's live sessions to check, on every save, who is saving:
# config/config.exs
config :phoenix_kit,
extra_live_session_on_mount: [PhoenixKitTemplatesEditor.Web.ScopeHook]
This is read when your router compiles. Without it the tab works, but shows the templates read-only and says why.
The hook is a stopgap: once PhoenixKit's email settings page passes its
phoenix_kit_current_scope to the tabs it mounts (a one-line change there), the
tab takes the scope from that and the hook can go.
The variables panel's hook
A click on a variable puts it at the cursor through a LiveView hook the module
ships prebuilt (priv/static/assets/phoenix_kit_templates_editor.js, declared
by js_sources/0). PhoenixKit's :phoenix_kit_js_sources compiler folds it
into your LiveSocket — no inline script, no app.js edit — provided your
mix.exs lists the compiler:
compilers: [:phoenix_kit_js_sources, :phoenix_live_view] ++ Mix.compilers()
Without it the panel still works: a click puts the variable at the end of the body instead.
Where templates are edited: the directory
The module never changes a file's owner. Where editable: true, make the
templates root a setgid directory of the group that owns your tree, so what the
server writes stays editable by the people who commit it:
chgrp -R devs priv/phoenix_kit_templates
chmod -R g+rwX priv/phoenix_kit_templates
find priv/phoenix_kit_templates -type d -exec chmod g+s {} +
Then give the server process the umask 0002 (supervisord umask=002,
systemd UMask=0002): new files come out 0664 and new directories 2775,
in the root's group. The module sets no mode and no owner itself: doing that
after a write, as the server's user, in a directory other people can write to,
could be turned onto another file through a link put there in the meantime.
Who sees the tab
The email settings page is behind PhoenixKit's settings permission. The tab
adds the module's own key, templates_editor, so it can only narrow that:
| To | A role needs |
|---|---|
| see the tab | settings and templates_editor, with the module switched on |
| save, create or delete files | the above and templates_editor.write, and editable: true in config |
Owner holds every permission. Admin is granted the module's keys when PhoenixKit first discovers it; revoke them in the roles matrix to hide the editor from Admins.
For your own "Edit templates" links:
if PhoenixKitTemplatesEditor.accessible?(scope) do
# href = PhoenixKitTemplatesEditor.tab_path() # URL prefix and locale applied
end
Configuration
# config/config.exs
config :phoenix_kit_templates_editor,
root: nil, # nil: the first of PhoenixKit.Email.Content.override_paths/0
name_prefixes: ["shop_order_", "_header-shop", "_footer-shop", "_layout-shop"],
locales: ~w(et ru en),
base_locale: "et",
layout_group: "shop",
preview: {MyApp.Emails, :preview}, # (name, locale) -> {subject, html, text}, see below
sample_variables: {MyApp.Emails, :sample_variables}, # () -> map
block_placeholders: ["documents_list"],
catalog: true
# config/dev.exs — only where templates are edited
config :phoenix_kit_templates_editor, editable: true
| Key | Default | Meaning |
|---|---|---|
root |
first of PhoenixKit.Email.Content.override_paths/0 |
the templates directory; it must exist |
editable |
false |
true allows writing, false shows the files read-only; any other value is a config error (the tab lists it, no editor). Leave it unset in production |
name_prefixes |
[] |
which templates the editor sees and may write — nothing until you say. A prefix shaped like a layout part (_header-shop) allows that part of layout shop and shop-…, not every name starting with the string |
locales |
PhoenixKit's enabled languages (base codes), else just base_locale |
the language tabs, in order |
base_locale |
first of locales, else PhoenixKit's default language, else "en" |
the language labels and notes fall back to |
layout_group |
nil |
the layout (_header-<g>, _footer-<g>, _layout-<g>) of an email without layout.txt |
layout_groups |
[layout_group] |
layout namespaces the editor may show and create: g and g-… (not gx) |
preview |
nil |
your preview callback (below); without one the module renders its own |
sample_variables |
%{} |
a map, or a callback returning one (below) |
describe_variables |
nil |
(name) -> %{variable => description}, shown in the variables panel (below) |
complete? |
nil |
(name, base_locale) -> boolean (below); default: subject and body on base_locale |
sent_by |
nil |
(name) -> :core | :host — who sends the email (below); default: PhoenixKit for the emails of its catalogue, your app for your own |
test_send |
nil |
(name, locale, user) — your own way to send "Send to me" for your own emails (below); default, and always for the emails PhoenixKit and its modules send: PhoenixKit.Mailer |
block_placeholders |
[] |
variables that stand as a block of their own |
raw_variables |
[] |
variables allowed in {{{x}}} besides content, header, footer |
email_fallback |
true |
whether emails get a locale-less Fallback tab |
shared_layout_editable |
false |
whether the shared _header/_footer/_layout (PhoenixKit's own system emails) may be edited |
overridable |
[] |
which emails PhoenixKit and its modules send may be given a file here — none until you say: a list — exactly those names; :all, your explicit choice — every one but the emails about getting into an account (account confirmation, password reset, email change, magic link sign-in and registration, organization invitation, the new-login and failed-sign-in alerts), which only a list names; "every one but" is this package's own list of those emails (Names.auth_emails/0), not core's current set, so a sign-in email a newer core adds is overridable under :all until the package is updated |
catalog |
false |
whether your templates are offered to PhoenixKit's email preview |
watch |
same as editable |
whether the files are re-read when they change on disk |
max_templates |
500 |
the most templates the root may hold: a new one past it is refused (nil — no limit) |
max_root_bytes |
52_428_800 (50 MiB) |
the most bytes the root's files may take: a save, copy or new file past it is refused (nil — no limit) |
A config that does not check out is shown on the tab as a list of problems — the page itself keeps working.
Callbacks
Each is {module, function} or a function of the arity shown. One that raises,
throws or exits is logged and never takes the page down.
preview(name, locale)— the preview of the template open in the editor.nameis one of your emails, an email PhoenixKit or a module sends once it has files here, or a layout part:_header-<g>,_footer-<g>,_layout-<g>(and the shared_header,_footer,_layoutwithshared_layout_editable: true).localeisnilon the Fallback tab. Return{subject, html},{subject, html, text}— each a string ornil; a{:safe, iodata}is escaped like a string — or{:error, reason}, shown in your words. Anything else is shown as an error. It runs with a 5 s deadline, past which the preview says it is too slow; it is not called for a template with no files yet, nor while the template or its layout has a file the module will not read (see What is never read).sample_variables()— or a plain map: your emails' variables,%{name => sample}, read when the tab is mounted. Anything but a map is logged and taken as%{}.describe_variables(name)—%{variable => description}for the variables panel of templatename, layout parts included. Descriptions that are not strings are left out; any other answer describes nothing.complete?(name, base_locale)— asked for each of your emails (no leading_) on every listing; onlyfalseflags the email in the list: it will not be offered for sending.sent_by(name)—:coreor:hostfor the open template; any other answer is the default (PhoenixKit for the emails of its catalogue, your app for your own).test_send(name, locale, user)— "Send to me" for one of your own emails: never a layout part, never an email PhoenixKit or a module sends (those go throughPhoenixKit.Mailer, as they do for real).localeis the tab's,base_localeon the Fallback tab;useris the user asking, once the module has checked that the page says who it is, that they may see the tab and that they sent none in the last 30 s. Return{:ok, address}when it went elsewhere than to the user (your test mode, say — the notice names that address),:okor{:ok, _}when it went to the user, or{:error, reason}. It runs in the page's process with no deadline: keep it fast.
The tab
- Notes for your editors. Put
README.<language>.md(orREADME.md) in the templates root — the language of the page first, thenbase_locale, thenREADME.md. It is shown above the editor as Markdown (sanitized), read-only servers included. Write there what only your project knows: which variables an email may use, what stops it from being sent. The template list skips files in the root, so the notes never show up as a template. - Not committed yet. Where
editable: true, a button lists the files under the root that differ from git's last commit, and the list refreshes after every change made in the editor. Git runs without the repository's hooks orGIT_*variables and never rewrites the index. - Layouts. A layout
gis_header-g,_footer-gand_layout-g; a part it lacks comes from the shared_header/_footer/_layout, else PhoenixKit's own. The tab lists the layouts inlayout_groups— the default (layout_group) first — with which parts are their own, which are inherited, and the emails using each. A new layout is a copy of an existing one; a layout goes only when no email uses it. An email's Layout field writes itslayout.txt(or deletes it for the default), and the preview follows at once. - Names it never touches. Whatever
name_prefixessay: the shared_header/_footer/_layout(they wrap PhoenixKit's sign-in and password emails too) unlessshared_layout_editable: true, and the emails PhoenixKit or another module sends — but through Module emails, below. - Module emails. Every email PhoenixKit and its modules send (their
catalogue, the one Preview emails lists), read only — the built-in text in
each language — until it has a file here. Where
overridableallows it (none by default, seeoverridable), a writer creates the file from the built-in text, one per language oflocales(from then on module updates no longer change that email's text), or, with the Emails module installed and an active database template of that name, from that row by the Emails module's own export. A module's own layout (layout: "billing"in its catalogue entry) gets its header, footer and page layout the same way, from your shared one or PhoenixKit's. The sign-in emails are left out of:alland carry a warning wherever they are shown. - What is sent. For an email PhoenixKit sends, each part — subject, text,
HTML, Markdown — says where it comes from, asked of PhoenixKit's own
resolution: this file, another file, an empty file (the part is left out),
the built-in text, or the Emails module's database template, which wins over
every file (a red notice says so). An email your app sends itself
(
sent_by) says the files are what it uses. Turning that database row into the files is offered only while the email has none ("Create file from the database template"); once it has, there is no button to overwrite them from the row — the row is exported by the Emails module's own task and the files merged by hand. Not done yet: decided, not forgotten. - Variables. Beside the form, the variables of the part's kind of
template, with a sample and your
describe_variablestext: an email's own (its catalogue samples, orsample_variables), the branding (logo_url,accent_color) in Markdown — everywhere for an email PhoenixKit sends — or, for a header, footer or layout, the layout's. A click puts one in the field. A save is checked: a variable of the other kind ({{site_url}}in an email's body) is saved only after "Save anyway"; an unknown one with a warning;{{{x}}}only forraw_variables(and a page layout'scontent,header,footer); a subject one line of at most 998 bytes. - Send to me. One email, with the sample variables, in its layout, to the
address of the user asking and nobody else — once in 30 s per user, logged,
read-only servers included. Your
test_sendcallback sends your own emails if you give one;PhoenixKit.Mailersends the rest, and the emails PhoenixKit and its modules send always. - Someone else changed the file. A part's file is fingerprinted when the
form is given it; a save of a part whose file has changed on disk since — by
hand, by
git pull, by another user — is refused, showing what is on disk now, and your text stays in the form. Reload reads everything again. - Activity log. Every save, deletion, copy, file made for a module's email and change of layout is in PhoenixKit's activity log: who, which template, part and language, and a short diff.
- Copying a template takes every file of its directory; your own files
beside the parts (
audience.txt, say) are listed to untick. - Preview. Without a
previewcallback the module renders the email as PhoenixKit would from these files: HTML fromhtml, else Markdown, else text; text fromtext, else Markdown, else the HTML; your sample variables filled in; wrapped in the layout of the email'slayout.txt, elselayout_group. A header, footer or layout is shown around the first email using that layout (else the first email, else a Lorem ipsum). The preview frame runs no scripts and loads no images but from your site's origin. - Converters. Fill text from content and Markdown → HTML keep
{{placeholders}}as typed (link targets too); a variable inblock_placeholdersalone in a paragraph comes out of the<p>. - Files changed on disk. Where the files are edited (or
watch: true), the module looks at them every 2 s and drops PhoenixKit's cached templates when they change — a hand edit or agit pullshows in the next email. It then broadcasts{:templates_editor, :files_changed}onPhoenixKitTemplatesEditor.Watcher.topic/0through PhoenixKit's PubSub, for a page of your own that shows something made of the files (the tab itself does not listen: core's page has nohandle_infofor it). - Preview emails. With
catalog: trueyour emails (names undername_prefixes, no leading_) appear in PhoenixKit's Preview emails — providedrootis the directory PhoenixKit reads emails from (config :phoenix_kit, :template_paths); otherwise the tab says so. - What is never read. A template file that is a symbolic link, leads out of the root or is larger than 256 KiB is shown as unavailable, never read. A template with such a file is not previewed, sent to you or copied, and its layout's are not either: PhoenixKit's own lookup follows links.
What this module does NOT do
- Ship templates. No header, footer or email text comes with it; they are your app's files.
- Write in production by default.
editableis off unless your config saystrue; template changes reach production through your repository and deploy. - Commit to git. It writes files; committing them is yours.
- Change file owners. Writes keep the server's user; group access comes from the setgid root (above).
- Grant access beyond
settings. Its key can only narrow who sees the email settings page. - Own database tables. One setting row (
templates_editor_enabled) is all it stores.
Development
mix deps.get
mix test.setup # once; creates the test database
mix test
mix precommit
See AGENTS.md for conventions and the test setup.
License
MIT — see LICENSE, which also carries the notice for code derived from
phoenix_kit_templates.