Let a message be written once and inserted as a template

Settings → Templates is a new section: a tree on the left, one editor
beside it. The tree opens with one folder per mail account, and those
are not stored — an account folder is the account, drawn as a folder,
so the mirror cannot drift. Folders the user makes go inside an
account, inside one another or beside the accounts. Removing an
account or a folder keeps the templates in it and moves them up.

A template holds variables: the recipient's first name, name and
address, the sender's name and address, a signature, and dates with an
offset and a format of their own. They are stored as text tokens in
the body, because the body goes through the inbound-mail sanitiser and
a marked element would not survive it; the editor draws them as chips
and turns them back before it posts.

The compose window gets a button beside the signature one. Dates,
sender and signature are filled in by the server when a template is
inserted. The recipient is not: a template is usually picked before
anyone is addressed, so recipient variables arrive as markers and the
window fills them when To has somebody in it, and asks before sending
with one still open. A template's signature replaces the window's
rather than joining it, and one the template names is pinned so a
change of From leaves it alone.

  TemplateTokens     the notation, read the same way in PHP and in
                     assets/compose/template_variables.js
  TemplateRenderer   what each variable becomes, and who fills what
  TemplateLibrary    the tree, and the only writer of where a
                     template or folder sits

One bug fell out of running it in a browser. Chromium will not put a
caret between a non-editable element and the edge of its block, so a
template ending in a variable could not be given its full stop — the
keystrokes were dropped. The editor keeps a zero-width space beside
each chip and strips them before saving.

Two new tables, mail_template and template_folder. Web only so far:
nothing is published over JMAP yet.

Refs #26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5.5 committed 2026-10-09 14:27:17 +02:00
1 parent 4656f8874e
commit 09600802db
52 files changed
+6410 -20

No files matched your search

