Files
pl_mail/templates/mail/_message_details.html.twig
T
Claude d42012cedf Report the category the tabs actually sort by, and the evidence behind it
The first real miscategorisation report read `filed:updates should:updates`
about a conversation its owner was reading in Primary. Both halves of that were
wrong in the same way: `filed` was `message.category`, and the mailbox lists
threads and filters the tabs on `thread.category`. A thread takes one category
from its newest message and a pin overrides it outright, so on any conversation
with more than one message the two are free to differ — and it is the thread
that decides which tab somebody is looking at.

So the report described a column nobody can see, and the popover's "category"
line did too. Both now read the thread, falling back to the message for one not
yet threaded, and both say so when the two disagree: the panel explains that
the conversation takes its category from its newest message, which is the
difference between a panel that looks broken and one that is informative.

The report row was also too thin to act on. It carried the four verdicts and
none of the inputs they came from, so a line could be entirely true and still
leave nobody able to say what to change. Added: the message's own category when
its thread disagrees, whether a pin is holding it, whether the reader had asked
for the provider to be overruled, whether the model was asked at all
(`model:-` could not tell "never asked" from "asked, no answer"), whether there
was any plain text for it to read, and List-Id's value — the one bulk header
whose value is a rule-writing input rather than a tracking link.

The card moves from Admin → AI into Reported mail, where somebody who has just
pressed Report goes looking for it, and is grouped by `filed → should` rather
than listed by date: twenty reports are rarely twenty problems, and the fix for
one group is not the fix for another. Each group and each row can be selected,
and the selection can be copied or downloaded as a text file — so whoever is
changing one rule can take the reports about that rule and leave the rest.

A report that agrees with where the mail already is now 409s rather than
landing as a row saying nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:42:46 +02:00

461 lines
24 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) %}
<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">
<span class="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>
</span>
{% endmacro %}
{% macro addresses(list) %}
{%- import _self as m -%}
{%- for entry in list -%}
{{ m.address(entry) }}{{ 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 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: ' ~ 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"
>
<dl class="grid grid-cols-[5.5rem_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>
{% if replyTo %}
<dt class="text-ink-faint text-right">{{ 'message.details.reply_to'|trans }}</dt>
<dd class="text-ink-soft break-words">{{ 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. #}
{{ ('tabs.' ~ (category.filed ?? category.effective.category))|trans }}
<span class="text-ink-faint">
{%- if category.pinnedAt %}
— {{ 'message.details.categorised.pinned'|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 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. #}
{% if category %}
<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>