mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
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:
1 parent
4656f8874e
commit
09600802db
52 files changed
+6410
-20
No files matched your search
@@ -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>
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user