{# Renders one row in the message list. Accepts either a MessageThread (`thread`) or a Message (`message`); everything below normalises onto the same set of variables so the markup is shared. Draft handling: a row opens the compose dock instead of the reading pane only when the row IS the draft — i.e. a thread holding a single draft message, or a bare Message row flagged as a draft. A real conversation carrying an unsent reply still opens the thread; that draft is edited from inside the reading pane. `draft_scope` (the Drafts list) overrides that: every row there is listed because of its draft, so it opens that draft even when the thread also holds the conversation it answers. #} {% set draft_scope = draft_scope|default(false) %} {# The Sent list, where the who column has the same problem the Drafts list had: every row is from you, so naming the sender fills the column with your own name and says nothing. See the `sent_scope` block further down. #} {% set sent_scope = sent_scope|default(false) %} {# The Snoozed list. Its rows answer "when does this come back", which is the only question that list is asked. #} {% set snooze_scope = snooze_scope|default(false) %} {# Search results, and only the ones the ordinary search would not have found: the row is here because a vector said it is about what was asked, not because anything in it matches the words. Every other list passes nothing and gets no badge — see the `meaning_match` block beside the New badge. #} {% set meaning_match = meaning_match|default(false) %} {% set item = thread ?? message %} {% set isThread = item.messages is defined %} {# Which account a row belongs to. The unified lists merge every account, and the same message can genuinely be in three of them at once — the same Message-ID delivered to three addresses puts three rows in unified Trash, identical in sender, subject and minute, with nothing on screen to say why there are three. Not a dedup bug; the rows are real. They were just indistinguishable. Shown only where it answers something: a list spanning accounts, on an install that has more than one. A single-account install would get a dot that is the same colour on every row forever, and an account-scoped list already says which account it is at the top. `unified` defaults to true because most lists are — the account view passes false. #} {# Where this conversation sits, for the hover actions below. Read from the mail's own labels rather than from the route, so a row reached from a search result or a live update answers the same as one in the folder listing. #} {% set placement = mail_placement(item) %} {# `?? true`, not `|default(true)`: the default filter fires on false as well as on absent, so the per-account list's `unified: false` was ignored and its rows carried the account disambiguation only the merged lists need. #} {% set unified = unified ?? true %} {% set rowAccount = item.account ?? null %} {% set showAccountDot = unified and rowAccount is not null and accounts|length > 1 %} {# Is this conversation NEW — never yet put in front of the user? Two sources, and the fallback is not defensive padding. A list render passes `newThreadIds`: the set was read BEFORE the same request retired it, so the row can still say "new" in the very frame that stops it being so — see MailController::renderList(). Anything re-rendering one row on its own (the turbo-streams under thread/status/, which redraw a row after a star or a mark-read) has no such set and asks the thread directly, which by then answers "already listed" — the badge goes when the row it belongs to is redrawn, rather than being resurrected by whichever redraw happened last. Threads only. A bare Message row is a message, and only a conversation arrives. #} {% set isNew = isThread and (newThreadIds is defined ? item.id in newThreadIds : newMail.isNew(item)) %} {# An escalation of isNew, never a replacement: an answer IS new, and every count, dot and retirement path goes on treating it as exactly that. All this decides is which of two badges the row wears. See MessageThread::isAnswerAt(). #} {% set isAnswer = isNew and isThread and newMail.isAnswer(item) %} {% if isThread %} {% set type = 'thread' %} {% set unread = item.unreadCount > 0 %} {% set subject = item.subject ?: ('thread_row.no_subject'|trans) %} {% set starred = item.starredAt is not null %} {% set date = item.lastMessageAt %} {% set count = item.messageCount %} {% set latest = item.messages|last %} {# Everyone who has written in the conversation, oldest first — not the newest sender, which made every thread you had answered look like it came from you. #} {% set sender = thread_participants(item)|join(', ') %} {# No |striptags: bodyText is ALREADY plain text — DraftPersister::plainTextBody() stripped the markup and then decoded the entities, and ingest does the same. Running it again does not remove markup, it removes the user's WRITING: a message whose text literally says `bold` was shown as "bold", and one containing `` had the whole thing silently deleted from the preview, so the list misreported what the message said. Twig's autoescaping is what makes this safe, and it is also what makes it correct — escaped, the angle brackets are shown; stripped, they were obeyed. #} {# mail_snippet, not bodyText. The text part is whatever the sender put in the half of the mail most recipients never see — for bulk senders that is routinely a sentence saying the mail is only available in HTML, and for some it is HTML itself, which arrived in the list as visible `

`. The preview is made from the rendered body instead, which is what the reader would actually see. See App\Service\Mail\MessageSnippet. #} {% set snippet = mail_snippet(latest ?: null)|slice(0, 100) %} {# `latest` comes from |last, which yields FALSE (not null) on an empty collection — so `is not null` passes and .isDraft blows up on a bool. A thread can legitimately end up with no messages (every message expunged provider-side), and this row is rendered for the whole inbox, so one such thread would 500 the entire list. #} {# Not `latest`: the collection is not ordered by date, so the draft is looked up by flag rather than assumed to be the last message. #} {% set draftMessage = item.messages|filter(m => m.isDraft)|last %} {% set draft = draftMessage and (draft_scope or item.messageCount == 1) %} {% set draftId = draft ? draftMessage.id : null %} {# In Drafts the who column asked the wrong question. Every draft is from you, so the participant list answered "ich" on every row and the column carried no information at all — the one thing that distinguishes one unsent message from another is who it is FOR. #} {# `?? []` is not defensive padding: a draft is saved the moment the body clears its minimum, which is routinely BEFORE a recipient has been typed, and toAddresses is null until one is. Without it every Drafts list holding one such draft is a 500 — which is exactly what the compose e2e spec found. #} {% if draft_scope and draftMessage %} {% set recipients = (draftMessage.toAddresses ?? []) |map(r => r.name ?: r.address) |filter(r => r is not empty) %} {% set sender = recipients|length > 0 ? ('thread_row.to'|trans({ '%recipients%': recipients|join(', ') })) : ('thread_row.no_recipient'|trans) %} {% endif %} {# Sent, for the same reason and with the same answer. The column read "Sven" on every row — or "ich" on some and "Sven" on others, depending on whether the participant list had learned the name — and in a folder of mail you sent, the one fact nobody needs is who sent it. The recipient is what tells one row from another, which the Drafts list has always got right. Taken from the newest message rather than from the thread's participants: a conversation you have replied into contains everybody, and the question here is who THIS went to. #} {% if sent_scope and latest %} {% set recipients = (latest.toAddresses ?? []) |map(r => r.name ?: r.address) |filter(r => r is not empty) %} {% set sender = recipients|length > 0 ? ('thread_row.to'|trans({ '%recipients%': recipients|join(', ') })) : ('thread_row.no_recipient'|trans) %} {% endif %} {# A draft on a hold. sentAt guards it for the reason the compose window guards it: once the mail has gone, submissionSendAt is the record of when it was due, not a schedule. #} {% set scheduledAt = (draftMessage and draftMessage.sentAt is null) ? draftMessage.submissionSendAt : null %} {# Which message the hold is on. Not draftId: that one is null unless the ROW opens the draft (a conversation carrying an unsent reply opens the thread), and the hold belongs to the draft either way. #} {% set scheduledId = scheduledAt is not null ? draftMessage.id : null %} {% set hasAttachment = item.attachmentCount > 0 %} {% set senderSeed = latest ? (latest.fromAddress ?: sender) : sender %} {% set href = path('app_mail_thread', { id: item.id }) %} {% set rowId = item.id %} {% else %} {% set type = 'message' %} {% set unread = item.seenAt is null %} {% set subject = item.subject ?: ('thread_row.no_subject'|trans) %} {% set starred = item.starredAt is not null %} {% set date = item.receivedAt %} {% set count = 1 %} {% set sender = item.fromName ?: item.fromAddress %} {% set senderSeed = item.fromAddress ?: sender %} {# Same source as the thread branch above, so the two cannot drift. #} {% set snippet = mail_snippet(item)|slice(0, 100) %} {% set hasAttachment = item.hasAttachments %} {% set draft = item.isDraft %} {% set draftId = item.id %} {% set scheduledAt = (item.isDraft and item.sentAt is null) ? item.submissionSendAt : null %} {% set scheduledId = scheduledAt is not null ? item.id : null %} {% set href = path('app_mail_message', { id: item.id }) %} {% set rowId = item.id %} {% endif %} {# Search results, and the reason a row can show WHY it matched. `highlight` is `{ subject, snippet }`, either half of which may be null, built by App\Service\Search\SearchResultHighlights from Postgres `ts_headline` over the message the search actually hit. Only the search list passes it; every other list — inbox, sent, drafts, trash, archive, spam, starred, snoozed, labels, accounts, and the per-row turbo-streams under thread/status/ — passes nothing, gets null here, and renders exactly as it did before this existed. The values are `Twig\Markup`: already escaped, with `` put back in afterwards by the one class that did the escaping. That is what lets this print them without `|raw`, which this file still does not contain. A sender's own markup inside a fragment therefore arrives as visible, escaped text, which is the same treatment the plain preview gives it — see the striptags note above. `subjectLine` and not `subject` itself, and that distinction is load-bearing: `subject` is also spent on `aria-label` and on the select checkbox's label, and a screen reader announcing "Oak for the <mark>alcove</mark>" would be a worse regression than the one this feature fixes. #} {% set highlight = highlight|default(null) %} {% set subjectLine = highlight.subject|default(subject) %} {# No |slice on this one, unlike the plain preview above: the fragment is HTML now, and cutting it at a hundred characters can land inside a tag. Its length is bounded by MaxWords in SearchHighlighter::HEADLINE_OPTIONS instead, and the row goes on truncating with CSS the way it always has. #} {% set snippet = highlight.snippet|default(snippet) %} {# `data-enter-NEW`, not `data-enter`, and the difference is the whole reason this can exist at all. A row is not automatically an arrival. This template is re-rendered for mail that has been on screen for an hour every time somebody stars it, archives it or snoozes it — the turbo-streams under thread/status/ hand the page a brand new node carrying the same mail. An entrance keyed on the NODE would fire every time, and a list that shimmers at rest is unreadable. The list's own sync refresh used to be the worst of these and no longer is: mail--mail-pane#_morphRows morphs the rows region rather than assigning over it, so a row that survives a sync keeps its node. What remains is everything that legitimately rebuilds one row, which is what the id rule below is for. What tells the two apart is the id: `thread_1234` is the same conversation whoever rendered it. motion.js animates an element the first time it sees a given id and never again, so this plays for mail that has actually just arrived and stays silent through every redraw of mail that has not. It has to be a separate attribute because a CSS animation starts the instant an element carrying one is inserted — before any observer can object. A row that might not deserve an entrance must therefore arrive with no animation attached, and be given one only once its id has proved to be new. That is what motion.js does with this attribute, and it is why the stylesheet knows nothing about it. Arriving as part of a whole new list — a folder change, a search, the next page — is silent too: those rows are new ids, but they come inside the