+144
View File
@@ -0,0 +1,144 @@
{#
compose/_template_panel.html.twig
─────────────────────────────────────────────────────────────────────────────
The compose window's template picker: a list to choose from, a preview of
whichever row the pointer or the focus is on, and two ways out at the foot.
Fetched into the dropdown each time it opens (see _window.html.twig for why it
is not rendered with the window) and driven by compose--template-picker, which
is on the dropdown's wrapper — this fragment declares targets and actions for
it and no controller of its own.
ORDER. The account being written from comes first, because that is where the
template for this message most likely is. Then the top level, which belongs to
no account. Then everything else, under its own headings: filed elsewhere, but
still the user's, and still one click away.
Inside a group the folders are flattened, each template carrying its folder's
name at the right. A tree with disclosure triangles in a 13rem column of a
dropdown is a tree nobody can aim at; the search field is how a long list is
navigated here.
EACH ROW CARRIES ITS OWN PREVIEW, as the stored subject and body, tokens and
all. The controller draws the tokens as the same chips the editor draws
(template_variables.js), so the preview shows what the template IS — which
variable goes where — and the inserted message shows what it became. The body
is in a <template> element so the browser parses it without rendering it or
loading anything it refers to.
Variables:
tree App\Domain\DTO\Template\TemplateTree
current Account|null the account in From
chipLabels see TemplateEditorViewData::chipLabels()
#}
{% macro rows(templates, folder) %}
{% for template in templates %}
<li>
<button type="button"
data-compose--template-picker-target="item"
data-url="{{ path('app_compose_templates_render', { id: template.id }) }}"
data-name="{{ template.name|lower }}"
data-subject="{{ template.subject }}"
data-action="click->compose--template-picker#choose mouseenter->compose--template-picker#preview focus->compose--template-picker#preview"
class="flex w-full items-center gap-2 min-h-9 md:min-h-8 px-2 py-1 rounded-lg text-sm text-left
text-ink-muted hover:bg-hover hover:text-ink focus:bg-hover focus:text-ink focus:outline-none
transition-colors cursor-pointer">
<i class="fa-regular fa-file-lines w-4 text-center text-ink-faint shrink-0" aria-hidden="true"></i>
<span class="truncate flex-1 min-w-0">{{ template.name }}</span>
{% if folder %}
<span class="shrink-0 max-w-[40%] truncate text-[11px] text-ink-faint">{{ folder }}</span>
{% endif %}
<template>{{ template.body|raw }}</template>
</button>
</li>
{% endfor %}
{% endmacro %}
{% macro group(node, heading) %}
{% if node.total > 0 %}
<li data-compose--template-picker-target="group">
<p class="px-2 pt-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-ink-faint truncate">{{ heading }}</p>
<ul>
{% for folder in node.flattened %}
{{ _self.rows(folder.templates, folder.depth > 0 ? folder.name : null) }}
{% endfor %}
</ul>
</li>
{% endif %}
{% endmacro %}
<div data-compose--template-picker-target="root"
data-chip-labels="{{ chipLabels|json_encode|e('html_attr') }}"
data-csrf="{{ csrf_token('template_save_draft') }}"
class="md:grid md:grid-cols-[13.5rem_minmax(0,1fr)]">
<div class="min-w-0 p-1.5 md:border-r border-line">
{% if tree.empty %}
<p class="px-2 py-6 text-center text-sm text-ink-faint">{{ 'compose.template.empty'|trans }}</p>
{% else %}
<label class="flex items-center gap-2 h-8 px-2 rounded-lg border border-field bg-field text-ink-faint">
<i class="fa-solid fa-magnifying-glass text-[11px]" aria-hidden="true"></i>
<input type="search"
data-compose--template-picker-target="search"
data-action="input->compose--template-picker#filter"
placeholder="{{ 'compose.template.search'|trans }}"
aria-label="{{ 'compose.template.search'|trans }}"
class="flex-1 min-w-0 bg-transparent text-sm text-ink placeholder:text-ink-faint focus:outline-none">
</label>
<ul class="mt-1 max-h-64 overflow-y-auto">
{% for node in tree.accounts %}
{% if current is not null and node.location.account is same as(current) %}
{{ _self.group(node, node.name) }}
{% endif %}
{% endfor %}
{% if tree.templates|length %}
<li data-compose--template-picker-target="group">
<p class="px-2 pt-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-ink-faint">
{{ 'compose.template.all_accounts'|trans }}
</p>
<ul>{{ _self.rows(tree.templates, null) }}</ul>
</li>
{% endif %}
{% for node in tree.accounts %}
{% if current is null or node.location.account is not same as(current) %}
{{ _self.group(node, node.name) }}
{% endif %}
{% endfor %}
{% for node in tree.folders %}
{{ _self.group(node, node.name) }}
{% endfor %}
</ul>
<p data-compose--template-picker-target="noMatch" hidden
class="px-2 py-4 text-center text-sm text-ink-faint">{{ 'compose.template.no_match'|trans }}</p>
{% endif %}
</div>
{# The preview. Hidden below md: there a row is tapped, not hovered, and a
tap inserts — a pane that only ever shows the template you have just
inserted is a pane showing you the message. #}
<div class="hidden md:flex flex-col min-w-0 p-3">
<p data-compose--template-picker-target="previewSubject"
class="pl-var-preview text-sm font-semibold text-ink break-words"></p>
<div data-compose--template-picker-target="previewBody"
class="pl-var-preview mt-1.5 max-h-56 overflow-y-auto text-xs leading-relaxed text-ink-soft"></div>
</div>
<div class="md:col-span-2 flex items-center justify-between gap-3 px-3 py-2 border-t border-line bg-sunken">
<button type="button"
data-action="click->compose--template-picker#saveDraft"
class="inline-flex items-center gap-1.5 text-xs text-ink-muted hover:text-ink transition-colors cursor-pointer">
<i class="fa-regular fa-floppy-disk" aria-hidden="true"></i>
{{ 'compose.template.save_draft'|trans }}
</button>
{# _top: this fragment lives inside the compose frame, and a link
followed in there would try to load the settings page into the
dock. #}
<a href="{{ path('app_settings_index', { section: 'templates' }) }}"
data-turbo-frame="_top"
class="text-xs text-accent hover:underline">{{ 'compose.template.manage'|trans }}</a>
</div>
</div>
+46
View File
@@ -71,6 +71,7 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
confirmNoSubject: 'compose.confirm.no_subject'|trans,
confirmNoBody: 'compose.confirm.no_body'|trans,
confirmSendAnyway: 'compose.confirm.send_anyway'|trans,
confirmOpenVariables: 'compose.confirm.open_variables'|trans,
tooLarge: 'compose.attach_too_large'|trans({ '%name%': '%s', '%limit%': '%d' }),
imageFailed: 'compose.image_insert_failed'|trans,
noSignature: 'compose.signature_none'|trans,
@@ -994,6 +995,51 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
aria-label="{{ 'compose.signature'|trans }}"
class="{{ ic }}"><i class="fa-solid fa-pen-nib" aria-hidden="true"></i></button>
{# ── Templates ─────────────────────────────────────
Beside the signature button because it is the same kind
of thing: writing that already exists, put into this
message. Same dropdown shape as its neighbours — a `menu`
target that starts hidden and opens upwards, anchored to
the toolbar block rather than to a button whose x
depends on where the row wrapped.
THE PANEL IS FETCHED WHEN IT OPENS, not rendered here.
This window is drawn for every reply and every draft,
and the list of templates would be two queries and a
tree on each of them for a button most messages never
press. It also has to be current: a template saved in
Settings in another tab should be in the list the next
time it opens, and the list is grouped by whichever
address is in From at that moment.
Not `richOnly`: a template goes into a plain-text body
too, as text — see compose--compose#insertTemplate.
No variable of its own, for the reason the assistant's
button below documents: this template is included `only`
from the two undo streams. #}
<div data-controller="ui--dropdown compose--template-picker"
data-action="ui--dropdown:opened->compose--template-picker#load"
data-compose--template-picker-panel-url-value="{{ path('app_compose_templates_panel') }}"
data-compose--template-picker-save-url-value="{{ path('app_compose_templates_save_draft') }}"
data-compose--template-picker-i18n-value="{{ {
loading: 'compose.template.loading'|trans,
failed: 'compose.template.failed'|trans,
nothingToSave: 'compose.template.nothing_to_save'|trans,
}|json_encode|e('html_attr') }}">
<button type="button"
data-action="click->ui--dropdown#toggle"
title="{{ 'compose.template.insert'|trans }}"
aria-label="{{ 'compose.template.insert'|trans }}"
class="{{ ic }}"><i class="fa-regular fa-file-lines" aria-hidden="true"></i></button>
<div data-ui--dropdown-target="menu" hidden
data-enter="rise"
data-compose--template-picker-target="panel"
class="absolute bottom-full mb-2 left-2 right-2
md:left-auto md:right-3 md:w-[30rem] z-30 popover overflow-hidden"></div>
</div>
{# ── More options ──────────────────────────────────
Same dropdown shape as the schedule menu above: a `menu`
target that starts hidden and `bottom-full` so it opens