Files
13303b247e v0.3.9 milestone (#283)
* Run the end-to-end suite on pull requests into milestone and wip branches

A release's work is now collected on a milestone branch (milestone/v0.4.0)
before it reaches main, and the suite only ran on pull requests into main,
so nothing proposed for the milestone was tested. `milestone/**` joins
`main`, and `wip/**` comes along from wip/28, where it already runs the
suite on the phases of #28 — the same block on both, so they merge
without a conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit d009ff72ad)

* linked-issue: read the description of a pull request into a milestone branch (#254)

GitHub links an issue from "Closes #N" only in a pull request into the
default branch. Into milestone/v0.3.8 or a wip/ branch the closing
references stay empty, so the check failed on every such pull request
however its description named the issue (#252 was the first). For a pull
request into another branch, the description is now read for the same
keywords GitHub accepts.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* The release pull request is the suite run behind a tag (#252)

Releasing asked for the E2E workflow to be started by hand on main before
a tag. Since main became protected, the Release vX.Y.Z pull request cannot
merge until that workflow is green on it, and its commit is what is tagged,
so a manual run only ran the same jobs twice. Releasing now says so, and to
update the branch if main moved after its checks ran, since the protection
does not ask for up-to-date branches.

The paragraph in CONTRIBUTING and the header of e2e.yml that said main is
not verified commit by commit describe main before it was protected; both
now describe it as it is.

Closes #251

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Calendar on a phone: a readable month, a toolbar and top bar that fit, a live now line (#256)

The month chip is its title first, on up to two lines, then its time range
and place; a narrower day drops the end and the place for a third line of
title, long words are hyphenated, and an unanswered invitation has a
dashed bar. Below 39rem, where a day is bars, the grid shrinks and the
chosen day is listed in full under it: today at first, any tapped day
after, with a + for that day that the toolbar's + follows too. On a wider
touch screen the first tap opens a day's card and the second its entry
(calendar--month-tap, and a day-open: variant that the hover and the tap
share).

The calendar toolbar fits below 37rem with icons, a short date label and
smaller buttons, and under 22rem today's date in a box for Today. English
writes a same-month week as "Oct 20-26" (range_short_month). The top bar
tightens below 640px and moves Sync now, What's new and Send feedback into
the user menu. Dialogs are the whole screen below 768px, as compose is.

calendar--now moves the now line every minute on the view's clock and
redraws a view that shows the old day or the new one when the date
changes, which also moves the month's circle on today.

And a + on a day that has gone opened the editor on today:
DefaultEventStart::onDay pulled any past day to the next hour. A past day
now keeps its date.

Closes #253

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Close an issue when its pull request is merged into a milestone branch (#260)

GitHub acts on "Closes #N" only for merges into the default branch, so
issues fixed on a milestone branch stayed open until the release reached
main. A workflow on merged pull requests into milestone/** now closes the
issues the description names, with a comment linking the pull request.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Preview the reply, not the quote, and send replies with their quote visible (#258)

The list preview flattened the whole body and skipped short blocks to find
the first long one, so a one-line reply previewed as its attribution line or
the quoted mail. The quoted history (same seam as the reading view's "···")
and a "-- " signature are cut off first now, in HTML and plain text, and the
chrome skip only applies to bulk mail. The JMAP preview uses the same code.

The composer folded the quote with an inline display: none and its clean-up
removed only the toggle, so replies went out with an invisible quote. The
fold is removed in the editor and again on the server, and the reading view
reveals a folded quote in mail already received.

Closes #257

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Move a thread's unread count when JMAP marks a message read or unread (#267)

EmailPatchApplier wrote seenAt for $seen and left MessageThread::$unreadCount alone, so the web list (which draws bold from that counter) disagreed with the phone app. $seen now goes through ThreadStatusUpdater::markRead(), the web UI's own path, and markRead() counts the whole thread rather than the messages passed in.

Closes #266

Co-authored-by: Philippe Delodder <7259755+phdelodder@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Leave a message that is already in the destination folder where it is (#270)

Trashing a conversation with a message already in the bin queued that message as a move from Trash into Trash. webklex's move() then left the connection in EXAMINE and the server refused it ("NO Not permitted in EXAMINE state"), and the row had already lost its UID. ApplyImapFlagsHandler no longer sends a move into the folder a message is in (and gives the row its UID back), and ThreadStatusUpdater's trash(), archive() and restore() leave such messages out of the move.

Closes #269

Co-authored-by: Philippe Delodder <7259755+phdelodder@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Hide a conversation from the lists only when all of it is in the bin (#272)

A thread's labels are the union of its messages', so one deleted message put Trash on the thread and took the whole conversation out of the Inbox, its tab, label lists, All Mail, Unread, the badges and search, along with any reply threaded into it afterwards. The bin rule is now "carries Trash and has no message outside it", written once (binnedThreads()) for every list and badge and spelled out the same way in the search SQL. Option (b) of the issue.

Closes #268

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Let a long message scroll in the expanded compose window (#273)

expandedValueChanged() used closest('div') to reach the editor's wrapper, but closest() starts at the element itself and the editor is a div, so the wrapper never became a flex container and the editor stayed 170px tall, clipped by [contain:paint]. The wrapper is now the flex column and the editor grows (1 0 auto) to fill the window or its content; the plain-text field fills the expanded window too.

Closes #262

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Write each changelog entry as a file of its own, collected at release (#275)

Every pull request added its paragraph under ## Unreleased in CHANGELOG.md at the same place, so the second of any two conflicted with the first once it merged, and each conflict fix re-ran the whole E2E suite. A pull request now adds changelog.d/<issue>-<words>.md; bin/changelog-release.sh, run in the Release vX.Y.Z pull request, collects the fragments (highlights first) and anything still under ## Unreleased into the release's section and deletes them. CHANGELOG.md stays the one file What's new, bin/release-notes.sh and GitHub read.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Say which step failed when notifications can't be turned on (#278)

Every failure in the enable path (registering the service worker, the
browser's push subscription, the server's answer) ended in the same
"Something went wrong" and the exception was dropped, so a report from a
Pixel 8 could not say which. The pane now names the step and the
DOMException name or HTTP status, and logs the exception to the console.

Closes #277

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Contacts: one address book, with Google, Outlook, Nextcloud and CardDAV contacts in it (#282)

A contacts page and pane for plMail's own contacts and the ones on a server:
Google and Outlook on the mail account's own sign-in, Nextcloud through the
Nextcloud connection, any CardDAV server through Settings -> Contacts. Every
field a phone keeps, the photo included, syncs both ways. Duplicates merge
with 90 days to undo; vCard and CSV import and export; contacts suggested
while composing; JMAP Contact/get, /query and /set. Nextcloud calendars come
with the Nextcloud connection too.

Replaces #237 and #109 by @jo2904 and #244 by @DanversKara.

Closes #157
Closes #236

Co-authored-by: jo2904 <jo2904@users.noreply.github.com>
Co-authored-by: DanversKara <DanversKara@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Install APCu and Xdebug with PIE instead of from PECL (#286)

PECL is deprecated in favour of PIE and was down for most of an evening on 2026-10-21, failing every image build. PIE 1.5.2 now installs APCu (and Xdebug in the dev image) from the extensions' own releases; it is mounted for that one step and not left in the image. Nothing changes at runtime.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Keep contact photos where the web container can serve them (#287)

Contact photos were stored in var/contacts, outside the volumes the web and worker containers share, so synced photos answered 404. They now live under APP_STORAGE_DIR/uploads/contacts; a migration clears the lost paths and has synced books read again so the photos come back.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Match a contact's email addresses in any case (#288)

Addresses are stored lower-cased but were matched as the provider spelled them, so a pull removed and re-inserted the same address and broke the unique key (prod sync failure). They are now matched in their stored form.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Open a contact's reference email in the mail pane (#294)

A click on one of a contact's reference emails in the docked contacts
pane was a Turbo visit inside the contacts frame, which blanked the mail
pane. The click is now offered to the mail pane as a mail-pane:open
event; the pane opens the message beside the contact. Without a mail
pane (the full contacts page) it stays an ordinary visit.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Open mail beside the full Contacts page without a reload (#297)

The full Contacts page was a page of its own, so a reference email
clicked there was a visit: the contact was gone, and on a notebook the
mailbox came back on the calendar it remembered.

The full page is now the mailbox with data-contacts-full on the shell:
the mail column put away and the contacts dock given the row, with the
page's own content in the dock's frame. Opening a message from it loads
the message into the existing mail pane, then contacts--pane#reveal
drops the state in a view transition: the mail slides in from the left
and the dock narrows to its width, the same DOM throughout. On a
notebook the calendar gives way; on a wide screen it stays.

The reference links lose data-turbo-frame="_top", which made ui--split
stash a calendar position for a visit that never came.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Preview image and video attachments on click, like PDFs (#295) (#298)

Clicking an image or video attachment chip opens a viewer in the modal
instead of downloading. Previewable chips (PDF included) get a download
half on the right. Images load from the existing attachment route; videos
from a new /mail/attachment/{id}/video route that serves a fixed video
type inline under the sandbox CSP and answers Range requests.

Closes #295

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Erase a message's parts with it, and delete mail one way (#300)

MessageEraser loaded a message's parts to delete their files, then removed
only the message. After the delete Doctrine forgot the message but kept the
parts managed, so the next flush in the same unit of work failed with "a new
entity was found through the relationship MessagePart#message". Every sync
that removed server-deleted mail with an attachment failed that way.

The eraser now removes the parts, removes a thread it empties (announced to
JMAP as destroyed), and logs file-delete failures instead of keeping the row.
MessagePurger hands its local delete to the eraser, so "delete forever" gains
the thread-label re-sync it lacked. ImapAccountSyncer clears an entity manager
a failed folder left open, so one folder's refused flush no longer fails the
rest.

Closes #299

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Rework the contact editor: quick add, paste details and Improve with AI (#305) (#306)

New contacts start in quick add: paste a name, signature, address line or
vCard and the details it finds are listed, each with a one-click type. The
editor shows the name as one field whose parts fill themselves (titles and
degrees included), rows that appear as you type, Paste details for existing
contacts and a Save to picker. Contact help is a new AI feature, off by
default, that rereads pasted text and suggests updates from the signature
of a contact's newest mail. Phones are read with libphonenumber.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Keep spam off the radar and out of search; add a Not spam button (#308)

Cards read out of mail in Spam are left off the Happening Soon panel and
the mailbox strip (read-time filter, so moving the mail out brings them
back), and a spam conversation no longer shows its cards above the
message. The bin keeps its cards. Search and the type-ahead leave Spam
out unless asked with in:spam, as they already did for the bin. A spam
conversation's toolbar gets a Not spam button that moves it to the inbox.

Closes #304

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Add to contacts asks new or existing; contacts button gets the calendar's three positions (#312)

Closes #309
Closes #311

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show the pictures a review request hid, dropped or never asked for (#274)

* Show the pictures a review request hid, dropped or never asked for

A BAUHAUS review request, sent through Bazaarvoice, showed neither its
logo nor its product photos, with images allowed. The image proxy was
fine; three steps around it were not.

- The logo is linked as http://. The sanitizer keeps http sources and
  leaves them to render time, the fetcher only connects over TLS, and
  nothing in between upgraded the URL, so every http image became the
  placeholder. RemoteContentBlocker now signs the https:// form. The
  fetcher's own rules also say in the log which one refused a target;
  until now that refusal was the one silent path.
- The first product card sits inside a <form>, and dropElement('form')
  took the photo, the name and the star links with it. The form is
  unwrapped now; textarea and select still go with their content.
- Each card is class="d" style="display:none", switched on with
  .d {display:block !important}. CssToInlineStyles keeps an inline
  property whatever the rule says, so the card stayed hidden next to its
  Outlook twin. Html5CssInliner now lets a stylesheet !important beat a
  plain inline style, and matches selectors against forms as the divs
  the reader will see, so `form .a` rules take the sender's fallback
  for clients without forms.

  app:backfill safe-html-hidden-cards

re-sanitizes stored mail with a form or a stylesheet !important. One
task for both, because neither fix brings the card back alone.

Closes #271

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Mark the German mail page as translated from the current English one

Both pages gained the same two paragraphs in the previous commit, but
the translated-from hash still named the English page before them, so
DocumentationCoverageTest reported the translation as stale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Credit the image proxy entry and keep it as a changelog fragment

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Paul Lützner <karatektus@gmail.com>

* Swipe a mail row, choose what each swipe does, and long-press for its actions on a phone (#314)

On a phone, below md and by touch only: a row swipes right for read/unread and left for archive, each direction set under Settings → Mail → Swipe actions (read/unread, archive, star, nothing; never trash), and holding a row opens an action sheet with every action its strip offers. Mouse, trackpad and wider windows are unchanged.

Takes over #117, #118 and #119, whose fork was archived. Also gives the Contacts settings' two cards their gap.

Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Admin → Integrations lists the calendar services, so a pinned CalDAV address can be changed later (#279)

CalDAV and Calendar feed get their own group below the file services, with the shared provider row and Configure form; Nextcloud stays with the file services. Calendar services are offered to everyone, so their rows say so and the form has no switch. The server address accepts only http or https, up to 512 characters.

Once people have connected, the CalDAV form says how many connections a changed or cleared pin reaches. A calendar feed offers its address only while one is stored, so it can be cleared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Offer an address book another connection already mirrors locked, not as new (#319)

Two connections to the same server, a Nextcloud one and a CardDAV one made by hand, reach the same address book under the same remote id. Settings → Contacts now offers such a book locked, saying which connection has it, and refuses a posted tick, as it does for calendars. Otherwise every contact in it showed twice.

Closes #317

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gmailify: the IMAP folder places a sibling's mail, Gmail deletions detach, and a switch to turn it off (#204)

Closes #203
Closes #205

- A sibling's IMAP copy keeps the placement its folder gives it; Gmail's INBOX/SENT/DRAFT/TRASH/SPAM no longer move it (by @phdelodder).
- Gmail deleting its copy detaches the sibling's IMAP copy instead of erasing it.
- app:backfill gmailify-placement repairs copies the old merge had moved.
- Settings → Aliases: a Gmailify switch per Gmail account. Off detaches carried copies in a background job and declines mail Gmail still collects for another account (Bcc'd mail excepted, as documented).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Gmail sync: look up only the listed ids, not every stored one (#321)

Part of #223: newGmailIds() asks only about the ids Gmail just listed, and nothing when none were.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Store whether a conversation is in the bin, kept by triggers (#322)

Part of #223: message_thread.binned holds #268's rule, kept by triggers on message_label, thread_label and message; lists, badges and search read the column. Identical results on 41 compared queries, most 5-100x faster on a 67k-thread mailbox.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Account sync: wait out a provider that can't be reached, quietly (#333)

When the worker couldn't connect to the Gmail or Graph API at all, the
account sync went up Messenger's retry ladder: a WARNING with three nested
stack traces per attempt, then CRITICAL and a parked job if the outage
lasted. That job resumes from a cursor and is asked for again every
quarter hour, so nothing was ever lost.

WaitOutConnectionFailures now puts a SyncAccountMessage that failed only
with an HttpClient TransportException back on its own queue, on the
ingest ladder's delays, with one info line per attempt. After five waits
it is dropped with a single warning. Message batches and every other
failure keep the normal path.

Closes #332

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Sven Joder <sj@pmdevelopment.de>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Philippe Delodder <7259755+phdelodder@users.noreply.github.com>
Co-authored-by: jo2904 <jo2904@users.noreply.github.com>
Co-authored-by: DanversKara <DanversKara@users.noreply.github.com>
Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
2026-10-23 11:18:21 +02:00

981 lines
56 KiB
Twig
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{#
The badge is always rendered — hidden at zero rather than omitted — so the
sidebar controller can patch its text in place after a Mercure sync without
having to re-render the markup. `key` must match a key from
app_mail_sidebar_counts.
#}
{% macro unread_badge(count, key, href = null, quiet = false) %}
{#
A LINK when the row it sits in has an unread view to open, and a plain span
otherwise — the LABELS heading's roll-up is the only badge with no single
list behind it, so it stays a span rather than pointing somewhere
approximate.
The link is a sibling of the row's own anchor, never a child of it: an <a>
inside an <a> is not markup a browser will keep, and the row would have kept
whichever one the parser decided to drop. That is why the system rows are a
.nav-item DIV wrapping their anchor now, the shape the custom label rows and
the account folder rows were already built in — the CSS above says as much,
"the pill hangs off .nav-item, not off the anchor".
Deliberately a real link rather than a span with a click handler. It can be
tabbed to, middle-clicked into a new tab and opened from the keyboard, and
the alternative — a control that only answers a mouse — would have been a
new feature that keyboard users simply do not have.
`data-count-key` and the badge target stay exactly as they were, so
ui--sidebar patches the number in place whichever element it lands on, and
the aria-label keeps the spelling _nameBadge rewrites it with. No title:
nothing patches one, so it would be the one part of the badge still showing
a count from two minutes ago.
#}
{#
`quiet` is Spam's (#37). The number is still an unread count and still the
same round pill — unread spam is new spam, and saying how much is the point
of the badge — but the accent is the colour of "this wants you", and nothing
in Spam does. A filled accent pill there sat level with the Inbox's and
drew the eye to the one folder nobody is meant to work through. The wash is
the faint ink rather than a grey of its own, so it follows every theme.
#}
{% set _tone = quiet ? 'bg-ink-faint/20 text-ink-soft' : 'bg-accent text-accent-ink' %}
{% set _classes = 'rail-hide text-xs font-semibold px-1.5 py-0.5 rounded-full ' ~ _tone ~ (count > 0 ? '' : ' hidden') %}
{% if href is null %}
<span class="{{ _classes }}"
data-ui--sidebar-target="badge"
data-badge-kind="unread"
aria-label="{{ 'sidebar.badge.unread'|trans({ '%count%': count }) }}"
data-count-key="{{ key }}">{{ count }}</span>
{% else %}
{# The unread view of a folder is a mail list like any other, so it
opens the way the row beside it does — see list_frame below. #}
{% import _self as badgeMacros %}
<a href="{{ href }}"
{{ badgeMacros.list_frame() }}
class="{{ _classes }} shrink-0 transition-opacity hover:opacity-80
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
data-ui--sidebar-target="badge"
data-badge-kind="unread"
aria-label="{{ 'sidebar.badge.unread'|trans({ '%count%': count }) }}"
data-count-key="{{ key }}">{{ count }}</a>
{% endif %}
{% endmacro %}
{#
The badge for a number that is a TOTAL rather than an unread count — Trash and
Drafts, see SidebarCounts::TOTAL_ROLES.
The rule is right: nobody triages a bin, and the useful number there is how much
is in it. What was wrong is that saying so relied on colour alone. Both numbers
were the same filled pill in the same place at the same size, one red and one a
faint grey, and a bare "5" in a pill is a bare "5" — the shape said "badge", not
"how many". Colour is also the one channel a person may not have.
So the two now differ in SHAPE as well as tone: unread is a filled round pill
(it is a call to action and looks like one), a total is an outlined rounded
rectangle that reads as a tally. And both carry an accessible name that says
which kind of number it is, because to a screen reader they were both the word
"five".
#}
{% macro total_badge(count, key) %}
{# text-ink-SOFT, not muted. Muted is the caption ink and measured 3.07:1
against the Solar sidebar at 12px — under half of what AA asks. The soft
ink is 7.5:1 or better in every theme, and this is a number, not a
caption: it is the content of the row's right-hand side. #}
<span class="rail-hide text-xs font-medium tabular-nums px-1.5 py-0.5 rounded-md border border-line text-ink-soft{{ count > 0 ? '' : ' hidden' }}"
data-ui--sidebar-target="badge"
data-badge-kind="total"
aria-label="{{ 'sidebar.badge.total'|trans({ '%count%': count }) }}"
data-count-key="{{ key }}">{{ count }}</span>
{% endmacro %}
{% macro label_dot(color) %}
<span class="w-2.5 h-2.5 rounded-full shrink-0
{{ {
'gray': 'bg-ink-faint',
'red': 'bg-red-500',
'orange': 'bg-orange-500',
'amber': 'bg-amber-500',
'green': 'bg-green-500',
'teal': 'bg-teal-500',
'blue': 'bg-blue-500',
'violet': 'bg-violet-500',
'pink': 'bg-pink-500',
}[color ?? 'gray'] ?? 'bg-ink-faint' }}"
aria-hidden="true"></span>
{% endmacro %}
{#
Takes a Label directly — labels are user-scoped, so there is no per-account
merging left to do and no view model between the entity and the template.
#}
{#
Opens the edit modal for a label. Sits outside the row's <a> rather than
inside it — a button nested in an anchor is invalid HTML — and inside <summary>
it relies on ui--modal#open calling preventDefault(), which also stops the
disclosure toggling.
Hover-revealed, but focus-visible brings it back for keyboard users, and it
keeps its accessible name either way.
#}
{#
The attributes that make a nav row somewhere a conversation can be dropped.
One macro rather than the attributes written out at each of the nine rows that
carry them, because the contract is mail--dnd's and a row that spelled it
slightly differently would simply stop accepting drops — silently, with no
console error and nothing to grep for. See assets/controllers/mail/dnd_controller.js.
TWO ATTRIBUTES, BECAUSE A DROP MEANS TWO DIFFERENT THINGS
────────────────────────────────────────────────────────
`data-dnd-folder` MOVES: the conversation ends up there and nowhere else.
`data-dnd-label` ATTACHES: it gains the label and stays exactly where it was.
Which one a row gets is decided by where the row IS, not by what the label is —
and it has to be, because the same custom label appears twice in this sidebar.
Under LABELS it means "everywhere", and dropping there is tagging. Under an
account it names that account's own folder, and dropping there is filing. One
label, two rows, two meanings, and only the row knows which.
The rows above LABELS are the system ones — Inbox, Archive, Spam, Trash — and
they are places by definition, so they always move. Attaching Inbox to a
conversation that already carries Trash would be a half-move that leaves it
claiming to be in two places; the server refuses it for exactly that reason.
Separate attributes rather than a `mode="move|label"` string because the
controller has to select on them (`[data-dnd-folder], [data-dnd-label]`) and a
selector for an attribute VALUE is one typo away from matching nothing quietly.
`labelId` is null on a role the user has no label for yet. System labels are
created lazily — the first archive, the first snooze, the first folder sync — so
a fresh install genuinely has no Archive row to point at, and emitting
data-dnd-folder="" would produce a target that accepts a drop and posts a zero.
Nothing is emitted instead, and the row is an ordinary link.
`account` narrows a row to one mailbox, for the folder lists under each account.
Absent means the row takes mail from any of them, which is right for the shared
label section and for the system rows — those resolve their destination per
account inside ThreadStatusUpdater.
#}
{% macro drop_target(labelId, account = null, moves = false) %}
{%- if labelId is not null -%}
{{ moves ? 'data-dnd-folder' : 'data-dnd-label' }}="{{ labelId }}"
{%- if account is not null %} data-dnd-account="{{ account.id }}"{% endif -%}
{%- endif -%}
{% endmacro %}
{% macro label_edit(label) %}
<button
data-controller="ui--modal"
data-action="click->ui--modal#open"
data-ui--modal-title-value="{{ 'label.form.edit_title'|trans }}"
data-ui--modal-src-value="{{ path('app_label_edit', { id: label.id }) }}"
class="shrink-0 w-5 h-5 flex items-center justify-center rounded
text-ink-faint hover:text-ink hover:bg-hover
opacity-0 group-hover/label:opacity-100 focus-visible:opacity-100
transition-opacity cursor-pointer"
aria-label="{{ 'settings.labels.edit'|trans({ '%name%': label|label_name }) }}"
>
<i class="fa-solid fa-pen text-[9px]" aria-hidden="true"></i>
</button>
{% endmacro %}
{% macro label_node(label, depth, drawerClose) %}
{% import _self as macros %}
{% set children = sidebarCounts.visibleChildrenOf(label) %}
{% set _href = path('app_mail_label_path', { path: label.fullName }) %}
{% if children|length > 0 %}
{# Keyed by full name, in the same stored set as the two section
headings — one key space, one endpoint, one handler, so a nested
label and the section it lives in cannot end up behaving
differently.
`open` now comes from the server rather than from localStorage
applied on connect. That is a fix, not just a move: restoring it
after the first paint meant a collapsed tree was rendered open and
then shut on every navigation, and the state was stuck to one
browser. #}
{% set _treeKey = 'label:' ~ label.fullName %}
<details class="group/tree"
data-collapse-key="{{ _treeKey }}"
data-action="toggle->ui--sidebar#rememberCollapse"
{{ sidebarCounts.isCollapsed(_treeKey) ? '' : 'open' }}>
<summary class="nav-item group/label flex items-center gap-1 cursor-pointer list-none
transition-colors {{ sidebar_row_state(_href) }}"
{# A parent label takes drops like any other. It is a real
folder with real mail in it; having children does not
make it a heading. #}
{{ macros.drop_target(label.id) }}
data-nav-tier="tree"
aria-expanded="{{ sidebarCounts.isCollapsed(_treeKey) ? 'false' : 'true' }}"
data-ui--sidebar-target="collapseSummary"
style="--label-depth: {{ depth * 14 }}px">
<i class="fa-solid fa-chevron-right text-[9px] text-ink-faint w-3
transition-transform group-open/tree:rotate-90" aria-hidden="true"></i>
{{ macros.label_dot(label.color) }}
<a href="{{ _href }}"
{{ macros.list_frame() }}
class="flex-1 flex items-center justify-between gap-2 truncate"
data-ui--sidebar-target="link"
data-action="{{ drawerClose }}">
{# The dot travels with the NAME rather than being a third
child of this justify-between row, which would have
stranded it in the middle of the gap. The row reads
"what it is · something arrived — how much is unread".
The label's own colour dot on the far left is identity,
not status, which is why this cannot be folded into it. #}
<span class="min-w-0 flex items-center gap-1.5">
<span class="truncate">{{ label|label_name }}</span>
{{ macros.label_new_dot(label) }}
</span>
</a>
{{ macros.unread_badge(sidebarCounts.forLabel(label), 'label:' ~ label.id,
path('app_mail_label_path', { path: label.fullName, unread: 1 })) }}
{{ macros.label_edit(label) }}
</summary>
<div class="nav-rows">
{% for child in children %}
{{ macros.label_node(child, depth + 1, drawerClose) }}
{% endfor %}
</div>
</details>
{% else %}
<div class="nav-item group/label flex items-center gap-2 transition-colors {{ sidebar_row_state(_href) }}"
{{ macros.drop_target(label.id) }}
data-nav-tier="tree"
style="--label-depth: {{ depth * 14 + 16 }}px">
{{ macros.label_dot(label.color) }}
<a href="{{ _href }}"
{{ macros.list_frame() }}
class="flex-1 flex items-center justify-between gap-2 truncate min-w-0"
data-ui--sidebar-target="link"
data-action="{{ drawerClose }}">
<span class="truncate">{{ label|label_name }}</span>
</a>
{{ macros.unread_badge(sidebarCounts.forLabel(label), 'label:' ~ label.id,
path('app_mail_label_path', { path: label.fullName, unread: 1 })) }}
{{ macros.label_edit(label) }}
</div>
{% endif %}
{% endmacro %}
{#
A nav row's "something arrived here" dot.
Wrapped rather than calling the shared macro at each of the eight rows, because
the thing that must not drift is the KEY: it has to be the same string the
counts endpoint emits, and one spelling is easier to keep true than eight. See
App\Twig\NewMailMarkers::roleKey() and ::labelKey(), which build the other half.
`newMail` is a Twig global, and globals are the one thing a macro can still see
— which is also how label_node above reaches sidebarCounts.
#}
{% macro role_dot(role) %}
{% import '_partials/_new_dot.html.twig' as shared %}
{{ shared.new_dot(newMail.forRole(role), 'new:role:' ~ role.value, ('sidebar.nav.' ~ role.value)|trans) }}
{% endmacro %}
{% macro label_new_dot(label) %}
{% import '_partials/_new_dot.html.twig' as shared %}
{{ shared.new_dot(newMail.forLabel(label), 'new:label:' ~ label.id, label|label_name) }}
{% endmacro %}
{% macro starred_new_dot() %}
{% import '_partials/_new_dot.html.twig' as shared %}
{{ shared.new_dot(newMail.forStarred(), 'new:starred', 'sidebar.nav.starred'|trans) }}
{% endmacro %}
{#
What makes a nav row swap the LIST rather than the whole page.
Every row here that opens a mail list points into the one frame the list lives
in — see _layout/_mailbox.html.twig. Without it a folder click is an ordinary
Turbo visit: the server renders the sidebar the click started in, the topbar
above it and the calendar pane beside it, Turbo swaps the whole body, and the
one thing on screen that actually differs is the list. With it, the request
carries Turbo's `Turbo-Frame` header, the server answers with the document
stripped to that frame (App\Twig\ListFragmentGlobal), and the chrome is neither
rendered nor replaced. Measured on a fifty-row list: 21–24 database
statements for the page, 9–13 for the frame.
A macro rather than the attribute written out at each of the dozen rows that
carry it, for the same reason drop_target is one: a row that spelled it
differently would quietly go back to fetching whole pages, and there would be
nothing on screen to see.
ONLY ROWS THAT OPEN A LIST. Compose targets the dock frame, the calendar row is
a page of its own, and Settings and Admin are not mail at all — pointing any of
those at the list frame would put a whole page inside the message list.
Nothing here names an action: the frame itself carries `data-turbo-action`, and
Turbo falls back to the frame's when the link does not name one, so the URL
still advances and the tab title still follows the click.
── WHY THIS IS A MARKER AND NOT `data-turbo-frame` ─────────────────────────
THIS FILE IS SHARED BY FOUR PAGES AND ONLY ONE OF THEM HAS THE FRAME.
`_layout/_mailbox.html.twig` has `inbox-list-frame`. `admin/index.html.twig`,
`calendar/index.html.twig` and `settings/index.html.twig` include this same
sidebar and have no such frame anywhere on the page. v0.2.34 wrote the real
`data-turbo-frame="inbox-list-frame"` here, so on those three every folder row
claimed a frame that was not there — and that shipped, because the attribute is
correct in the host the person adding it was looking at.
A missing frame is not the harmless fallback it looks like. MEASURED against a
Turbo 8.0.23 harness, with the server logging the headers of every request:
hover → Turbo PREFETCHES the link, and a prefetch of a frame-targeted link
sends `Turbo-Frame: inbox-list-frame` WITHOUT FIRST CHECKING THAT
THE FRAME EXISTS. The server answers with the fragment: a head, and
a body containing only the list.
click → Turbo finds no frame, falls back to a Drive visit, and that visit
takes the prefetched response out of its URL-keyed cache. No second
request is made. The fragment is rendered AS THE DOCUMENT: correct
URL, no sidebar, no topbar, a bare message list.
Clicking without hovering first, or hovering longer than the prefetch cache
lives, made the same row work correctly — which is why this reached a release.
So the rule cannot be "remember not to use this macro off a mail page": the
markup has no way of knowing which page it is on, and two of this macro's
callers genuinely cannot know. `mail/_account_folder_rows.html.twig` is fetched
into its own frame by its own controller action, and `label/_lists.stream.html
.twig` is a Turbo Stream that re-renders these rows into whatever page happens
to be open — a label renamed from Settings re-renders the Settings sidebar.
The only place that can answer "is the list frame on this page" is the page.
So the row ships an inert marker and `ui--sidebar` promotes it to the real
`data-turbo-frame` on connect, but only after finding the frame in the live
DOM — see `_bindListFrame()` in assets/controllers/ui/sidebar_controller.js.
Off a mail page the marker stays inert, the row is an ordinary link, and the
click gets a whole page. Anything added to this sidebar later gets that for
free; there is no host-specific rule left to remember.
#}
{% macro list_frame() %}data-list-frame{% endmacro %}
{% import _self as macros %}
{#
_drawerMode is set to true when this sidebar is rendered inside the mobile drawer.
When true, nav links also dispatch a close action to the parent sidebar-drawer controller.
#}
{% set _drawerClose = _drawerMode is defined and _drawerMode ? 'click->ui--sidebar-drawer#close' : '' %}
{#
`shell-chrome` marks this as shell furniture rather than content, so a Layout
can restyle it (see .layout-flat in app.css). The drawer copy is deliberately
excluded: it floats over a dim backdrop and must stay an opaque card whatever
the layout.
#}
{#
Deliberately NOT data-turbo-permanent. Carrying the element across visits kept
it from moving, but it also froze what it says: after a data reset the deleted
labels stayed in the nav, linking to 404s, and a label created or renamed
anywhere else stayed invisible until a full reload. It re-renders per visit
now, and the two things anyone would notice moving are restored by the
controller instead — the scroll position and which trees are open.
#}
<aside id="{{ _drawerMode is defined and _drawerMode ? 'sidebar-drawer-inner' : 'sidebar' }}"
data-controller="ui--sidebar"
data-ui--sidebar-counts-url-value="{{ path('app_mail_sidebar_counts') }}"
{# The change sequence the badges below were counted at. The controller
ignores a `mail.changed` nudge that is not newer than it, so a page
that has just been rendered does not re-read numbers it was sent a
moment ago. See SidebarCounts::changeSequence(). #}
data-ui--sidebar-sequence-value="{{ sidebarCounts.changeSequence }}"
data-ui--sidebar-collapse-url-value="{{ path('app_mail_sidebar_section_collapsed') }}"
{# The badge names are patched on every sync alongside the numbers, so
the strings have to reach the controller rather than being written in
it. One JSON value, the way every other controller here takes them. #}
data-ui--sidebar-i18n-value="{{ {
unread: 'sidebar.badge.unread'|trans({ '%count%': '%count%' }),
total: 'sidebar.badge.total'|trans({ '%count%': '%count%' }),
}|json_encode }}"
class="flex flex-col w-56 h-full flex-shrink-0 overflow-hidden pane
transition-[width] duration-200
{{ _drawerMode is defined and _drawerMode ? '' : 'shell-chrome' }}">
{# Compose #}
<div class="compose-wrap px-3 py-3 border-b border-line">
<a href="{{ path('app_compose_new') }}"
data-turbo-frame="compose_dock"
data-shortcut="compose"
data-action="{{ _drawerClose }}"
title="{{ 'sidebar.compose'|trans }}"
class="compose-link w-full flex items-center justify-center gap-2 bg-accent hover:bg-accent-strong text-accent-ink text-sm font-medium rounded-lg px-3 py-row transition-colors">
<i class="fa-solid fa-plus w-4 h-4" aria-hidden="true"></i>
<span class="rail-hide">{{ 'sidebar.compose'|trans }}</span>
</a>
</div>
{# Nav #}
<nav class="flex-1 overflow-y-auto px-2 py-2 nav-rows text-sm"
data-ui--sidebar-target="scroller"
data-action="scroll->ui--sidebar#rememberScroll">
{# Which row is open is RENDERED, by sidebar_row_state() on every row
below, not left to ui--sidebar's pass on connect: arriving after the
page had been styled, that pass turned into a 150ms fade from black
on every row, on every load. The href is set once per row because the state
is decided from it — a row whose link and state were written out
separately could point at one place and light up for another. See
App\Twig\SidebarRowStateExtension. #}
{# Every system row answers to its eye toggle in label settings. Only
Spam used to: the toggle saved the flag for Inbox or Trash, the
settings list greyed the label out, and the row stayed. A role with
no label yet counts as shown — see SidebarCounts::isRoleHidden(). #}
{% if not sidebarCounts.isRoleHidden(enum('App\\Domain\\Enum\\Mail\\LabelRole').Inbox) %}
{% set _href = path('app_mail_inbox') %}
<div class="nav-item flex items-center justify-between gap-2 px-3 rounded-lg transition-colors {{ sidebar_row_state(_href) }}"
{# Dropping a conversation here is the same operation as the row's
own "back to the inbox" button: out of the bin, out of spam, out
of the archive. See ThreadStatusUpdater::move(), which sends
every system role to the action that already owns it. #}
{{ macros.drop_target(sidebarCounts.roleLabelId(enum('App\\Domain\\Enum\\Mail\\LabelRole').Inbox), null, true) }}>
{# The tier rides on the LINK, not on the pill around it. The pill is
what you see — .nav-item draws it — but the row's height comes from
padding-block, and padding on the wrapper is area the anchor does not
cover: the row would look 36px tall and answer a click on 20 of them.
The wrapper then takes its height from the link, so the geometry is
the same to the pixel as when the anchor WAS the row. #}
<a class="flex-1 min-w-0 flex items-center gap-2"
data-nav-tier="row"
href="{{ _href }}"
{{ macros.list_frame() }}
data-ui--sidebar-target="link"
data-action="{{ _drawerClose }}"
title="{{ 'sidebar.nav.inbox'|trans }}"
>
<span class="flex items-center gap-2">
<i class="fa-solid fa-inbox w-4" aria-hidden="true"></i>
<span class="rail-hide">{{ 'sidebar.nav.inbox'|trans }}</span>
{{ macros.role_dot(enum('App\\Domain\\Enum\\Mail\\LabelRole').Inbox) }}
</span>
</a>
{{ macros.unread_badge(sidebarCounts.forRoleBadge(enum('App\\Domain\\Enum\\Mail\\LabelRole').Inbox), 'role:inbox', path('app_mail_inbox', { unread: 1 })) }}
</div>
{% endif %}
{# Every unread conversation in one list. Opt-in: its label is created
hidden and switched on in label settings, like Spam's.
A drop here marks the mail unread and leaves it where it is — it is
a state, not a folder, so data-dnd-unread rather than drop_target(),
which only knows how to move or to label. See mail--dnd. #}
{% if sidebarCounts.hasVisibleRole(enum('App\\Domain\\Enum\\Mail\\LabelRole').Unread) %}
{% set _href = path('app_mail_unread') %}
<div class="nav-item flex items-center justify-between gap-2 px-3 rounded-lg transition-colors {{ sidebar_row_state(_href) }}"
data-dnd-unread>
{# The tier rides on the LINK, not on the pill around it. The pill is
what you see — .nav-item draws it — but the row's height comes from
padding-block, and padding on the wrapper is area the anchor does not
cover: the row would look 36px tall and answer a click on 20 of them.
The wrapper then takes its height from the link, so the geometry is
the same to the pixel as when the anchor WAS the row. #}
<a class="flex-1 min-w-0 flex items-center gap-2"
data-nav-tier="row"
href="{{ _href }}"
{{ macros.list_frame() }}
data-ui--sidebar-target="link"
data-action="{{ _drawerClose }}"
title="{{ 'sidebar.nav.unread'|trans }}"
>
<span class="flex items-center gap-2">
<i class="fa-solid fa-envelope w-4" aria-hidden="true"></i>
<span class="rail-hide">{{ 'sidebar.nav.unread'|trans }}</span>
</span>
</a>
{{ macros.unread_badge(sidebarCounts.forUnread(), 'unread') }}
</div>
{% endif %}
{# A drop here stars the mail and leaves it where it is, as the Unread
row above marks it unread: a state, not a folder. See mail--dnd. #}
{% set _href = path('app_mail_starred') %}
<div class="nav-item flex items-center justify-between gap-2 px-3 rounded-lg transition-colors {{ sidebar_row_state(_href) }}"
data-dnd-star>
{# The tier rides on the LINK, not on the pill around it. The pill is
what you see — .nav-item draws it — but the row's height comes from
padding-block, and padding on the wrapper is area the anchor does not
cover: the row would look 36px tall and answer a click on 20 of them.
The wrapper then takes its height from the link, so the geometry is
the same to the pixel as when the anchor WAS the row. #}
<a class="flex-1 min-w-0 flex items-center gap-2"
data-nav-tier="row"
href="{{ _href }}"
{{ macros.list_frame() }}
data-ui--sidebar-target="link"
data-action="{{ _drawerClose }}"
title="{{ 'sidebar.nav.starred'|trans }}"
>
<span class="flex items-center gap-2">
<i class="fa-solid fa-star w-4" aria-hidden="true"></i>
<span class="rail-hide">{{ 'sidebar.nav.starred'|trans }}</span>
{{ macros.starred_new_dot() }}
</span>
</a>
{{ macros.unread_badge(sidebarCounts.forStarred(), 'starred', path('app_mail_starred', { unread: 1 })) }}
</div>
{% if not sidebarCounts.isRoleHidden(enum('App\\Domain\\Enum\\Mail\\LabelRole').Sent) %}
{% set _href = path('app_mail_sent') %}
<a href="{{ _href }}"
{{ macros.list_frame() }}
class="nav-item flex items-center justify-between gap-2 px-3 rounded-lg transition-colors {{ sidebar_row_state(_href) }}"
data-nav-tier="row"
data-ui--sidebar-target="link"
data-action="{{ _drawerClose }}"
title="{{ 'sidebar.nav.sent'|trans }}"
>
<span class="flex items-center gap-2">
<i class="fa-solid fa-paper-plane w-4" aria-hidden="true"></i>
<span class="rail-hide">{{ 'sidebar.nav.sent'|trans }}</span>
{{ macros.role_dot(enum('App\\Domain\\Enum\\Mail\\LabelRole').Sent) }}
</span>
{{ macros.unread_badge(sidebarCounts.forRoleBadge(enum('App\\Domain\\Enum\\Mail\\LabelRole').Sent), 'role:sent') }}
</a>
{% endif %}
{% if not sidebarCounts.isRoleHidden(enum('App\\Domain\\Enum\\Mail\\LabelRole').Drafts) %}
{% set _href = path('app_mail_drafts') %}
<a href="{{ _href }}"
{{ macros.list_frame() }}
class="nav-item flex items-center justify-between gap-2 px-3 rounded-lg transition-colors {{ sidebar_row_state(_href) }}"
data-nav-tier="row"
data-ui--sidebar-target="link"
data-action="{{ _drawerClose }}"
title="{{ 'sidebar.nav.drafts'|trans }}"
>
<span class="flex items-center gap-2">
<i class="fa-solid fa-file-lines w-4" aria-hidden="true"></i>
<span class="rail-hide">{{ 'sidebar.nav.drafts'|trans }}</span>
{{ macros.role_dot(enum('App\\Domain\\Enum\\Mail\\LabelRole').Drafts) }}
{% import '_partials/_unsent_mark.html.twig' as unsent %}
{{ unsent.sidebar(sidebarCounts.unsentDrafts()) }}
</span>
{{ macros.total_badge(sidebarCounts.forRoleBadge(enum('App\\Domain\\Enum\\Mail\\LabelRole').Drafts), 'role:drafts') }}
</a>
{% endif %}
{# Every conversation outside Spam and the bin, in one list. Opt-in like
Unread: its label is created hidden and switched on in label
settings (LabelRole::AllMail), and the rows under each account follow
the same switch (mail/_account_folder_rows.html.twig).
Up here rather than under More, beside the lists people read, because
finding archived mail in one click is what it is for. No badge: a
count of everything is not news, and its unread share is the Unread
row's job. Takes no drops — there is nothing to move into a list that
already holds everything. #}
{% if sidebarCounts.hasVisibleRole(enum('App\\Domain\\Enum\\Mail\\LabelRole').AllMail) %}
{% set _href = path('app_mail_all') %}
<a href="{{ _href }}"
{{ macros.list_frame() }}
class="nav-item flex items-center justify-between gap-2 px-3 rounded-lg transition-colors {{ sidebar_row_state(_href) }}"
data-nav-tier="row"
data-ui--sidebar-target="link"
data-action="{{ _drawerClose }}"
title="{{ 'sidebar.nav.all_mail'|trans }}"
>
<span class="flex items-center gap-2">
<i class="fa-solid fa-layer-group w-4" aria-hidden="true"></i>
<span class="rail-hide">{{ 'sidebar.nav.all_mail'|trans }}</span>
</span>
</a>
{% endif %}
{# Drawer only. On a wide screen the calendar is a panel that slides in
beside the mail, and a control for that is not a destination — as a
nav row it read as a link that did not navigate. It lives in the
topbar there instead. Below lg there is nowhere to dock it, so this
is a plain link to the page like any other row here. #}
{% if _drawerMode is defined and _drawerMode %}
{% set _href = path('app_calendar_index') %}
<a href="{{ _href }}"
class="nav-item flex items-center justify-between gap-2 px-3 rounded-lg transition-colors {{ sidebar_row_state(_href) }}"
data-nav-tier="row"
data-ui--sidebar-target="link"
data-action="{{ _drawerClose }}"
title="{{ 'sidebar.nav.calendar'|trans }}"
>
<span class="flex items-center gap-2">
<i class="fa-regular fa-calendar w-4" aria-hidden="true"></i>
<span class="rail-hide">{{ 'sidebar.nav.calendar'|trans }}</span>
</span>
</a>
{% endif %}
{# Snoozed and Archive live behind a disclosure: both are real
destinations, and neither is somewhere you go often enough to
spend a permanent row on. Archive in particular had no entry at
all unless its label had been switched visible in settings —
the route existed and nothing linked to it.
<details> rather than a controller: the open/closed state is
exactly what the element is for, and it keeps working before any
JavaScript has run.
`open` when you are already on one of the pages inside it, and that
is a bug fix rather than a nicety. The sidebar is deliberately not
data-turbo-permanent, so every navigation re-renders it from
scratch and the disclosure came back shut — click More, click
Archive, and the menu snapped closed with the row you had just
chosen hidden behind it again. It read as "the menu collapsed
instead of doing anything". Decided here rather than remembered in
localStorage like the label trees, because the honest answer to
"should this be open?" is "are you in it?", and the server already
knows. #}
{% set _showSnoozed = sidebarCounts.hasSnoozed and not sidebarCounts.isRoleHidden(enum('App\\Domain\\Enum\\Mail\\LabelRole').Snoozed) %}
{% set _showArchive = not sidebarCounts.isRoleHidden(enum('App\\Domain\\Enum\\Mail\\LabelRole').Archive) %}
{% set _showSpam = sidebarCounts.hasVisibleRole(enum('App\\Domain\\Enum\\Mail\\LabelRole').Spam) %}
{% set _showTrash = not sidebarCounts.isRoleHidden(enum('App\\Domain\\Enum\\Mail\\LabelRole').Trash) %}
{% set _moreRoutes = ['app_mail_snoozed', 'app_mail_archive', 'app_mail_spam', 'app_mail_trash'] %}
{# Not drawn at all when everything in it is switched off: a disclosure
that opens onto nothing is a control that does nothing. #}
{% if _showSnoozed or _showArchive or _showSpam or _showTrash %}
<details class="rail-hide group/more" {{ app.request.attributes.get('_route') in _moreRoutes ? 'open' : '' }}>
<summary class="nav-item flex items-center gap-2 px-3 rounded-lg cursor-pointer list-none transition-colors text-ink-muted"
data-nav-tier="row">
{# transition-transform was here from the start, but nothing ever
set the open-state transform — the chevron had an animation
and no destination. group-open is the same recipe the label
tree uses. #}
<i class="fa-solid fa-chevron-right w-4 text-xs transition-transform group-open/more:rotate-90" aria-hidden="true"></i>
<span>{{ 'sidebar.nav.more'|trans }}</span>
</summary>
{% if _showSnoozed %}
{% set _href = path('app_mail_snoozed') %}
<div class="nav-item flex items-center justify-between gap-2 pl-9 pr-3 rounded-lg transition-colors {{ sidebar_row_state(_href) }}">
{# The tier rides on the LINK, not on the pill around it. The pill is
what you see — .nav-item draws it — but the row's height comes from
padding-block, and padding on the wrapper is area the anchor does not
cover: the row would look 36px tall and answer a click on 20 of them.
The wrapper then takes its height from the link, so the geometry is
the same to the pixel as when the anchor WAS the row. #}
<a class="flex-1 min-w-0 flex items-center gap-2"
data-nav-tier="row"
href="{{ _href }}"
{{ macros.list_frame() }}
data-ui--sidebar-target="link"
data-action="{{ _drawerClose }}"
title="{{ 'sidebar.nav.snoozed'|trans }}"
>
<span class="flex items-center gap-2">
<i class="fa-solid fa-clock w-4" aria-hidden="true"></i>
<span class="rail-hide">{{ 'sidebar.nav.snoozed'|trans }}</span>
{{ macros.role_dot(enum('App\\Domain\\Enum\\Mail\\LabelRole').Snoozed) }}
</span>
</a>
{{ macros.unread_badge(sidebarCounts.forRoleBadge(enum('App\\Domain\\Enum\\Mail\\LabelRole').Snoozed), 'role:snoozed', path('app_mail_snoozed', { unread: 1 })) }}
</div>
{% endif %}
{% if _showArchive %}
{% set _href = path('app_mail_archive') %}
<div class="nav-item flex items-center justify-between gap-2 pl-9 pr-3 rounded-lg transition-colors {{ sidebar_row_state(_href) }}"
{{ macros.drop_target(sidebarCounts.roleLabelId(enum('App\\Domain\\Enum\\Mail\\LabelRole').Archive), null, true) }}>
{# The tier rides on the LINK, not on the pill around it. The pill is
what you see — .nav-item draws it — but the row's height comes from
padding-block, and padding on the wrapper is area the anchor does not
cover: the row would look 36px tall and answer a click on 20 of them.
The wrapper then takes its height from the link, so the geometry is
the same to the pixel as when the anchor WAS the row. #}
<a class="flex-1 min-w-0 flex items-center gap-2"
data-nav-tier="row"
href="{{ _href }}"
{{ macros.list_frame() }}
data-ui--sidebar-target="link"
data-action="{{ _drawerClose }}"
title="{{ 'sidebar.nav.archive'|trans }}"
>
<span class="flex items-center gap-2">
<i class="fa-solid fa-box-archive w-4" aria-hidden="true"></i>
<span class="rail-hide">{{ 'sidebar.nav.archive'|trans }}</span>
{{ macros.role_dot(enum('App\\Domain\\Enum\\Mail\\LabelRole').Archive) }}
</span>
</a>
{{ macros.unread_badge(sidebarCounts.forRoleBadge(enum('App\\Domain\\Enum\\Mail\\LabelRole').Archive), 'role:archive', path('app_mail_archive', { unread: 1 })) }}
</div>
{% endif %}
{# The one row that is absent until its label is switched visible,
where the others are present until switched off: plenty of installs
have no junk folder worth a permanent row. It had no row at ALL
before — no entry, no route, no translation key — so switching Spam
visible in settings did nothing observable anywhere. Sits above
Trash because that is where LabelRole::sortOrder() already put it. #}
{% if _showSpam %}
{% set _href = path('app_mail_spam') %}
<div class="nav-item flex items-center justify-between gap-2 pl-9 pr-3 rounded-lg transition-colors {{ sidebar_row_state(_href) }}"
{# "This is junk", said by dropping — the same move the spam
button and Move to make, for a pointer that is already
holding the mail. #}
{{ macros.drop_target(sidebarCounts.roleLabelId(enum('App\\Domain\\Enum\\Mail\\LabelRole').Spam), null, true) }}>
{# The tier rides on the LINK, not on the pill around it. The pill is
what you see — .nav-item draws it — but the row's height comes from
padding-block, and padding on the wrapper is area the anchor does not
cover: the row would look 36px tall and answer a click on 20 of them.
The wrapper then takes its height from the link, so the geometry is
the same to the pixel as when the anchor WAS the row. #}
<a class="flex-1 min-w-0 flex items-center gap-2"
data-nav-tier="row"
href="{{ _href }}"
{{ macros.list_frame() }}
data-ui--sidebar-target="link"
data-action="{{ _drawerClose }}"
title="{{ 'sidebar.nav.spam'|trans }}"
>
<span class="flex items-center gap-2">
<i class="fa-solid fa-ban w-4" aria-hidden="true"></i>
<span class="rail-hide">{{ 'sidebar.nav.spam'|trans }}</span>
{{ macros.role_dot(enum('App\\Domain\\Enum\\Mail\\LabelRole').Spam) }}
</span>
</a>
{{ macros.unread_badge(sidebarCounts.forRoleBadge(enum('App\\Domain\\Enum\\Mail\\LabelRole').Spam), 'role:spam', path('app_mail_spam', { unread: 1 }), true) }}
</div>
{% endif %}
{% if _showTrash %}
{% set _href = path('app_mail_trash') %}
<a href="{{ _href }}"
{{ macros.list_frame() }}
class="nav-item flex items-center justify-between gap-2 pl-9 pr-3 rounded-lg transition-colors {{ sidebar_row_state(_href) }}"
{{ macros.drop_target(sidebarCounts.roleLabelId(enum('App\\Domain\\Enum\\Mail\\LabelRole').Trash), null, true) }}
data-nav-tier="row"
data-ui--sidebar-target="link"
data-action="{{ _drawerClose }}"
title="{{ 'sidebar.nav.trash'|trans }}"
>
<span class="flex items-center gap-2">
<i class="fa-solid fa-trash w-4" aria-hidden="true"></i>
<span class="rail-hide">{{ 'sidebar.nav.trash'|trans }}</span>
{{ macros.role_dot(enum('App\\Domain\\Enum\\Mail\\LabelRole').Trash) }}
</span>
{{ macros.total_badge(sidebarCounts.forRoleBadge(enum('App\\Domain\\Enum\\Mail\\LabelRole').Trash), 'role:trash') }}
</a>
{% endif %}
</details>
{% endif %}
{# Contacts: the address book. Above the labels and accounts, with
the mail views: under the accounts heading it read as one more
account.
Drawer only, as the calendar's row is and for its reason (#311): on
a wide screen the address book is a pane beside the mail, and the
topbar button that moves it is the way in. A row that went to the
page beside it read as a second, different contacts. #}
{% if _drawerMode is defined and _drawerMode %}
{% set _contactsHref = path('app_contacts_list') %}
<a href="{{ _contactsHref }}"
class="nav-item mb-2 flex items-center justify-between gap-2 px-3 rounded-lg transition-colors {{ sidebar_row_state(_contactsHref) }}"
data-nav-tier="row"
data-ui--sidebar-target="link"
data-action="{{ _drawerClose }}"
title="{{ 'contacts.nav'|trans }}"
>
<span class="flex items-center gap-2">
<i class="fa-solid fa-address-book w-4" aria-hidden="true"></i>
<span class="rail-hide">{{ 'contacts.nav'|trans }}</span>
</span>
</a>
{% endif %}
{# ── Labels section ────────────────────────────────────────────────
A <details>, which is what the "More" block above already is, and
deliberately the same element rather than a second collapsing
mechanism beside it: the chevron recipe, the summary.nav-item shape,
Enter/Space activation, the accessible name and the expanded state a
screen reader reads out are then all the ones this file already has,
and none of them are re-implemented.
What differs from More is only where "should this be open?" comes
from. More asks the ROUTE — the honest answer there is "are you in
it?". Here the honest answer is "how did you leave it", so it comes
from the user's settings bag, and it is rendered into `open` on the
server. That is the whole point of storing it per user rather than in
localStorage: the sidebar re-renders on every visit, and a section
collapsed by JavaScript after the first paint is a section the user
watches snap shut on every single click.
The section, rather than each label, is the unit — see the tree keys
below, which remain for anyone who does nest. A wall of two dozen
flat labels pushing the accounts off the bottom of the nav is the
case this exists for, and per-label collapse does nothing for it. #}
{% set _labelsCollapsed = sidebarCounts.isCollapsed(constant('App\\Entity\\User\\User::SIDEBAR_SECTION_LABELS')) %}
<details class="rail-hide group/labels"
data-action="toggle->ui--sidebar#rememberCollapse"
data-collapse-key="{{ constant('App\\Entity\\User\\User::SIDEBAR_SECTION_LABELS') }}"
{{ _labelsCollapsed ? '' : 'open' }}>
{# Named explicitly, because a summary takes its accessible name
from its contents and this one contains a roll-up badge and the
"Create label" button — unnamed, it read as "Labels 3 unread
Create label". The same string as the visible heading, so a
voice user asking for "Labels" still gets this. #}
<summary class="group/head nav-section-y pb-1 px-3 flex items-center gap-2 cursor-pointer list-none rounded-lg transition-colors hover:bg-hover"
aria-label="{{ 'sidebar.labels.heading'|trans }}"
aria-expanded="{{ _labelsCollapsed ? 'false' : 'true' }}"
data-ui--sidebar-target="collapseSummary">
<i class="fa-solid fa-chevron-right w-3 text-[9px] text-ink-faint transition-transform group-open/labels:rotate-90" aria-hidden="true"></i>
<span class="flex-1 text-xs font-semibold text-ink-faint uppercase tracking-wider">{{ 'sidebar.labels.heading'|trans }}</span>
{# The section's own number, and ONLY while it is shut. Open,
the per-label badges under it say it better and this would
be the same mail counted twice on screen — so the disclosure
hides it, via the group-open variant rather than by the
controller, which owns the plain `hidden` class and toggles
it purely on whether the count is zero. The two never fight:
both resolve to display:none, and they answer different
questions.
Rendered whether or not it is visible, so refreshCounts()
patches it by data-count-key like every other badge — a
collapsed section that stopped being patched would show a
number from three syncs ago the moment it was reopened. #}
<span class="group-open/labels:hidden">{{ macros.unread_badge(sidebarCounts.unreadInLabels(), 'labels:unread') }}</span>
{# Quiet until its heading row is hovered. A "+" on every
section heading is two controls permanently on screen for
something done a few times a year. The row is the summary
(`group/head`), not the section: a label further down must
not light it up.
Two exits from "hidden", and both are needed. Keyboard focus
shows it, or a button reached with Tab would be invisible
while it holds focus. And it is only ever hidden where there
is a hover to reveal it with — `[@media(hover:hover)]` — or
a phone could neither create a label nor add an account.
`transition` rather than `transition-colors`, so the fade is
one. The Accounts heading below has the same button. #}
<button
data-controller="ui--modal"
data-action="click->ui--modal#open {{ _drawerClose }}"
data-ui--modal-title-value="{{ 'sidebar.labels.add'|trans }}"
data-ui--modal-src-value="{{ path('app_label_new') }}"
class="p-1 rounded hover:bg-hover text-gray-400 hover:text-ink-soft transition cursor-pointer
[@media(hover:hover)]:opacity-0 group-hover/head:opacity-100 focus-visible:opacity-100"
aria-label="{{ 'sidebar.labels.add'|trans }}"
>
<i class="fa-solid fa-plus w-3.5 h-3.5" aria-hidden="true"></i>
</button>
</summary>
<div id="{{ _drawerMode is defined and _drawerMode ? 'label-list-drawer' : 'label-list' }}" class="nav-rows">
{% set labelTree = sidebarCounts.userLabelTree() %}
{% if labelTree|length == 0 %}
<p class="px-3 text-xs text-ink-faint" data-nav-tier="tree">{{ 'sidebar.labels.none'|trans }}</p>
{% else %}
{% for node in labelTree %}
{{ macros.label_node(node, 0, _drawerClose) }}
{% endfor %}
{% endif %}
</div>
</details>
{# ── Accounts section ──────────────────────────────────────────────
The same disclosure, for the same reason and out of the same stored
set. It does NOT replace or duplicate SETTING_SIDEBAR_ACCOUNT, which
answers a different question: that one is *which* account is
expanded, this one is whether the accounts are on screen at all.
Collapsing takes the expanded account's folder list down with it and
expanding brings the same account back still open, because neither
setting is written when the other changes.
No roll-up badge here, unlike LABELS, and not for symmetry's sake:
there is no honest single number. Account rows count within one
account, and only the expanded one's folders are ever loaded, so a
heading total would either be a sum over lists that are not on the
page or a count that silently means "the one account you happened to
have open". #}
{% set _accountsCollapsed = sidebarCounts.isCollapsed(constant('App\\Entity\\User\\User::SIDEBAR_SECTION_ACCOUNTS')) %}
<details class="rail-hide group/accounts"
data-action="toggle->ui--sidebar#rememberCollapse"
data-collapse-key="{{ constant('App\\Entity\\User\\User::SIDEBAR_SECTION_ACCOUNTS') }}"
{{ _accountsCollapsed ? '' : 'open' }}>
<summary class="group/head nav-section-y pb-1 px-3 flex items-center gap-2 cursor-pointer list-none rounded-lg transition-colors hover:bg-hover"
aria-label="{{ 'sidebar.accounts.heading'|trans }}"
aria-expanded="{{ _accountsCollapsed ? 'false' : 'true' }}"
data-ui--sidebar-target="collapseSummary">
<i class="fa-solid fa-chevron-right w-3 text-[9px] text-ink-faint transition-transform group-open/accounts:rotate-90" aria-hidden="true"></i>
<span class="flex-1 text-xs font-semibold text-ink-faint uppercase tracking-wider">{{ 'sidebar.accounts.heading'|trans }}</span>
<button
data-controller="ui--modal"
data-action="click->ui--modal#open {{ _drawerClose }}"
data-ui--modal-title-value="{{ 'sidebar.accounts.add'|trans }}"
data-ui--modal-src-value="{{ path('app_account_new') }}"
class="p-1 rounded hover:bg-hover text-gray-400 hover:text-ink-soft transition cursor-pointer
[@media(hover:hover)]:opacity-0 group-hover/head:opacity-100 focus-visible:opacity-100"
aria-label="{{ 'sidebar.accounts.add'|trans }}"
>
<i class="fa-solid fa-plus w-3.5 h-3.5" aria-hidden="true"></i>
</button>
</summary>
<div id="{{ _drawerMode is defined and _drawerMode ? 'account-list-drawer' : 'account-list' }}" class="nav-rows">
{# The same suffix the list containers either side of this use. Both
sidebars render this partial on every page, so without it each
account's folder frame and expand toggle existed twice under one
id — invalid HTML, and Turbo resolves a frame target BY id, so
the drawer and the desktop rail were one bad guess apart from
swapping folder lists. #}
{% include '_partials/_account_list.html.twig' with {
_drawerClose: _drawerClose,
_idSuffix: _drawerMode is defined and _drawerMode ? '-drawer' : '',
} %}
</div>
</details>
</nav>
{# ClockPlacement::Sidebar: the day at a glance, pinned under the nav — the
nav is the part that scrolls, so this stays put however long the label
list grows. The time, the date, and the next thing coming up, which opens
the same Happening Soon panel the topbar button does. Hidden in the rail,
which has no width for any of it. #}
{% if clock.placement == 'sidebar' %}
{% import '_partials/_clock.html.twig' as clockui %}
{% set soonest = calendar.soonest %}
<div class="rail-hide shrink-0 px-2 pb-2">
<div {{ clockui.attrs() }}
data-clock-placement="sidebar"
class="flex flex-col gap-2 rounded-pane border border-line bg-surface/60 px-3.5 py-3">
<div class="flex items-baseline gap-1.5 tabular-nums">
<span data-ui--clock-target="bare"
class="text-3xl font-semibold tracking-tight leading-none text-ink">{{ clockui.bare() }}</span>
{% if clock.is12Hour %}
<span data-ui--clock-target="meridiem" class="text-sm text-ink-faint">{{ clockui.meridiem() }}</span>
{% endif %}
</div>
<div class="-mt-1 text-xs text-ink-muted">{{ clockui.longDate() }}</div>
{% if soonest %}
<button type="button"
data-controller="ui--modal"
data-action="click->ui--modal#open {{ _drawerClose }}"
data-ui--modal-src-value="{{ path('app_calendar_soon') }}"
data-ui--modal-title-value="{{ 'calendar.soon.title'|trans }}"
data-ui--modal-size-value="wide"
class="flex min-w-0 items-start gap-2 rounded-lg bg-sunken px-2.5 py-1.5 text-left
hover:bg-hover transition-colors cursor-pointer
focus:outline-none focus-visible:ring-2 focus-visible:ring-accent">
<i class="{{ soonest.icon }} mt-0.5 text-xs text-ink-faint" aria-hidden="true"></i>
<span class="flex min-w-0 flex-col">
<span class="text-[11px] text-ink-faint">
{{- 'calendar.soon.next'|trans }} ·
{{ soonest.startsAt|intl_date('EEEdMMM', soonest.event.isAllDay ? false : null) }}
{%- if not soonest.event.isAllDay %}, {{ soonest.startsAt|date(clock.time) }}{% endif -%}
</span>
<span class="truncate text-xs font-medium text-ink">
{{- soonest.event.title ?: 'calendar.soon.untitled'|trans -}}
</span>
</span>
</button>
{% endif %}
</div>
</div>
{% endif %}
</aside>