mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
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>
497 lines
27 KiB
Twig
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 }} <<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>
|
|
{{- 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 <alice@…>`. #}
|
|
{% 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>
|