diff --git a/src/Controller/Mail/MailController.php b/src/Controller/Mail/MailController.php index 7ff2199f..58c7f95d 100644 --- a/src/Controller/Mail/MailController.php +++ b/src/Controller/Mail/MailController.php @@ -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, diff --git a/src/Repository/Mail/MessageThreadRepository.php b/src/Repository/Mail/MessageThreadRepository.php index 4508db02..a4a27826 100644 --- a/src/Repository/Mail/MessageThreadRepository.php +++ b/src/Repository/Mail/MessageThreadRepository.php @@ -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; diff --git a/templates/_partials/_account_list.html.twig b/templates/_partials/_account_list.html.twig index c7054c4f..6df16dd8 100644 --- a/templates/_partials/_account_list.html.twig +++ b/templates/_partials/_account_list.html.twig @@ -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. #} -
-
+ {# 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. #} + +
+{% endblock %} + +{% block message_list %} + {% if threads is empty %} +
  • + {{ 'label.empty'|trans }} +
  • + {% else %} + {% for thread in threads %} + {% include '_partials/_thread_row.html.twig' with { thread: thread } %} + {% endfor %} + {% endif %} +{% endblock %} diff --git a/templates/mail/label.html.twig b/templates/mail/label.html.twig index 8ee1dec3..16185df2 100644 --- a/templates/mail/label.html.twig +++ b/templates/mail/label.html.twig @@ -9,8 +9,18 @@ mail/label.html.twig — thread list for a custom label. {% endblock %} {% block inbox_tabs %} -
    + {% endblock %} diff --git a/tests/e2e/account-scope.spec.ts b/tests/e2e/account-scope.spec.ts new file mode 100644 index 00000000..c601eba3 --- /dev/null +++ b/tests/e2e/account-scope.spec.ts @@ -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/); + }); +}); diff --git a/translations/messages.de.yaml b/translations/messages.de.yaml index d099dd85..545c956f 100644 --- a/translations/messages.de.yaml +++ b/translations/messages.de.yaml @@ -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 diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 476b3fc9..30a18158 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -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