Scope the sidebar's per-account labels to their account, and surface Archive

**Labels under an account listed every account's mail.** Labels are
user-scoped and bind to several accounts at once, so a label on its own
means "across the whole mailbox" — which is right for the sidebar's own
label section and wrong under an account, where the same entry reads as
"this account's" and returned everybody's.

Those entries now carry `?account=`, and the label view narrows by it. A
query parameter rather than a second route, because it is a filter on
the same view and the label id alone still has to resolve. It is checked
against the signed-in user rather than trusted: it arrives off the query
string, and without the check appending someone else's account id would
scope a label you own to threads you do not.

The scoped view says which account it is showing and offers a link back
to the label across all of them — the same label under a different
account looks identical otherwise, and an empty one is then
indistinguishable from an empty label.

**Clicking an account now opens it.** The whole row used to toggle the
label list, so there was no way to ask for the account itself. The name
navigates to a new /mail/account/{account} view; the chevron kept the
expanding.

**Archive had no way in.** The route existed, and the sidebar entry was
gated on the user having switched the Archive label visible in label
settings — and it is created hidden, so on a normal install nothing
linked to it. It now sits with Snoozed behind a collapsed <details>,
which is also where it stops competing for a permanent row.

Not covered by a test: that the filter returns *fewer* threads. The
fixture seeds a single account, so scoped and unscoped results are
identical there — the specs pin the wiring, the ownership check and the
UI, and the query itself is a one-line andWhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5 committed 2026-07-31 23:23:05 +02:00
1 parent daef201019
commit 6edf33fed9
10 files changed
+329 -23

No files matched your search

