mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
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>
461 lines
24 KiB
Twig
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 }} <<span data-ui--clipboard-target="source">{{ entry.address }}</span>>
|
|
{%- 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 <alice@…>`. #}
|
|
{% 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>
|