Files
pl_mail/templates/mail/_message_details.html.twig
T
Paul LütznerandClaude Opus 5.5 3c0769c1e6 Order every inbox tab, and let a built-in one be switched off
One order across the built-in tabs and the reader's own, and a set of
built-ins switched off, both beside the other sorting choices on the
user (CategorySorting). The order replaces the categories' own position
column, which never shipped: the strip is one list, so its order is too.
Primary is always first and cannot be switched off — it is where the
rest falls back to.

A switched-off category takes no mail. Whatever would have gone there —
by the header rules, Google's label or the assistant — is in Primary,
and the message panel says it would have been that category. The
answer is mapped rather than the next rule consulted: "Forums is off"
does not make a mailing list Promotions for its List-Unsubscribe.

Until the re-sort a switch queues has run, and for a conversation
somebody pinned there, Primary lists and counts the switched-off
category's conversations too, so nothing is in no tab at all.

InboxTabs is where order and on/off are applied, so the strip, the drop
targets and every key that resolves agree. A restore rewrites the
order's own-tab keys to the categories' new ids.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:34:45 +02:00

497 lines
27 KiB
Twig

{#
mail/_message_details.html.twig
─────────────────────────────────────────────────────────────────────────────
Gmail-style "to Alice, Bob +2 ▾" trigger plus the details popover for a single
message.
Variables:
message Message
The recipient lists come from message_addresses(), not straight off the entity:
it falls back to the row's own stored headers when the column is empty, which
is what every message synced over IMAP before MessageSyncer::addressesOf() was
fixed looks like. See App\Twig\MessageRecipientsExtension.
Everything else is derived from the stored `message.headers` map, since there
is no raw RFC822 blob and no replyTo column on the entity. Header names are
canonicalised to the lowercase hyphen spelling on the way in, because the
paths disagree about how they store them: Gmail and Graph keep the name as it
arrived (`Reply-To`), webklex lowercases it and turns the hyphen into an
underscore (`reply_to`). Looking up only the hyphen form meant reply-to,
mailed-by and signed-by were silently missing from every IMAP message.
#}
{% import _self as details %}
{# One address, with a copy button for the addr-spec itself. The clipboard
source is the address text as it is rendered, so the insecure-context
fallback (which selects the source) highlights exactly what it copied. #}
{% macro address(entry, separator = '') %}
<span data-controller="ui--clipboard"
data-ui--clipboard-confirm-text-value="{{ 'clipboard.copied'|trans }}"
data-ui--clipboard-failed-text-value="{{ 'clipboard.copy_failed'|trans }}"
class="inline-flex items-baseline gap-1 max-w-full">
{# min-w-0 for the same reason as the grid column below: a flex item
will not go narrower than its longest word unless told to. #}
<span class="min-w-0 break-words">
{%- if entry.name -%}
{{ entry.name }} &lt;<span data-ui--clipboard-target="source">{{ entry.address }}</span>&gt;
{%- else -%}
<span data-ui--clipboard-target="source">{{ entry.address }}</span>
{%- endif -%}
</span>
<button
type="button"
data-action="click->ui--clipboard#copy"
aria-label="{{ 'message.details.copy_address'|trans({ '%address%': entry.address }) }}"
class="shrink-0 inline-flex items-center justify-center w-5 h-5 rounded
text-ink-faint hover:text-ink hover:bg-hover transition-colors cursor-pointer"
>
<i data-ui--clipboard-target="icon" class="fa-solid fa-copy text-[10px]" aria-hidden="true"></i>
{# Announced, not shown: the check mark is the sighted confirmation
and this is the same news for a screen reader. #}
<span data-ui--clipboard-target="label" class="sr-only" role="status"></span>
</button>
{{- separator -}}
</span>
{% endmacro %}
{# The comma goes inside the address it follows and the space outside, so a
line can break between two addresses but never leave the comma alone at
the start of one, which it did on a phone. #}
{% macro addresses(list) %}
{%- import _self as m -%}
{%- for entry in list -%}
{{ m.address(entry, not loop.last ? ',' : '') }}{{ not loop.last ? ' ' : '' }}
{%- endfor -%}
{% endmacro %}
{% set rawHeaders = message.headers ?? {} %}
{% set h = {} %}
{% for key, value in rawHeaders %}
{% set canonical = key|lower|replace({ '_': '-' }) %}
{% if h[canonical] is not defined %}
{% set h = h|merge({ (canonical): (value is iterable ? value|join("\n") : value) }) %}
{% endif %}
{% endfor %}
{% set toList = message_addresses(message, 'to') %}
{% set ccList = message_addresses(message, 'cc') %}
{% set bccList = message_addresses(message, 'bcc') %}
{% set replyToList = message_addresses(message, 'reply-to') %}
{% set summary = message_recipient_summary(message) %}
{% set replyTo = h['reply-to']|default(null) %}
{% set returnPath = h['return-path']|default('') %}
{% set mailedBy = '@' in returnPath ? returnPath|split('@')|last|trim('<> ') : null %}
{% set dkimParts = (h['dkim-signature']|default(''))|split(';')|map(p => p|trim)|filter(p => p starts with 'd=') %}
{% set signedBy = dkimParts is not empty ? (dkimParts|first)|slice(2) : null %}
{# The at-a-glance line: "to me", "to Alice, Bob +2", or — when the message
genuinely names nobody — "to undisclosed recipients". A message with no
To: header is an ordinary thing (a bcc delivery, a mailing list that hides
its members); a header row that just leaves the line out is not, because
an absent line reads as something broken rather than as a fact. #}
{% set summaryLabel = summary.empty
? 'message.details.undisclosed'|trans
: summary.names|join(', ') %}
{% set fromText = message.fromName
? message.fromName ~ ' <' ~ message.fromAddress ~ '>'
: message.fromAddress %}
{% set dateAt = message.receivedAt ?? message.sentAt %}
{% set messageIdText = message.messageId|default('') %}
{# The technical headers as plain text, in the order the panel shows them.
Only the lines the panel actually displays — a copy that carried more than
the reader can see is a different document from the one they asked for.
Assembled with map/join rather than through the address macro on purpose:
macro output is already-escaped markup, and concatenating it into a string
that is then printed would escape it a second time, so the clipboard would
receive `Alice &lt;alice@…&gt;`. #}
{% set addressText = a => a.name ? a.name ~ ' <' ~ a.address ~ '>' : a.address %}
{% set headerLines = ['From: ' ~ fromText] %}
{% if replyTo %}
{% set headerLines = headerLines|merge(['Reply-To: ' ~ (replyToList is not empty
? replyToList|map(addressText)|join(', ')
: replyTo)]) %}
{% endif %}
{% set headerLines = headerLines|merge([
'To: ' ~ (toList is not empty
? toList|map(addressText)|join(', ')
: 'message.details.undisclosed'|trans),
]) %}
{% if ccList is not empty %}
{% set headerLines = headerLines|merge(['Cc: ' ~ ccList|map(addressText)|join(', ')]) %}
{% endif %}
{% if bccList is not empty %}
{% set headerLines = headerLines|merge(['Bcc: ' ~ bccList|map(addressText)|join(', ')]) %}
{% endif %}
{% if dateAt %}
{% set headerLines = headerLines|merge(['Date: ' ~ dateAt|date('r')]) %}
{% endif %}
{% set headerLines = headerLines|merge([
'Subject: ' ~ (message.subject ?: ('thread.no_subject'|trans)),
]) %}
{% if messageIdText %}
{% set headerLines = headerLines|merge(['Message-ID: ' ~ messageIdText]) %}
{% endif %}
<div class="relative inline-flex" data-controller="mail--message-details">
<button
type="button"
data-action="click->mail--message-details#toggle"
aria-label="{{ 'message.details.label'|trans }}"
class="inline-flex items-center gap-1 rounded px-1 -mx-1 py-0.5
text-xs text-ink-faint hover:text-ink-soft hover:bg-hover
transition-colors cursor-pointer"
>
<span class="truncate max-w-[22rem]">
{{- 'message.details.to'|trans({ '%name%': summaryLabel }) -}}
{{- summary.extra > 0 ? ' +' ~ summary.extra : '' -}}
</span>
<i data-mail--message-details-target="caret"
class="fa-solid fa-caret-down text-[10px] transition-transform duration-150"
aria-hidden="true"></i>
</button>
<div
data-mail--message-details-target="panel"
data-action="click->mail--message-details#stop"
class="hidden absolute left-0 top-full mt-1 z-30 w-[28rem] max-w-[85vw]
rounded-pane bg-pane-soft backdrop-blur-sm
border border-line shadow-pop
text-xs cursor-default"
>
{# minmax(0,1fr), not 1fr. A bare 1fr never goes narrower than its
longest unbreakable word, so a GitHub reply address widened the whole
column past the panel, every row with it, and break-words below
never got the chance to wrap anything. #}
<dl class="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-x-3 gap-y-1.5 px-4 py-3">
<dt class="text-ink-faint text-right">{{ 'message.details.from'|trans }}</dt>
<dd class="text-ink-soft break-words">
{{ details.address({ name: message.fromName, address: message.fromAddress }) }}
</dd>
{# Parsed like the rows around it, so it reads the same and copies
the same. The raw header is the fallback for one that names no
address this can parse, which is better shown than hidden. #}
{% if replyTo %}
<dt class="text-ink-faint text-right">{{ 'message.details.reply_to'|trans }}</dt>
<dd class="text-ink-soft break-words">
{{- replyToList is not empty ? details.addresses(replyToList) : replyTo -}}
</dd>
{% endif %}
{# Always rendered, empty or not — see the summary note above. #}
<dt class="text-ink-faint text-right">{{ 'message.details.to_label'|trans }}</dt>
<dd class="text-ink-soft break-words">
{% if toList is not empty %}
{{ details.addresses(toList) }}
{% else %}
<span class="text-ink-faint italic">{{ 'message.details.undisclosed'|trans }}</span>
{% endif %}
</dd>
{% if ccList is not empty %}
<dt class="text-ink-faint text-right">{{ 'message.details.cc'|trans }}</dt>
<dd class="text-ink-soft break-words">{{ details.addresses(ccList) }}</dd>
{% endif %}
{% if bccList is not empty %}
<dt class="text-ink-faint text-right">{{ 'message.details.bcc'|trans }}</dt>
<dd class="text-ink-soft break-words">{{ details.addresses(bccList) }}</dd>
{% endif %}
<dt class="text-ink-faint text-right">{{ 'message.details.date'|trans }}</dt>
<dd class="text-ink-soft">
{{ dateAt ? dateAt|intl_date('EEEdMMMy') ~ ', ' ~ dateAt|date(clock.time) : '—' }}
</dd>
<dt class="text-ink-faint text-right">{{ 'message.details.subject'|trans }}</dt>
<dd class="text-ink-soft break-words">{{ message.subject ?: ('thread.no_subject'|trans) }}</dd>
{% if mailedBy %}
<dt class="text-ink-faint text-right">{{ 'message.details.mailed_by'|trans }}</dt>
<dd class="text-ink-soft break-words">{{ mailedBy }}</dd>
{% endif %}
{% if signedBy %}
<dt class="text-ink-faint text-right">{{ 'message.details.signed_by'|trans }}</dt>
<dd class="text-ink-soft break-words">{{ signedBy }}</dd>
{% endif %}
{#
EVERY ANSWER THERE IS ABOUT WHICH TAB THIS BELONGS IN.
It used to be one row, plus a second one only on Gmail mail. That
was enough to say where the message went and not enough to judge
whether it went to the right place — which is what somebody opening
this panel is actually asking. So all of it is here, always, and the
rows disagreeing with each other is the useful part rather than a
fault: a header rule and a language model reaching different
conclusions about the same mail is exactly the thing worth seeing.
Recomputed from the rules as they stand now rather than read off the
row — see MessageCategoryExtension, which also explains why this is
one call and not four.
#}
{% set category = category_report(message) %}
{% if category %}
{#
Where it is. Normally that is whatever decided it; once somebody
has dragged the conversation onto a tab it is their choice and
nothing below can move it, so the row says so out loud. The
other rows stay — they are the reasoning that was overridden,
which is the whole reason to look.
#}
<dt class="text-ink-faint text-right">{{ 'message.details.category'|trans }}</dt>
<dd class="text-ink-soft break-words">
{# WHERE IT ACTUALLY IS, off the THREAD's row — the one the
tabs are a filter over. The three lines below are
recomputations, "what would each of them say"; this one
answers a different question entirely, and reading it off
a row is the only way the panel can be honest when they
disagree, which they do whenever a re-file is queued.
It read the MESSAGE's row until it put a report's name to
the problem: a conversation sitting in Primary, described
here as Updates, because a sibling message carried the
thread and this panel was reading a column no tab shows. #}
{# A tab the reader made wins the row: it is where the
conversation is shown, while `filed` is only the built-in
category underneath it. Both are true; this is the one
the reader is looking at. #}
{% if category.own %}{{ category.own }}{% else %}{{ ('tabs.' ~ (category.filed ?? category.effective.category))|trans }}{% endif %}
<span class="text-ink-faint">
{%- if category.pinnedAt %}
— {{ 'message.details.categorised.pinned'|trans }}
{%- elseif category.own %}
— {{ 'message.details.categorised.own_filter'|trans }}
{%- elseif category.effective.reason == 'switched_off' %}
{#- The signal is the category it would have been, named
the way its tab was. #}
— {{ 'message.details.categorised.switched_off'|trans({'%category%': ('tabs.' ~ category.effective.signal)|trans}) }}
{%- else %}
— {{ ('message.details.categorised.' ~ category.effective.reason)|trans }}
{%- if category.effective.signal %}
<span class="font-mono">{{ category.effective.signal }}</span>
{%- endif -%}
{%- endif -%}
</span>
{# The row and a fresh answer disagreeing is not a fault, it
is a re-file that has not run yet — the sorting setting
queues one and a large mailbox takes minutes. Saying so
is the difference between "give it a moment" and a bug
report. Never shown for a pinned thread, where the two
are SUPPOSED to differ. #}
{% if not category.pinnedAt and not category.own and category.filed and category.filed != category.effective.category %}
<span class="block text-ink-faint">{{ 'message.details.categorised.awaiting_resort'|trans }}</span>
{% endif %}
{# THE CONVERSATION IS NOT WHERE THIS MESSAGE WOULD PUT IT.
A thread takes one category for all of it, most recent
wins, so a reply can carry the whole conversation out of
the tab an earlier message belongs in. Without this the
panel reads as simply wrong: every line agrees on Updates
and the mail is plainly sitting in Primary. #}
{% if category.filedMessage %}
<span class="block text-ink-faint">
{{ 'message.details.categorised.thread_differs'|trans({
'%category%': ('tabs.' ~ category.filedMessage)|trans,
}) }}
</span>
{% endif %}
</dd>
{#
Gmail's own answer, on its own line.
It used to appear only as a footnote on the line above, and only
while it was the thing that had decided — so a reader who asked
plMail to overrule Google lost the one number they most wanted to
compare against. Null on any other account, where the row is
omitted rather than filled with an answer Gmail never gave.
#}
{% if category.gmail %}
<dt class="text-ink-faint text-right">{{ 'message.details.category_gmail'|trans }}</dt>
<dd class="text-ink-soft break-words">
{{ ('tabs.' ~ category.gmail)|trans }}
{% if category.source == 'rules' or category.effective.reason != 'gmail' %}
<span class="text-ink-faint">— {{ 'message.details.categorised.gmail_overruled'|trans }}</span>
{% endif %}
</dd>
{% endif %}
{#
plMail's own cascade, with neither Gmail's labels nor the model
in the way. On a Gmail mailbox this never runs — the provider's
CATEGORY_* labels are authoritative while they arrive — and it
takes over the moment they stop, having never been checked
against real mail. On any other mailbox it is what actually
decided, and repeating it costs a line and removes the need to
work out from the row above whether it did.
#}
<dt class="text-ink-faint text-right">{{ 'message.details.category_rules'|trans }}</dt>
<dd class="text-ink-soft break-words">
{{ ('tabs.' ~ category.rules.category)|trans }}
<span class="text-ink-faint">
— {{ ('message.details.categorised.' ~ category.rules.reason)|trans }}
{%- if category.rules.signal %}
<span class="font-mono">{{ category.rules.signal }}</span>
{%- endif %}
{%- if category.source == 'rules' %}
· {{ 'message.details.categorised.in_force'|trans }}
{%- endif -%}
</span>
</dd>
{#
And the model, asked about every message rather than only the
ones no rule recognised — the point being to have all three
answers on the same mail.
Whether it DECIDES is now the reader's own setting. It used to
be read only as a tie-break, where a message any rule matched
kept the rule's answer however loudly this row disagreed; that
is still what happens under "Rules", and under "The assistant"
this row is the one that wins. Which is in force is marked
rather than left to be inferred from three categories and a
memory of a settings page.
Three states, and they are genuinely different: never asked
(the feature is off, or this arrived before it was on), asked
and got nothing usable back, and an answer.
#}
<dt class="text-ink-faint text-right">{{ 'message.details.category_ai'|trans }}</dt>
<dd class="text-ink-soft break-words">
{% if category.ai.category %}
{{ ('tabs.' ~ category.ai.category)|trans }}
<span class="text-ink-faint">
{%- if category.source == 'assistant' %}
— {{ 'message.details.categorised.in_force'|trans }}
{%- elseif category.ai.category != category.rules.category %}
— {{ 'message.details.categorised.ai_differs'|trans }}
{%- endif -%}
</span>
{% elseif category.ai.asked %}
<span class="text-ink-faint">{{ 'message.details.categorised.ai_no_answer'|trans }}</span>
{% else %}
<span class="text-ink-faint">{{ 'message.details.categorised.ai_not_asked'|trans }}</span>
{% endif %}
</dd>
{% endif %}
</dl>
{# "This is in the wrong tab."
Only where there is a category to be wrong about, which is the whole
of the condition: category_report() answers null for a principal it
does not recognise, and the rows above are absent then too.
A SELECT AND A BUTTON rather than five buttons. Knowing a message was
misfiled is a fraction as useful as knowing where it belonged —
"promotions, not primary" is a rule somebody can act on, "wrong" is a
shrug — and the closed set is five, which is a select.
It posts and stays put: this is pressed inside a popover on a thread
the reader is in the middle of, and navigating them away to
acknowledge a button would cost more than the button is worth.
NOT FOR A CONVERSATION IN A TAB THE READER MADE. The report is
evidence about plMail's own rules, and that conversation is where it
is because of the reader's own filter or their own drag — nothing of
the rules' to fix. Offered anyway it was also wrong in a way the
reader could not get out of: the built-in category underneath may be
the very answer they want to give, and "should be where it is" is
refused as no report. #}
{% if category and not category.own %}
<div class="border-t border-line px-4 py-2"
data-controller="mail--category-report"
data-mail--category-report-url-value="{{ path('app_mail_category_report', {'id': message.id}) }}"
data-mail--category-report-token-value="{{ csrf_token('category_report') }}"
data-mail--category-report-sent-label-value="{{ 'message.details.report.sent'|trans }}"
data-mail--category-report-failed-label-value="{{ 'message.details.report.failed'|trans }}"
data-mail--category-report-stale-label-value="{{ 'message.details.report.stale'|trans }}">
<div class="flex items-center gap-2">
<span class="text-ink-faint shrink-0">{{ 'message.details.report.label'|trans }}</span>
<select data-mail--category-report-target="category"
class="min-w-0 flex-1 h-7 rounded-pane border border-line bg-surface px-1.5
text-ink-soft cursor-pointer">
{% for option in ['primary', 'social', 'promotions', 'updates', 'forums'] %}
{# The one it is already in is not offered: "it
should be where it is" is not a report. #}
{% if option != (category.filed ?? category.effective.category) %}
<option value="{{ option }}">{{ ('tabs.' ~ option)|trans }}</option>
{% endif %}
{% endfor %}
</select>
<button type="button"
data-action="click->mail--category-report#send"
data-mail--category-report-target="button"
class="shrink-0 inline-flex items-center gap-1.5 h-7 px-2 rounded-pane
border border-line text-ink-faint hover:text-ink-soft hover:bg-hover
cursor-pointer transition-colors">
<i data-mail--category-report-target="icon" class="fa-solid fa-flag w-3" aria-hidden="true"></i>
<span data-mail--category-report-target="label">{{ 'message.details.report.send'|trans }}</span>
</button>
</div>
</div>
{% endif %}
{# Copy the whole block. The source is hidden rather than assembled in
JavaScript: the text owed to the clipboard is the panel's own
rendering, translations and all, and Twig is where that already
exists. #}
<div class="border-t border-line"
data-controller="ui--clipboard"
data-ui--clipboard-confirm-text-value="{{ 'clipboard.copied'|trans }}"
data-ui--clipboard-failed-text-value="{{ 'clipboard.copy_failed'|trans }}">
<pre data-ui--clipboard-target="source" class="hidden">{{ headerLines|join("\n") }}</pre>
<button
type="button"
data-action="click->ui--clipboard#copy"
class="w-full flex items-center gap-2 px-4 py-2 text-left
text-ink-faint hover:text-ink-soft hover:bg-hover
transition-colors cursor-pointer"
>
<i data-ui--clipboard-target="icon" class="fa-solid fa-copy w-3.5" aria-hidden="true"></i>
<span data-ui--clipboard-target="label">{{ 'message.details.copy_headers'|trans }}</span>
</button>
</div>
{% if rawHeaders is not empty %}
<div class="border-t border-line">
<button
type="button"
data-action="click->mail--message-details#toggleAll"
class="w-full flex items-center gap-2 px-4 py-2 text-left
text-ink-faint hover:text-ink-soft hover:bg-hover
transition-colors cursor-pointer"
>
<i class="fa-solid fa-list w-3.5" aria-hidden="true"></i>
<span
data-mail--message-details-target="allHeadersLabel"
data-more="{{ 'message.details.show_all_headers'|trans }}"
data-less="{{ 'message.details.hide_all_headers'|trans }}"
>{{ 'message.details.show_all_headers'|trans }}</span>
</button>
<div
data-mail--message-details-target="allHeaders"
class="hidden max-h-72 overflow-y-auto border-t border-line px-4 py-3"
>
<dl class="grid grid-cols-[8rem_1fr] gap-x-3 gap-y-1 font-mono text-[11px] leading-relaxed">
{% for key, value in rawHeaders %}
<dt class="text-ink-faint break-words">{{ key }}</dt>
<dd class="text-ink-soft break-all whitespace-pre-wrap">{{ value is iterable ? value|join("\n") : value }}</dd>
{% endfor %}
</dl>
</div>
</div>
{% endif %}
</div>
</div>