+60 -2
View File
@@ -9,6 +9,7 @@ use App\Entity\Label\Label;
use App\Entity\Mail\Message;
use App\Entity\Mail\MessageThread;
use App\Repository\Label\LabelRepository;
use App\Repository\Mail\AccountRepository;
use App\Repository\Mail\MailboxRepository;
use App\Repository\Mail\MessageRepository;
use App\Repository\Mail\MessageThreadRepository;
@@ -27,6 +28,7 @@ final class MailController extends AbstractController
private readonly MessageRepository $messageRepository,
private readonly MessageThreadRepository $threadRepository,
private readonly LabelRepository $labelRepository,
private readonly AccountRepository $accountRepository,
)
{
}
@@ -94,14 +96,70 @@ final class MailController extends AbstractController
private function renderLabel(Label $label, Request $request): Response
{
$account = $this->requestedAccount($request);
$page = max(1, (int) $request->query->get('page', 1));
$threads = $this->threadRepository->findForLabel($label, $page);
$total = $this->threadRepository->countForLabel($label);
$threads = $this->threadRepository->findForLabel($label, $account, $page);
$total = $this->threadRepository->countForLabel($label, $account);
$this->threadRepository->preloadLabels($threads);
return $this->render('mail/label.html.twig', [
'label' => $label,
'account' => $account,
'threads' => $threads,
'page' => $page,
'total' => $total,
'per_page' => 50,
]);
}
/**
* The `?account=` a label view may be narrowed by.
*
* A query parameter rather than a second route: it is a filter on the same
* view, and the label id alone still has to resolve — the sidebar's own
* label list means "across every account" and links without it.
*
* Ownership is checked here rather than trusted, because this comes
* straight off the query string: without it, appending someone else's
* account id would scope a label you own to threads you do not.
*/
private function requestedAccount(Request $request): ?Account
{
$id = $request->query->get('account');
if (null === $id || '' === $id) {
return null;
}
$account = $this->accountRepository->find($id);
if (null === $account || $account->getUsr() !== $this->getUser()) {
throw $this->createAccessDeniedException();
}
return $account;
}
/**
* Everything in one account — what clicking the account itself in the
* sidebar now does. Its labels sit underneath for narrowing further.
*/
#[Route('/account/{account}', name: 'account')]
public function accountView(Account $account, Request $request): Response
{
if ($account->getUsr() !== $this->getUser()) {
throw $this->createAccessDeniedException();
}
$page = max(1, (int) $request->query->get('page', 1));
$threads = $this->threadRepository->findForAccount($account, $page);
$total = $this->threadRepository->countForAccount($account);
$this->threadRepository->preloadLabels($threads);
return $this->render('mail/account.html.twig', [
'account' => $account,
'threads' => $threads,
'page' => $page,
'total' => $total,
@@ -10,6 +10,7 @@ use App\Entity\Label\Label;
use App\Entity\Mail\MessageThread;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\DBAL\ParameterType;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Security\Core\User\UserInterface;
@@ -160,14 +161,53 @@ class MessageThreadRepository extends ServiceEntityRepository
->getQuery()
->getSingleScalarResult();
}
public function findForLabel(Label $label, int $page = 1, int $perPage = 50): array
/**
* Threads carrying a label, optionally narrowed to one account.
*
* Labels are user-scoped and can be bound to several accounts at once, so
* a label on its own spans every account that has it. That is right for the
* sidebar's own label list — one "Receipts" across the whole mailbox — and
* wrong under an account, where the same entry is read as "this account's
* Receipts" and returned everybody's.
*/
public function findForLabel(Label $label, ?Account $account = null, int $page = 1, int $perPage = 50): array
{
$offset = ($page - 1) * $perPage;
$qb = $this->createQueryBuilder('t')
->join('t.labels', 'l')
->where('l = :label')
->setParameter('label', $label)
->orderBy('t.lastMessageAt', 'DESC')
->setFirstResult($offset)
->setMaxResults($perPage);
$this->narrowToAccount($qb, $account);
return $qb->getQuery()->getResult();
}
public function countForLabel(Label $label, ?Account $account = null): int
{
$qb = $this->createQueryBuilder('t')
->select('COUNT(t.id)')
->join('t.labels', 'l')
->where('l = :label')
->setParameter('label', $label);
$this->narrowToAccount($qb, $account);
return (int) $qb->getQuery()->getSingleScalarResult();
}
/** Everything in one account, whatever it is labelled. */
public function findForAccount(Account $account, int $page = 1, int $perPage = 50): array
{
$offset = ($page - 1) * $perPage;
return $this->createQueryBuilder('t')
->join('t.labels', 'l')
->where('l = :label')
->setParameter('label', $label)
->where('t.account = :account')
->setParameter('account', $account)
->orderBy('t.lastMessageAt', 'DESC')
->setFirstResult($offset)
->setMaxResults($perPage)
@@ -175,16 +215,25 @@ class MessageThreadRepository extends ServiceEntityRepository
->getResult();
}
public function countForLabel(Label $label): int
public function countForAccount(Account $account): int
{
return (int) $this->createQueryBuilder('t')
->select('COUNT(t.id)')
->join('t.labels', 'l')
->where('l = :label')
->setParameter('label', $label)
->where('t.account = :account')
->setParameter('account', $account)
->getQuery()
->getSingleScalarResult();
}
/** No-op when no account was asked for, so callers need no branch. */
private function narrowToAccount(QueryBuilder $qb, ?Account $account): void
{
if (null === $account) {
return;
}
$qb->andWhere('t.account = :account')->setParameter('account', $account);
}
public function findForStarred(UserInterface $user, int $page = 1, int $perPage = 50): array
{
$offset = ($page - 1) * $perPage;
+16 -7
View File
@@ -5,18 +5,27 @@
data-settings--account-expand-folders-url-value="{{ path('app_mail_account_folders', { account: account.id }) }}"
data-settings--account-expand-active-value="false">
{# The whole row toggles the label list — per-account "folders" are
labels now, so there is no INBOX landing link anymore. #}
<div class="flex items-center cursor-pointer rounded-lg hover:bg-hover transition-colors"
data-action="click->settings--account-expand#toggle">
<div class="flex items-center gap-2 px-3 py-2 flex-1 min-w-0 text-ink-muted">
{# Two targets, not one. The name opens the account — everything in it,
whatever it is labelled — and the chevron opens its label list. The
whole row used to toggle, which left no way to ask for the account
itself, and expanding was the only thing clicking an account could
do. #}
<div class="flex items-center rounded-lg hover:bg-hover transition-colors">
<a
href="{{ path('app_mail_account', { account: account.id }) }}"
data-ui--sidebar-target="link"
data-action="{{ _drawerClose }}"
class="nav-item flex items-center gap-2 px-3 py-2 flex-1 min-w-0 text-ink-muted"
>
<span class="w-2 h-2 rounded-full flex-shrink-0 {{ account.isActive ? 'bg-green-400' : 'bg-ink-faint' }}"></span>
<span class="truncate text-sm">{{ account.displayAddress }}</span>
</div>
</a>
<button
type="button"
aria-label="{{ 'sidebar.accounts.toggle'|trans }}"
class="p-2 rounded-lg text-gray-400 pointer-events-none"
title="{{ 'sidebar.accounts.toggle'|trans }}"
class="p-2 rounded-lg text-gray-400 cursor-pointer hover:text-ink"
data-action="click->settings--account-expand#toggle"
>
<i data-settings--account-expand-target="chevron"
class="fa-solid fa-chevron-right text-xs shrink-0 transition-transform duration-150"></i>
+15 -2
View File
@@ -186,6 +186,20 @@ the layout.
</span>
{{ macros.unread_badge(sidebarCounts.forRole(enum('App\\Domain\\Enum\\Mail\\LabelRole').Drafts), 'role:drafts') }}
</a>
{# 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. #}
<details class="rail-hide">
<summary class="nav-item flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer list-none transition-colors text-ink-muted">
<i class="fa-solid fa-chevron-right w-4 text-xs transition-transform" aria-hidden="true"></i>
<span>{{ 'sidebar.nav.more'|trans }}</span>
</summary>
{% if sidebarCounts.hasSnoozed %}
<a href="{{ path('app_mail_snoozed') }}"
class="nav-item flex items-center justify-between gap-2 px-3 py-2 rounded-lg transition-colors"
@@ -199,7 +213,6 @@ the layout.
{{ macros.unread_badge(sidebarCounts.forRole(enum('App\\Domain\\Enum\\Mail\\LabelRole').Snoozed), 'role:snoozed') }}
</a>
{% endif %}
{% if sidebarCounts.hasVisibleArchive %}
<a href="{{ path('app_mail_archive') }}"
class="nav-item flex items-center justify-between gap-2 px-3 py-2 rounded-lg transition-colors"
data-ui--sidebar-target="link"
@@ -211,7 +224,7 @@ the layout.
</span>
{{ macros.unread_badge(sidebarCounts.forRole(enum('App\\Domain\\Enum\\Mail\\LabelRole').Archive), 'role:archive') }}
</a>
{% endif %}
</details>
<a href="{{ path('app_mail_trash') }}"
class="nav-item flex items-center justify-between gap-2 px-3 py-2 rounded-lg transition-colors"
data-ui--sidebar-target="link"
+9 -3
View File
@@ -1,13 +1,19 @@
{# Replaces mail/_account_folders.html.twig — an account's navigation is its
labels now. System labels first (fixed sortOrder), custom labels after,
indented by tree depth. Every entry links to the generic label view, which
works for system labels too (an account's Inbox label = its inbox). #}
indented by tree depth.
Each entry carries `account`, which narrows the label view to this account.
Without it these linked to the plain label view, and since labels are
user-scoped and bind to several accounts at once, opening "Receipts" under
one account listed every account's Receipts — read as this account's, and it
was not. The sidebar's own label section still links without it, because
there the label genuinely does mean "across everything". #}
<turbo-frame id="account-folders-{{ account.id }}">
<nav class="pl-3 space-y-0.5">
{% for label in labels %}
<a
data-settings--account-expand-target="link"
href="{{ path('app_mail_label', { id: label.id }) }}"
href="{{ path('app_mail_label', { id: label.id, account: account.id }) }}"
data-turbo-frame="_top"
data-turbo-prefetch="false"
class="flex items-center justify-between px-3 py-1.5 rounded-lg text-xs transition-colors text-ink-muted hover:bg-hover"
+28
View File
@@ -0,0 +1,28 @@
{#
mail/account.html.twig — every thread in one account, whatever it is labelled.
Reached by clicking the account itself in the sidebar. Its labels sit
underneath it there and narrow this further, to that account's copy of the
label rather than the label across the whole mailbox.
#}
{% extends '_layout/_mailbox.html.twig' %}
{% block title %}{{ account.displayAddress }} — {{ 'app.name'|trans }}{% endblock %}
{% block inbox_tabs %}
<div class="px-4 py-2.5 border-b border-line">
<h2 class="text-sm font-semibold text-ink-soft">{{ account.displayAddress }}</h2>
</div>
{% endblock %}
{% block message_list %}
{% if threads is empty %}
<li class="px-4 py-8 text-center text-sm text-ink-faint">
{{ 'label.empty'|trans }}
</li>
{% else %}
{% for thread in threads %}
{% include '_partials/_thread_row.html.twig' with { thread: thread } %}
{% endfor %}
{% endif %}
{% endblock %}
+11 -1
View File
@@ -9,8 +9,18 @@ mail/label.html.twig — thread list for a custom label.
{% endblock %}
{% block inbox_tabs %}
<div class="px-4 py-2.5 border-b border-line">
<div class="px-4 py-2.5 border-b border-line flex items-center gap-2">
<h2 class="text-sm font-semibold text-ink-soft">{{ label.fullName }}</h2>
{# Narrowed to one account. Said out loud, and with a way back out:
the same label under a different account looks identical otherwise,
and an empty one is then indistinguishable from an empty label. #}
{% if account is defined and account %}
<span class="text-xs text-ink-faint">{{ account.displayAddress }}</span>
<a href="{{ path('app_mail_label', { id: label.id }) }}"
class="text-xs text-accent hover:underline">
{{ 'label.all_accounts'|trans }}
</a>
{% endif %}
</div>
{% endblock %}
+129
View File
@@ -0,0 +1,129 @@
import { test, expect } from "./support/test";
import { seed } from "./support/config";
/**
* Per-account navigation in the sidebar.
*
* Labels are user-scoped and bind to several accounts at once, so a label on
* its own means "across the whole mailbox". Under an account it is read as
* "this account's" — and it used to link to the unscoped view, listing every
* account's mail under a heading that said otherwise.
*/
test.beforeEach(() => {
seed("seed-mail");
});
test.describe("account scoping", () => {
const accountLink = (page: import("@playwright/test").Page) =>
page.locator('#sidebar a[href^="/mail/account/"]').first();
test("clicking an account shows that account rather than expanding it", async ({ page }) => {
await page.goto("/mail/inbox");
const link = accountLink(page);
await expect(link).toBeVisible();
const href = await link.getAttribute("href");
await link.click();
await page.waitForURL(new RegExp(href!.replace(/[/]/g, "\\/")));
await expect(page.locator("h2").first()).toHaveText("E2E Mailbox");
});
/**
* The chevron kept the old job. Clicking the row used to be the only thing
* an account could do, which left no way to ask for the account itself.
*/
test("the chevron still expands the account's labels", async ({ page }) => {
await page.goto("/mail/inbox");
await page.locator('#sidebar button[aria-label="Toggle account folders"]').first().click();
const labels = page.locator('#sidebar turbo-frame[id^="account-folders-"] a');
await expect(labels.first()).toBeVisible();
});
test("labels under an account are scoped to it", async ({ page }) => {
await page.goto("/mail/inbox");
await page.locator('#sidebar button[aria-label="Toggle account folders"]').first().click();
const scoped = page.locator('#sidebar turbo-frame a[href*="/mail/label/"]');
await expect(scoped.first()).toBeVisible();
// Every one of them, not just the first: a single unscoped entry is the
// bug all over again for whichever label it happens to be.
const hrefs = await scoped.evaluateAll((links) =>
links.map((link) => link.getAttribute("href") ?? ""),
);
expect(hrefs.length).toBeGreaterThan(0);
expect(hrefs.every((href) => href.includes("account="))).toBe(true);
});
test("an account-scoped label view says so, and offers the way back out", async ({ page }) => {
await page.goto("/mail/inbox");
await page.locator('#sidebar button[aria-label="Toggle account folders"]').first().click();
const scoped = page.locator('#sidebar turbo-frame a[href*="/mail/label/"]').first();
await expect(scoped).toBeVisible();
await scoped.click();
await page.waitForURL(/\/mail\/label\/\d+\?account=/);
const header = page.locator("h2").first().locator("..");
await expect(header).toContainText("E2E Mailbox");
// Back to the label across every account.
const allAccounts = header.getByRole("link");
await expect(allAccounts).toBeVisible();
await allAccounts.click();
await page.waitForURL(/\/mail\/label\/\d+$/);
});
/**
* `?account=` arrives off the query string, so it is checked rather than
* trusted — otherwise appending someone else's account id would scope a
* label you own to threads you do not.
*/
test("refuses an account the user does not own", async ({ page }) => {
await page.goto("/mail/inbox");
await page.locator('#sidebar button[aria-label="Toggle account folders"]').first().click();
const scoped = page.locator('#sidebar turbo-frame a[href*="/mail/label/"]').first();
await expect(scoped).toBeVisible();
const href = (await scoped.getAttribute("href"))!;
const foreign = href.replace(/account=\d+/, "account=999999");
const response = await page.request.get(foreign);
expect(response.status()).toBe(403);
});
});
test.describe("archive", () => {
/**
* The Archive route existed and nothing linked to it: the sidebar entry was
* gated on the user having switched the Archive label visible in settings,
* and it is created hidden.
*/
test("is reachable from the sidebar without touching label settings", async ({ page }) => {
await page.goto("/mail/inbox");
const more = page
.locator("#sidebar details > summary")
.filter({ hasText: /^\s*(More|Mehr)\s*$/ });
await expect(more).toBeVisible();
// Collapsed by default — it is not somewhere you go often.
await expect(page.locator('#sidebar a[href="/mail/archive"]')).toBeHidden();
await more.click();
const archive = page.locator('#sidebar a[href="/mail/archive"]');
await expect(archive).toBeVisible();
await archive.click();
await page.waitForURL(/\/mail\/archive/);
});
});
+2
View File
@@ -381,6 +381,7 @@ integration:
too_big: 'Zu groß zum Anhängen'
label:
all_accounts: Alle Konten
color:
amber: Bernstein
blue: Blau
@@ -1127,6 +1128,7 @@ sidebar:
archive: Archiv
drafts: Entwürfe
inbox: Posteingang
more: Mehr
sent: Gesendet
snoozed: Zurückgestellt
starred: Markiert
+2
View File
@@ -379,6 +379,7 @@ integration:
too_big: 'Too large to attach'
label:
all_accounts: All accounts
color:
amber: Amber
blue: Blue
@@ -1108,6 +1109,7 @@ sidebar:
archive: Archive
drafts: Drafts
inbox: Inbox
more: More
sent: Sent
snoozed: Snoozed
starred: Starred