feat(settings): let a reader make, rename and delete their own inbox tabs

A card on Settings -> General beside the sorting card: a list of the reader's
categories with rename and delete, and a form to add one. A name is trimmed,
at most 40 characters, not a built-in tab's name and not one the reader already
has (case-insensitively); a refused name comes back as a toast saying why.

Removing a category un-files its mail through the foreign keys and clears the
pins of everything in it first, in one transaction: a foreign key cannot clear a
pin, and a conversation pinned into a category that has gone would be skipped
by every recompute for ever. A pin to a built-in tab or to another category is
left alone. The filter list shows a category chip naming its category, or
"(deleted category)". The unused colour column is dropped from the new table.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
maniandClaude Sonnet 5.5 committed 2026-10-12 14:32:14 +02:00
1 parent 2a564c34b2
commit 7adccfb6f5
16 files changed
+945 -7

No files matched your search

-1
View File
@@ -45,7 +45,6 @@ final class Version20261012140000 extends AbstractMigration
id SERIAL NOT NULL,
usr_id INT NOT NULL,
name VARCHAR(40) NOT NULL,
color VARCHAR(30) DEFAULT NULL,
position INT DEFAULT 0 NOT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Controller\Settings;
use App\Controller\ChecksCsrf;
use App\Domain\Exception\InvalidInboxCategoryException;
use App\Entity\Mail\InboxCategory;
use App\Entity\User\User;
use App\Security\Voter\OwnershipVoter;
use App\Service\Mail\InboxCategoryManager;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* The inbox tabs a reader makes themselves — Settings → General.
*
* Plain form posts that redirect back to the page, like the sorting card beside
* it: three small writes on a short list do not need a frame of their own, and
* a refused name comes back as a toast saying why rather than as a half-redrawn
* card. Every write is the reader's own — the id in the URL is checked against
* the owner before anything is changed.
*
* What fills a tab is not here. A category is only a name; mail goes into it
* through a filter's "File in category" action (Settings → Filters).
*/
#[Route('/settings/categories', name: 'app_settings_categories_')]
#[IsGranted('ROLE_USER')]
final class InboxCategoryController extends AbstractController
{
use ChecksCsrf;
private const string CSRF = 'settings-categories';
public function __construct(
private readonly InboxCategoryManager $manager,
private readonly TranslatorInterface $translator,
) {
}
#[Route('', name: 'create', methods: ['POST'])]
public function create(Request $request): Response
{
$this->assertCsrf($request, self::CSRF);
/** @var User $user */
$user = $this->getUser();
try {
$category = $this->manager->create($user, (string) $request->request->get('name', ''));
} catch (InvalidInboxCategoryException $e) {
$this->addFlash('error', $this->translator->trans($e->translationKey));
return $this->back();
}
$this->addFlash('success', $this->translator->trans('settings.categories.created', ['%name%' => $category->name]));
return $this->back();
}
#[Route('/{id}/rename', name: 'rename', methods: ['POST'], requirements: ['id' => '\d+'])]
public function rename(Request $request, InboxCategory $category): Response
{
$this->denyAccessUnlessGranted(OwnershipVoter::OWN, $category);
$this->assertCsrf($request, self::CSRF);
try {
$this->manager->rename($category, (string) $request->request->get('name', ''));
} catch (InvalidInboxCategoryException $e) {
$this->addFlash('error', $this->translator->trans($e->translationKey));
return $this->back();
}
$this->addFlash('success', $this->translator->trans('settings.categories.renamed', ['%name%' => $category->name]));
return $this->back();
}
#[Route('/{id}/delete', name: 'delete', methods: ['POST'], requirements: ['id' => '\d+'])]
public function delete(Request $request, InboxCategory $category): Response
{
$this->denyAccessUnlessGranted(OwnershipVoter::OWN, $category);
$this->assertCsrf($request, self::CSRF);
$name = $category->name;
$this->manager->remove($category);
$this->addFlash('success', $this->translator->trans('settings.categories.deleted', ['%name%' => $name]));
return $this->back();
}
private function back(): Response
{
return $this->redirectToRoute('app_settings_index', ['section' => 'general']);
}
}
@@ -22,6 +22,7 @@ use App\Repository\Calendar\BookingPageRepository;
use App\Repository\Calendar\CalendarRepository;
use App\Repository\Calendar\CalendarShareLinkRepository;
use App\Repository\Mail\AccountRepository;
use App\Repository\Mail\InboxCategoryRepository;
use App\Repository\Push\PushDeliveryRepository;
use App\Repository\User\ApiTokenRepository;
use App\Repository\User\PushSubscriptionRepository;
@@ -87,6 +88,7 @@ final class SettingsController extends AbstractController
private readonly CalendarShareLinkRepository $shareLinkRepository,
private readonly BookingPageRepository $bookingPageRepository,
private readonly MailRuleRepository $mailRuleRepository,
private readonly InboxCategoryRepository $inboxCategoryRepository,
private readonly PushSubscriptionRegistry $pushSubscriptionRegistry,
private readonly PushSubscriptionRepository $pushSubscriptions,
private readonly PushDeliveryRepository $pushDeliveries,
@@ -155,6 +157,11 @@ final class SettingsController extends AbstractController
'labels' => $this->labelRepository->findForUserTreeOrdered($this->getUser()),
'calendars' => $this->calendarRepository->findForUser($this->getUser()),
'rules' => $this->mailRuleRepository->findForUserOrdered($this->getUser()),
// Only the General section draws them, and every other settings page
// would otherwise pay a read for a list it never shows.
'inboxCategories' => 'general' === $section
? $this->inboxCategoryRepository->findForUserOrdered($this->getUser())
: [],
'apiTokens' => $this->apiTokenRepository->findForUser($this->getUser()),
'apiTokenForm' => $this->createForm(ApiTokenType::class)->createView(),
// A refused profile save forwards here with its form, so the page
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Domain\Exception;
/**
* A name somebody typed for an inbox category cannot be used.
*
* Carries a translation key rather than a sentence, so the controller that
* catches it can say why in the reader's own language without this class
* knowing there is a translator.
*/
final class InvalidInboxCategoryException extends \DomainException
{
public function __construct(public readonly string $translationKey)
{
parent::__construct($translationKey);
}
}
-6
View File
@@ -55,12 +55,6 @@ class InboxCategory
#[ORM\Column(length: self::NAME_MAX_LENGTH)]
public ?string $name = null;
/**
* A palette token, the way Label::$color is. Null takes the neutral tone.
*/
#[ORM\Column(length: 30, nullable: true)]
public ?string $color = null;
/** Left to right among the user's own tabs; ties break on id. */
#[ORM\Column(options: ['default' => 0])]
public int $position = 0;
@@ -19,6 +19,25 @@ class InboxCategoryRepository extends ServiceEntityRepository
parent::__construct($registry, InboxCategory::class);
}
/**
* Where a new category goes: after every one the reader already has.
*
* One past the largest position rather than a count, because removing a
* category leaves a gap and a count would then collide with the tab at the
* end of the strip.
*/
public function nextPositionFor(UserInterface $user): int
{
$largest = $this->createQueryBuilder('c')
->select('MAX(c.position)')
->where('c.usr = :user')
->setParameter('user', $user)
->getQuery()
->getSingleScalarResult();
return null === $largest ? 0 : (int) $largest + 1;
}
/**
* Every category the user made, in the order their tabs are drawn.
*
+123
View File
@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Service\Mail;
use App\Domain\Enum\Mail\MessageCategory;
use App\Domain\Exception\InvalidInboxCategoryException;
use App\Entity\Mail\InboxCategory;
use App\Entity\User\User;
use App\Repository\Mail\InboxCategoryRepository;
use Doctrine\ORM\EntityManagerInterface;
/**
* Making, renaming and removing the inbox tabs a reader made themselves.
*
* NAMES ARE CHECKED HERE, NOT BY A CONSTRAINT. Per reader and without regard to
* case, which an index cannot say in Doctrine attributes (see InboxCategory),
* and a built-in tab's name is refused outright: a strip with two tabs both
* called Promotions, or one a reader typed beside the real one, is a strip
* nobody can trust. The built-ins are refused by their English names — what the
* enum stores — and not by their translated labels, which differ per language
* and are the reader's to confuse themselves with.
*
* REMOVAL DOES WHAT THE DATABASE CANNOT. The two columns that file mail under a
* category are ON DELETE SET NULL, so the foreign keys un-file the mail and the
* conversations on their own. They cannot clear a PIN. A conversation the reader
* dragged into the category carries MessageThread::$categoryPinnedAt, every
* recompute skips a pinned thread, and once its category was gone it would
* stay pinned to nothing for ever — filed in no tab of the reader's, never
* resolved again, and nothing on screen to say why. So the pins of everything
* in the category are cleared first, in the same transaction.
*
* A thread pinned to a BUILT-IN tab, or to another of the reader's categories,
* is not touched: it was never pinned to this one.
*/
final readonly class InboxCategoryManager
{
public function __construct(
private InboxCategoryRepository $categories,
private EntityManagerInterface $em,
) {
}
/**
* @throws InvalidInboxCategoryException
*/
public function create(User $owner, string $name): InboxCategory
{
$category = new InboxCategory();
$category->usr = $owner;
$category->name = $this->validName($owner, $name, null);
$category->position = $this->categories->nextPositionFor($owner);
$this->em->persist($category);
$this->em->flush();
return $category;
}
/**
* @throws InvalidInboxCategoryException
*/
public function rename(InboxCategory $category, string $name): void
{
$owner = $category->usr;
if (null === $owner) {
throw new \LogicException('A category with no owner cannot be renamed.');
}
$category->name = $this->validName($owner, $name, $category);
$this->em->flush();
}
/**
* Remove the category, un-file everything in it and free the pins into it.
*/
public function remove(InboxCategory $category): void
{
$this->em->wrapInTransaction(function () use ($category): void {
$this->em->getConnection()->executeStatement(
'UPDATE message_thread SET category_pinned_at = NULL WHERE user_category_id = :id',
['id' => $category->id],
);
$this->em->remove($category);
$this->em->flush();
});
}
private function validName(User $owner, string $name, ?InboxCategory $renaming): string
{
// Unicode whitespace both ends: a name pasted with a non-breaking space
// would otherwise be a tab nobody can tell from its neighbour.
$name = (string) preg_replace('/^\s+|\s+$/u', '', $name);
if ('' === $name) {
throw new InvalidInboxCategoryException('settings.categories.error.empty');
}
if (mb_strlen($name) > InboxCategory::NAME_MAX_LENGTH) {
throw new InvalidInboxCategoryException('settings.categories.error.too_long');
}
if (null !== MessageCategory::tryFrom(mb_strtolower($name))) {
throw new InvalidInboxCategoryException('settings.categories.error.reserved');
}
foreach ($this->categories->findForUserOrdered($owner) as $existing) {
if ($existing === $renaming) {
continue;
}
if (mb_strtolower((string) $existing->name) === mb_strtolower($name)) {
throw new InvalidInboxCategoryException('settings.categories.error.taken');
}
}
return $name;
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Twig;
use App\Entity\User\User;
use App\Service\Mail\InboxTabs;
use Symfony\Bundle\SecurityBundle\Security;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
/**
* The names of the signed-in reader's own inbox categories, for a template that
* has an id and wants a word.
*
* A function rather than one more variable on every controller that renders the
* filter list: that partial is drawn from three places, and a category chip is
* the only thing in it that needs the names. Answered from InboxTabs, which
* reads them once per request.
*
* Keyed `id12`, not `12`, for the reason the label map beside it is: Twig's
* merge follows array_merge, which RENUMBERS integer keys, and a map that quietly
* became positional would name the wrong category.
*/
final class InboxCategoryExtension extends AbstractExtension
{
public function __construct(
private readonly InboxTabs $tabs,
private readonly Security $security,
) {
}
public function getFunctions(): array
{
return [
new TwigFunction('inbox_category_names', $this->names(...)),
];
}
/**
* @return array<string,string> 'id<categoryId>' => name; empty when nobody is signed in
*/
public function names(): array
{
$user = $this->security->getUser();
if (false === $user instanceof User) {
return [];
}
$names = [];
foreach ($this->tabs->customFor($user) as $tab) {
$names['id' . $tab->categoryId] = (string) $tab->name;
}
return $names;
}
}
+8
View File
@@ -27,6 +27,10 @@ Variables:
{% set integrationNames = integrationNames|merge({ ('id' ~ integration.id): integration.name }) %}
{% endfor %}
{# id => name for the reader's own categories, the same shape and for the same
reason as the two maps above. #}
{% set categoryNames = inbox_category_names() %}
<div data-controller="rules--rule-run" data-rules--rule-run-busy-value="{{ anyRunning ? 'true' : 'false' }}"></div>
<div class="flex items-center justify-between gap-3 px-4 py-3 border-b border-line bg-sunken">
@@ -125,11 +129,15 @@ Variables:
'trash': 'fa-trash',
'markSpam': 'fa-ban',
'saveToIntegration': 'fa-cloud-arrow-up',
'fileInCategory': 'fa-folder',
}[action.type] ?? 'fa-bolt' }} text-[9px]" aria-hidden="true"></i>
{{ ('settings.filters.action.' ~ action.type)|trans }}
{%- if action.labelId is defined and labelNames['id' ~ action.labelId] is defined %}
<span class="font-medium" data-action-label>{{ labelNames['id' ~ action.labelId] }}</span>
{%- endif %}
{%- if action.categoryId is defined %}
<span class="font-medium">{{ categoryNames['id' ~ action.categoryId] ?? 'settings.filters.category_missing'|trans }}</span>
{%- endif %}
{%- if action.integrationId is defined %}
<span class="font-medium">{{ integrationNames['id' ~ action.integrationId] ?? 'settings.filters.integration_missing'|trans }}</span>
{%- endif %}
@@ -0,0 +1,69 @@
{#
settings/_inbox_categories.html.twig
─────────────────────────────────────────────────────────────────────────────
The inbox tabs this person made, beside the five that come with plMail.
A category is only a name. What goes into it is decided by a filter's "File in
category" action, so the card says so and links there — a tab with nothing
filing into it stays out of the strip, and a card that did not explain that
would look broken the first time somebody made one.
Plain posts that redirect back, like the sorting card above: three small writes
on a short list.
Accepts:
categories InboxCategory[] in the order their tabs are drawn
#}
<div class="px-4 py-3">
<p class="text-[11px] text-ink-faint">
{{ 'settings.categories.hint'|trans }}
<a href="{{ path('app_settings_index', { section: 'filters' }) }}" class="text-accent hover:underline">{{ 'settings.categories.filters_link'|trans }}</a>
</p>
{% if categories is empty %}
<p class="mt-3 text-xs text-ink-faint" data-inbox-categories-empty>{{ 'settings.categories.empty'|trans }}</p>
{% else %}
<ul class="mt-3 divide-y divide-line rounded-pane border border-line" data-inbox-categories>
{% for category in categories %}
<li class="flex items-center gap-2 px-3 py-2">
<form method="post" action="{{ path('app_settings_categories_rename', { id: category.id }) }}"
data-turbo="false" class="flex min-w-0 flex-1 items-center gap-2">
<input type="hidden" name="_token" value="{{ csrf_token('settings-categories') }}">
<input type="text" name="name" value="{{ category.name }}" required maxlength="40"
aria-label="{{ 'settings.categories.name_label'|trans }}"
class="min-w-0 flex-1 h-8 rounded-pane border border-line bg-surface px-2 text-sm text-ink">
<button type="submit"
class="shrink-0 h-8 px-2.5 rounded-pane border border-line text-xs text-ink-muted hover:text-ink hover:bg-hover cursor-pointer transition-colors">
{{ 'settings.categories.rename'|trans }}
</button>
</form>
<form method="post" action="{{ path('app_settings_categories_delete', { id: category.id }) }}"
data-turbo="false"
data-turbo-confirm="{{ 'settings.categories.delete_confirm'|trans({ '%name%': category.name }) }}">
<input type="hidden" name="_token" value="{{ csrf_token('settings-categories') }}">
<button type="submit"
class="shrink-0 w-8 h-8 flex items-center justify-center rounded-lg text-ink-faint hover:text-danger hover:bg-hover cursor-pointer transition-colors"
aria-label="{{ 'settings.categories.delete'|trans }}"
title="{{ 'settings.categories.delete'|trans }}">
<i class="fa-solid fa-trash text-xs" aria-hidden="true"></i>
</button>
</form>
</li>
{% endfor %}
</ul>
{% endif %}
<form method="post" action="{{ path('app_settings_categories_create') }}" data-turbo="false" class="mt-3 flex items-center gap-2">
<input type="hidden" name="_token" value="{{ csrf_token('settings-categories') }}">
<input type="text" name="name" required maxlength="40"
placeholder="{{ 'settings.categories.placeholder'|trans }}"
aria-label="{{ 'settings.categories.name_label'|trans }}"
class="min-w-0 flex-1 h-8 rounded-pane border border-line bg-surface px-2 text-sm text-ink">
<button type="submit"
class="shrink-0 inline-flex items-center gap-1.5 px-2.5 h-8 rounded-lg text-xs font-medium bg-accent hover:bg-accent-strong text-accent-ink transition-colors cursor-pointer">
<i class="fa-solid fa-plus text-[10px]" aria-hidden="true"></i>
{{ 'settings.categories.add'|trans }}
</button>
</form>
</div>
+12
View File
@@ -411,6 +411,18 @@
} %}
{% endblock %}
{% endembed %}
{# The tabs this person names themselves — what sorting
above cannot give them. Filled by a filter's "File in
category" action, which the card says. #}
{% embed 'settings/_card.html.twig' with {
heading: 'settings.categories.heading'|trans,
subheading: 'settings.categories.subheading'|trans,
} %}
{% block body %}
{% include 'settings/_inbox_categories.html.twig' with { categories: inboxCategories } %}
{% endblock %}
{% endembed %}
</div>
{% elseif section == 'reading-pane' %}
{% embed 'settings/_card.html.twig' with {
@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Settings;
use App\Entity\Mail\InboxCategory;
use App\Entity\Rule\MailRule;
use App\Entity\User\User;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* The card where a reader makes their own tabs, from the browser's end.
*
* What is pinned: the card is there and works end to end through the real
* forms; a refused name says why and changes nothing; and every write is the
* reader's OWN — another reader's category id in the URL is refused, and leaves
* their category exactly as it was.
*/
final class InboxCategoryControllerTest extends WebTestCase
{
private EntityManagerInterface $em;
private Connection $connection;
private User $user;
protected function tearDown(): void
{
if (isset($this->connection) && true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
parent::tearDown();
}
public function testTheCardIsOnTheGeneralPageAndListsTheReadersCategories(): void
{
$client = $this->signIn();
$this->category($this->user, 'Shipping');
$crawler = $client->request('GET', '/settings?section=general');
self::assertResponseIsSuccessful();
self::assertStringContainsString('Your own tabs', $crawler->text());
self::assertSame('Shipping', $crawler->filter('[data-inbox-categories] input[name="name"]')->first()->attr('value'));
}
public function testACategoryIsCreatedThroughTheRealForm(): void
{
$client = $this->signIn();
$crawler = $client->request('GET', '/settings?section=general');
$form = $crawler->filter('form[action$="/settings/categories"]')->form(['name' => ' Shipping ']);
$client->submit($form);
self::assertResponseRedirects('/settings?section=general');
$rows = $this->connection->fetchFirstColumn('SELECT name FROM inbox_category WHERE usr_id = :id', ['id' => $this->user->id]);
self::assertSame(['Shipping'], $rows);
$client->followRedirect();
// Escaped, because it is read back out of the page's markup.
self::assertStringContainsString('Created &quot;Shipping&quot;', (string) $client->getResponse()->getContent());
}
public function testARefusedNameSaysWhyAndChangesNothing(): void
{
$client = $this->signIn();
$this->category($this->user, 'Shipping');
$crawler = $client->request('GET', '/settings?section=general');
$client->submit($crawler->filter('form[action$="/settings/categories"]')->form(['name' => 'shipping']));
self::assertResponseRedirects('/settings?section=general');
$client->followRedirect();
self::assertStringContainsString('You already have a category with that name', (string) $client->getResponse()->getContent());
self::assertSame(
1,
(int) $this->connection->fetchOne('SELECT COUNT(*) FROM inbox_category WHERE usr_id = :id', ['id' => $this->user->id]),
);
}
public function testACategoryIsRenamedAndDeleted(): void
{
$client = $this->signIn();
$shipping = $this->category($this->user, 'Shipping');
$crawler = $client->request('GET', '/settings?section=general');
$client->submit($crawler->filter('form[action$="/settings/categories/' . $shipping->id . '/rename"]')->form(['name' => 'Parcels']));
self::assertResponseRedirects();
self::assertSame('Parcels', $this->connection->fetchOne('SELECT name FROM inbox_category WHERE id = :id', ['id' => $shipping->id]));
$crawler = $client->request('GET', '/settings?section=general');
$client->submit($crawler->filter('form[action$="/settings/categories/' . $shipping->id . '/delete"]')->form());
self::assertResponseRedirects();
self::assertSame(0, (int) $this->connection->fetchOne('SELECT COUNT(*) FROM inbox_category WHERE id = :id', ['id' => $shipping->id]));
}
/**
* The id in the URL is the reader's input and ownership is the only thing
* between it and somebody else's tab. Both writes, and the category must be
* exactly as it was — name, and still there.
*/
public function testSomebodyElsesCategoryCanNeitherBeRenamedNorDeleted(): void
{
$client = $this->signIn();
$theirs = $this->category($this->seedUser('stranger'), 'Theirs');
$token = $this->token($client);
$client->request('POST', '/settings/categories/' . $theirs->id . '/rename', ['_token' => $token, 'name' => 'Mine now']);
self::assertResponseStatusCodeSame(403);
$client->request('POST', '/settings/categories/' . $theirs->id . '/delete', ['_token' => $token]);
self::assertResponseStatusCodeSame(403);
self::assertSame('Theirs', $this->connection->fetchOne('SELECT name FROM inbox_category WHERE id = :id', ['id' => $theirs->id]));
}
public function testAWriteWithoutTheTokenIsRefused(): void
{
$client = $this->signIn();
$client->request('GET', '/settings?section=general');
$client->request('POST', '/settings/categories', ['name' => 'Sneaky']);
self::assertResponseStatusCodeSame(403);
self::assertSame(0, (int) $this->connection->fetchOne('SELECT COUNT(*) FROM inbox_category WHERE usr_id = :id', ['id' => $this->user->id]));
}
/**
* The filter list is where a reader checks what a filter files into, so its
* chip names the category — and says so when the category has gone, the way
* it does for a deleted label.
*/
public function testAFilterChipNamesItsCategoryAndSaysWhenItIsGone(): void
{
$client = $this->signIn();
$shipping = $this->category($this->user, 'Shipping');
$rule = new MailRule();
$rule->usr = $this->user;
$rule->name = 'Carriers';
$rule->actions = [['type' => 'fileInCategory', 'categoryId' => $shipping->id]];
$this->em->persist($rule);
$this->em->flush();
$client->request('GET', '/settings?section=filters');
self::assertResponseIsSuccessful();
self::assertStringContainsString('Shipping', (string) $client->getResponse()->getContent());
$this->connection->executeStatement('DELETE FROM inbox_category WHERE id = :id', ['id' => $shipping->id]);
$client->request('GET', '/settings?section=filters');
self::assertResponseIsSuccessful();
self::assertStringContainsString('(deleted category)', (string) $client->getResponse()->getContent());
}
// ── fixtures ─────────────────────────────────────────────────────────────
private function token(KernelBrowser $client): string
{
$crawler = $client->request('GET', '/settings?section=general');
return (string) $crawler->filter('input[name="_token"][value]')->last()->attr('value');
}
private function category(User $owner, string $name): InboxCategory
{
$category = new InboxCategory();
$category->usr = $owner;
$category->name = $name;
$this->em->persist($category);
$this->em->flush();
return $category;
}
private function signIn(): KernelBrowser
{
$client = static::createClient();
$client->disableReboot();
$container = static::getContainer();
$this->em = $container->get(EntityManagerInterface::class);
$this->connection = $container->get(Connection::class);
$this->connection->beginTransaction();
$this->user = $this->seedUser('reader');
$client->loginUser($this->user);
return $client;
}
private function seedUser(string $tag): User
{
$user = new User();
$user->email = 'categories-' . $tag . '-' . uniqid('', true) . '@example.test';
$user->nameFirst = 'Category';
$user->nameLast = 'Card';
$user->roles = ['ROLE_USER'];
$user->password = '$2y$04$abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOP';
$this->em->persist($user);
$this->em->flush();
return $user;
}
}
@@ -0,0 +1,248 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Mail;
use App\Domain\Enum\Mail\LabelRole;
use App\Domain\Enum\Mail\MessageCategory;
use App\Domain\Exception\InvalidInboxCategoryException;
use App\Entity\Mail\MessageThread;
use App\Repository\Mail\InboxCategoryRepository;
use App\Repository\Mail\MessageThreadRepository;
use App\Service\Mail\InboxCategoryManager;
use App\Tests\Support\Mail\SeedsMarkerFixtures;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* Making, renaming and removing the tabs a reader made.
*
* Two things are worth the effort here. The names, because a tab strip with two
* tabs called the same thing, or one called "Primary" beside the real one, is a
* strip nobody can trust. And removal, because it is the one operation that has
* to un-file mail the database cannot un-file on its own: a foreign key can null
* a pointer, but it cannot clear a PIN, and a pinned conversation whose
* category has gone would be skipped by every recompute for ever — a
* conversation stuck in a tab that no longer exists, undetectable from outside.
*/
final class InboxCategoryManagerTest extends KernelTestCase
{
use SeedsMarkerFixtures;
private Connection $connection;
private InboxCategoryManager $manager;
protected function setUp(): void
{
self::bootKernel();
$container = self::getContainer();
$this->em = $container->get(EntityManagerInterface::class);
$this->connection = $container->get(Connection::class);
// Built by hand rather than fetched: a service with one consumer is
// inlined into it when the container is compiled, and cannot be asked for.
$this->manager = new InboxCategoryManager($container->get(InboxCategoryRepository::class), $this->em);
$this->connection->beginTransaction();
$this->user = $this->seedUser();
$this->account = $this->seedAccount();
$this->inbox = $this->seedLabel('Inbox', LabelRole::Inbox);
}
protected function tearDown(): void
{
if (true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
parent::tearDown();
}
public function testACategoryIsTrimmedAndAppendedAfterTheOnesThereAlready(): void
{
$first = $this->manager->create($this->user, ' Shipping ');
$second = $this->manager->create($this->user, 'Receipts');
self::assertNotNull($first->id);
self::assertSame('Shipping', $first->name);
self::assertSame(0, $first->position);
self::assertSame(1, $second->position, 'a new tab did not go to the end of the strip');
}
/**
* @return iterable<string, array{string, string}>
*/
public static function refusedNames(): iterable
{
yield 'nothing' => ['', 'settings.categories.error.empty'];
yield 'only spaces' => [" \t ", 'settings.categories.error.empty'];
yield 'forty-one characters' => [str_repeat('x', 41), 'settings.categories.error.too_long'];
yield 'a built-in name' => ['Primary', 'settings.categories.error.reserved'];
yield 'a built-in in capitals' => ['FORUMS', 'settings.categories.error.reserved'];
yield 'one the reader has' => ['shipping', 'settings.categories.error.taken'];
}
#[DataProvider('refusedNames')]
public function testANameThatWouldMakeTheStripAmbiguousIsRefused(string $name, string $expectedKey): void
{
$this->manager->create($this->user, 'Shipping');
try {
$this->manager->create($this->user, $name);
self::fail('accepted ' . json_encode($name));
} catch (InvalidInboxCategoryException $e) {
self::assertSame($expectedKey, $e->translationKey);
}
}
/**
* The presence companion for every refusal above: the same call with an
* acceptable name works, on the same fixture, so "refuses everything" cannot
* pass for "refuses what it should".
*/
public function testNamesThatAreFineAreAccepted(): void
{
$this->manager->create($this->user, 'Shipping');
self::assertSame(str_repeat('x', 40), $this->manager->create($this->user, str_repeat('x', 40))->name);
// Another reader's tab is none of this reader's business.
$other = $this->seedUser();
self::assertSame('Shipping', $this->manager->create($other, 'Shipping')->name);
}
public function testRenamingKeepsTheCategoryAndMayChangeOnlyItsCase(): void
{
$shipping = $this->manager->create($this->user, 'Shipping');
$this->manager->create($this->user, 'Receipts');
$this->manager->rename($shipping, 'SHIPPING');
self::assertSame('SHIPPING', $shipping->name, 'a category could not be renamed to a different case of itself');
$this->manager->rename($shipping, 'Parcels');
self::assertSame('Parcels', $shipping->name);
$this->expectException(InvalidInboxCategoryException::class);
$this->manager->rename($shipping, 'receipts');
}
/**
* Fixture, derived by hand. The category C is removed; D stays.
*
* thread built-in own tab pinned message verdict
* draggedIn primary C yes - (pinned INTO C)
* filtered promotions C no C (filed by a filter)
* draggedElsewhere primary - yes - (pinned to a built-in tab)
* inOther promotions D yes D (a different category)
*
* After removing C:
* draggedIn : own tab gone, PIN GONE (or no recompute would ever move it)
* filtered : own tab gone, message verdict gone
* draggedElsewhere : untouched, pin kept — it was never pinned to C
* inOther : untouched, pin kept
* and no mail was deleted.
*/
public function testRemovingACategoryUnfilesItsMailAndFreesItsPinsAndNothingElse(): void
{
$c = $this->manager->create($this->user, 'Shipping');
$d = $this->manager->create($this->user, 'Receipts');
$draggedIn = $this->thread('dragged in', MessageCategory::Promotions, unread: 1);
$draggedIn->category = MessageCategory::Primary;
$draggedIn->userCategory = $c;
$draggedIn->categoryPinnedAt = new \DateTimeImmutable('2026-07-28 10:00:00');
$filtered = $this->thread('filtered', MessageCategory::Promotions, unread: 1);
$filtered->userCategory = $c;
$filtered->messages->first()->ruleCategory = $c;
$draggedElsewhere = $this->thread('dragged elsewhere', MessageCategory::Promotions, unread: 1);
$draggedElsewhere->category = MessageCategory::Primary;
$draggedElsewhere->categoryPinnedAt = new \DateTimeImmutable('2026-07-28 10:00:00');
$inOther = $this->thread('in other', MessageCategory::Promotions, unread: 1);
$inOther->userCategory = $d;
$inOther->categoryPinnedAt = new \DateTimeImmutable('2026-07-28 10:00:00');
$inOther->messages->first()->ruleCategory = $d;
$this->em->flush();
$this->manager->remove($c);
self::assertSame(0, $this->rows('inbox_category', 'id = ' . (int) $c->id), 'the category is still there');
self::assertSame(1, $this->rows('inbox_category', 'id = ' . (int) $d->id), 'another category went with it');
self::assertNull($this->threadColumn($draggedIn, 'user_category_id'));
self::assertNull($this->threadColumn($draggedIn, 'category_pinned_at'), 'a conversation pinned into the removed category is still pinned, so no recompute will ever move it');
self::assertNull($this->threadColumn($filtered, 'user_category_id'));
self::assertNull($this->messageColumn($filtered, 'rule_category_id'), 'a message is still filed under a category that is gone');
self::assertNotNull($this->threadColumn($draggedElsewhere, 'category_pinned_at'), 'a pin to a built-in tab was cleared');
self::assertSame('primary', $this->threadColumn($draggedElsewhere, 'category'));
self::assertSame($d->id, (int) $this->threadColumn($inOther, 'user_category_id'));
self::assertNotNull($this->threadColumn($inOther, 'category_pinned_at'));
self::assertSame($d->id, (int) $this->messageColumn($inOther, 'rule_category_id'));
self::assertSame(4, $this->rows('message_thread', 'account_id = ' . (int) $this->account->id), 'removing a category removed a conversation');
self::assertSame(4, $this->rows('message', 'account_id = ' . (int) $this->account->id), 'removing a category removed mail');
}
/**
* Why the pin has to go, shown rather than argued: once it is gone the
* conversation is resolved from its messages again and lands in the
* built-in tab its newest message belongs in. Pinned, the same statement
* would have skipped it.
*/
public function testAConversationPinnedIntoARemovedCategoryIsResolvedAgainAfterwards(): void
{
$c = $this->manager->create($this->user, 'Shipping');
$thread = $this->thread('dragged in', MessageCategory::Promotions, unread: 1);
$thread->messages->first()->category = MessageCategory::Promotions;
$thread->category = MessageCategory::Primary;
$thread->userCategory = $c;
$thread->categoryPinnedAt = new \DateTimeImmutable('2026-07-28 10:00:00');
$this->em->flush();
$this->manager->remove($c);
self::getContainer()->get(MessageThreadRepository::class)->recomputeCategoriesForThreads([(int) $thread->id]);
self::assertSame(
'promotions',
$this->threadColumn($thread, 'category'),
'the conversation stayed where the removed pin had put it',
);
}
// ── fixture ──────────────────────────────────────────────────────────────
private function rows(string $table, string $where): int
{
return (int) $this->connection->fetchOne(sprintf('SELECT COUNT(*) FROM %s WHERE %s', $table, $where));
}
private function threadColumn(MessageThread $thread, string $column): mixed
{
$value = $this->connection->fetchOne(sprintf('SELECT %s FROM message_thread WHERE id = :id', $column), ['id' => $thread->id]);
return false === $value ? null : $value;
}
private function messageColumn(MessageThread $thread, string $column): mixed
{
$value = $this->connection->fetchOne(
sprintf('SELECT %s FROM message WHERE thread_id = :id ORDER BY id LIMIT 1', $column),
['id' => $thread->id],
);
return false === $value ? null : $value;
}
}
+20
View File
@@ -3920,6 +3920,26 @@ settings:
save: 'Signatur speichern'
saved: 'Gespeichert.'
subheading: 'Was am Ende der Nachrichten steht, die du schreibst.'
categories:
add: Hinzufügen
created: 'Kategorie „%name%“ angelegt.'
delete: Kategorie löschen
delete_confirm: 'Kategorie „%name%“ löschen? Die dort abgelegte Mail kommt zurück in den Tab, in dem sie sonst wäre. Es wird keine Mail gelöscht.'
deleted: 'Kategorie „%name%“ gelöscht.'
empty: 'Du hast noch keine eigenen Tabs.'
error:
empty: 'Eine Kategorie braucht einen Namen.'
reserved: 'Dieser Name gehört zu einem der eingebauten Tabs.'
taken: 'Du hast bereits eine Kategorie mit diesem Namen.'
too_long: 'Dieser Name ist zu lang (höchstens 40 Zeichen).'
filters_link: 'Einstellungen → Filter.'
heading: Deine eigenen Tabs
hint: 'Ein Tab erscheint im Posteingang, solange er Mail enthält. Mail kommt über einen Filter mit der Aktion „In Kategorie ablegen“ hinein — richte einen ein unter'
name_label: Name
placeholder: 'z. B. Sendungen'
rename: Umbenennen
renamed: 'Umbenannt in „%name%“.'
subheading: 'Posteingangs-Tabs, die du selbst benennst, neben den fünf, die plMail mitbringt.'
sorting:
again:
button: Nochmal fragen
+20
View File
@@ -3901,6 +3901,26 @@ settings:
save: 'Save signature'
saved: 'Saved.'
subheading: 'What is added to the bottom of the messages you write.'
categories:
add: Add
created: 'Created "%name%".'
delete: Delete category
delete_confirm: 'Delete "%name%"? Mail filed there goes back to the tab it would otherwise be in. No mail is deleted.'
deleted: 'Deleted "%name%".'
empty: 'You have no tabs of your own yet.'
error:
empty: 'A category needs a name.'
reserved: 'That name belongs to one of the built-in tabs.'
taken: 'You already have a category with that name.'
too_long: 'That name is too long (40 characters at most).'
filters_link: 'Settings → Filters.'
heading: Your own tabs
hint: 'A tab shows in the inbox while it holds mail. Mail goes into it through a filter whose action is "File in category" — set one up under'
name_label: Name
placeholder: 'e.g. Shipping'
rename: Rename
renamed: 'Renamed to "%name%".'
subheading: 'Inbox tabs you name yourself, beside the five that come with plMail.'
sorting:
again:
button: Ask again
+20
View File
@@ -3703,6 +3703,26 @@ settings:
save: 'Set down yer mark'
saved: 'Set down.'
subheading: 'What be carved at the foot o'' the scrolls ye write.'
categories:
add: Add
created: 'Built the hold "%name%".'
delete: Scuttle the hold
delete_confirm: 'Scuttle "%name%"? Post stowed there goes back to the hold it would be in otherwise. No post goes over the side.'
deleted: 'Scuttled "%name%".'
empty: 'Ye have no holds o'' yer own yet.'
error:
empty: 'A hold needs a name.'
reserved: 'That name belongs to one o'' the holds the ship came with.'
taken: 'Ye already have a hold by that name.'
too_long: 'That name be too long (forty letters at most).'
filters_link: 'Settings → Sieves.'
heading: Yer own holds
hint: 'A hold shows in the inbox while it has post in it. Post goes in through a sieve whose order be "Stow in a hold o'' its own" — make one under'
name_label: Name
placeholder: 'e.g. Parcels'
rename: Rename
renamed: 'Renamed to "%name%".'
subheading: 'Holds ye name yerself, beside the five the ship came with.'
sorting:
again:
button: "Ask again"