Order every inbox tab, and let a built-in one be switched off

One order across the built-in tabs and the reader's own, and a set of
built-ins switched off, both beside the other sorting choices on the
user (CategorySorting). The order replaces the categories' own position
column, which never shipped: the strip is one list, so its order is too.
Primary is always first and cannot be switched off — it is where the
rest falls back to.

A switched-off category takes no mail. Whatever would have gone there —
by the header rules, Google's label or the assistant — is in Primary,
and the message panel says it would have been that category. The
answer is mapped rather than the next rule consulted: "Forums is off"
does not make a mailing list Promotions for its List-Unsubscribe.

Until the re-sort a switch queues has run, and for a conversation
somebody pinned there, Primary lists and counts the switched-off
category's conversations too, so nothing is in no tab at all.

InboxTabs is where order and on/off are applied, so the strip, the drop
targets and every key that resolves agree. A restore rewrites the
order's own-tab keys to the categories' new ids.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5.5 committed 2026-10-14 11:34:45 +02:00
1 parent 52ad273359
commit 3c0769c1e6
22 files changed
+522 -86

No files matched your search

+12 -2
View File
@@ -30,6 +30,11 @@ use Doctrine\Migrations\AbstractMigration;
* The foreign keys are ON DELETE SET NULL, which is the point: removing a
* category un-files its mail in the database, leaving no pointer at a row that
* is gone. The owner's foreign key is CASCADE, so a category goes with its user.
*
* And two settings on the user, beside the other sorting choices
* (CategorySorting): which built-in tabs are switched off, and the order of all
* of them. An empty list is the shipped behaviour for both — every tab on, the
* built-ins in their usual order — so no row is rewritten for them either.
*/
final class Version20261014170000 extends AbstractMigration
{
@@ -45,13 +50,12 @@ final class Version20261014170000 extends AbstractMigration
id SERIAL NOT NULL,
usr_id INT NOT NULL,
name VARCHAR(40) NOT 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,
PRIMARY KEY(id)
)
SQL);
$this->addSql('CREATE INDEX idx_inbox_category_usr_position ON inbox_category (usr_id, position)');
$this->addSql('CREATE INDEX idx_inbox_category_usr ON inbox_category (usr_id)');
$this->addSql('ALTER TABLE inbox_category ADD CONSTRAINT FK_inbox_category_usr FOREIGN KEY (usr_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE message ADD rule_category_id INT DEFAULT NULL');
@@ -61,10 +65,16 @@ final class Version20261014170000 extends AbstractMigration
$this->addSql('ALTER TABLE message_thread ADD user_category_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE message_thread ADD CONSTRAINT FK_message_thread_user_category FOREIGN KEY (user_category_id) REFERENCES inbox_category (id) ON DELETE SET NULL NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX idx_message_thread_user_category ON message_thread (user_category_id) WHERE user_category_id IS NOT NULL');
$this->addSql('ALTER TABLE "user" ADD category_disabled JSON DEFAULT \'[]\' NOT NULL');
$this->addSql('ALTER TABLE "user" ADD category_tab_order JSON DEFAULT \'[]\' NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE "user" DROP category_tab_order');
$this->addSql('ALTER TABLE "user" DROP category_disabled');
$this->addSql('DROP INDEX idx_message_thread_user_category');
$this->addSql('ALTER TABLE message_thread DROP CONSTRAINT FK_message_thread_user_category');
$this->addSql('ALTER TABLE message_thread DROP user_category_id');
+102 -1
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Entity\Embeddable;
use App\Domain\Enum\Mail\CategorySource;
use App\Domain\Enum\Mail\MessageCategory;
use Doctrine\ORM\Mapping as ORM;
/**
@@ -83,6 +84,89 @@ class CategorySorting
#[ORM\Column(name: 'tabs', type: 'boolean', options: ['default' => true])]
public bool $tabs = true;
/**
* The built-in categories this person has switched off, by value.
*
* A FOURTH DECISION, AND UNLIKE $tabs IT MOVES MAIL. Gmail's Forums is a
* tab most people never see anything in, and Promotions is one some would
* rather not have at all. A switched-off category is not merely hidden:
* nothing sorts into it — not the header rules, not Google's labels, not
* the assistant — and what would have gone there is in Primary instead
* (MessageCategorizer). Switching it back on re-sorts, and the mail is
* back, because every answer is recomputed from stored data.
*
* Primary cannot be switched off: it is where everything else falls back
* to, and the tab the inbox opens on. A stored "primary" is ignored rather
* than trusted, whoever wrote it.
*
* @var list<string>
*/
#[ORM\Column(name: 'disabled', type: 'json', options: ['default' => '[]'])]
public array $disabled = [];
/**
* The order of the tabs after Primary, as tab keys — a built-in's value or
* a category of the person's own (InboxTab).
*
* One list across both kinds, because the person arranges one strip.
* Primary is not in it: it always comes first. A key that names nothing
* any more is skipped, and a tab the list does not name goes after the
* ones it does — built-ins in their usual order, then the person's own
* oldest first — so an empty list is exactly the strip as it shipped.
*
* @var list<string>
*/
#[ORM\Column(name: 'tab_order', type: 'json', options: ['default' => '[]'])]
public array $tabOrder = [];
/**
* The built-in categories switched off, as cases. Never Primary, and
* nothing this build does not know.
*
* @return list<MessageCategory>
*/
public function switchedOff(): array
{
$off = [];
foreach ($this->disabled as $value) {
$category = MessageCategory::tryFrom((string) $value);
if (null !== $category && MessageCategory::Primary !== $category && false === in_array($category, $off, true)) {
$off[] = $category;
}
}
return $off;
}
public function isSwitchedOff(MessageCategory $category): bool
{
return true === in_array($category, $this->switchedOff(), true);
}
/**
* @param list<MessageCategory> $categories
*/
public function switchOff(array $categories): void
{
$this->disabled = array_values(array_unique(array_map(
static fn (MessageCategory $category): string => $category->value,
array_filter($categories, static fn (MessageCategory $category): bool => MessageCategory::Primary !== $category),
)));
}
/**
* @param list<string> $keys
*/
public function arrange(array $keys): void
{
$this->tabOrder = array_values(array_unique(array_filter(
array_map(static fn (mixed $key): string => (string) $key, $keys),
static fn (string $key): bool => '' !== $key && MessageCategory::Primary->value !== $key,
)));
}
public function sourceEnum(): CategorySource
{
return CategorySource::from_($this->source);
@@ -133,10 +217,14 @@ class CategorySorting
public function toArray(): array
{
return [
'version' => 1,
'version' => 2,
'source' => $this->source,
'overrideProvider' => $this->overrideProvider,
'tabs' => $this->tabs,
'disabled' => $this->disabled,
// Custom keys carry the category ids of the installation that wrote
// them; ConfigBackupUserRestorer rewrites them to the new rows.
'tabOrder' => $this->tabOrder,
];
}
@@ -166,6 +254,19 @@ class CategorySorting
$this->tabs = self::boolean($data['tabs']);
}
// Through the same filters the settings page writes through, so a
// hand-edited file cannot switch Primary off or store a non-string.
if (true === isset($data['disabled']) && true === is_array($data['disabled'])) {
$this->switchOff(array_values(array_filter(array_map(
static fn (mixed $value): ?MessageCategory => is_string($value) ? MessageCategory::tryFrom($value) : null,
$data['disabled'],
))));
}
if (true === isset($data['tabOrder']) && true === is_array($data['tabOrder'])) {
$this->arrange(array_values(array_filter($data['tabOrder'], 'is_string')));
}
return $this;
}
+9 -6
View File
@@ -33,9 +33,10 @@ use Doctrine\ORM\Mapping as ORM;
*/
#[ORM\Entity(repositoryClass: InboxCategoryRepository::class)]
#[ORM\Table(name: 'inbox_category')]
// The only lookup is "this user's categories, in order" — on every inbox render
// and every filter edit — so owner leads and position follows.
#[ORM\Index(name: 'idx_inbox_category_usr_position', columns: ['usr_id', 'position'])]
// The only lookup is "this user's categories" — on every inbox render and every
// filter edit. Their order is not here: it is one list across the built-in tabs
// and these, so it lives with the other sorting choices (CategorySorting).
#[ORM\Index(name: 'idx_inbox_category_usr', columns: ['usr_id'])]
#[ORM\HasLifecycleCallbacks]
class InboxCategory
{
@@ -55,7 +56,9 @@ class InboxCategory
#[ORM\Column(length: self::NAME_MAX_LENGTH)]
public string $name;
/** Left to right among the user's own tabs; ties break on id. */
#[ORM\Column(options: ['default' => 0])]
public int $position = 0;
/** The key this category's tab goes by — see InboxTab. */
public function tabKey(): string
{
return \App\Domain\DTO\Mail\InboxTab::CUSTOM_PREFIX . (int) $this->id;
}
}
@@ -20,32 +20,14 @@ class InboxCategoryRepository extends ServiceEntityRepository
}
/**
* 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.
* Every category the user made, oldest first. Where their tabs go among the
* built-in ones is CategorySorting::$tabOrder, applied by InboxTabs.
*
* @return list<InboxCategory>
*/
public function findForUserOrdered(UserInterface $user): array
{
/** @var list<InboxCategory> */
return $this->findBy(['usr' => $user], ['position' => 'ASC', 'id' => 'ASC']);
return $this->findBy(['usr' => $user], ['id' => 'ASC']);
}
}
+52 -14
View File
@@ -152,7 +152,7 @@ class MessageThreadRepository extends ServiceEntityRepository
->setMaxResults($perPage)
->distinct();
$this->narrowToCategory($qb, $category);
$this->narrowToCategory($qb, $category, $this->switchedOffBy($user));
$this->excludeTrashed($qb);
$this->narrowToUnread($qb, $unreadOnly);
@@ -167,7 +167,11 @@ class MessageThreadRepository extends ServiceEntityRepository
* count so the two cannot come to mean different things — a pager offering
* a page the list does not have is what that looks like.
*/
private function narrowToCategory(QueryBuilder $qb, MessageCategory|InboxTab|null $category): void
/**
* @param list<string> $absorbed built-in values switched off — their
* conversations are listed under Primary
*/
private function narrowToCategory(QueryBuilder $qb, MessageCategory|InboxTab|null $category, array $absorbed = []): void
{
if (null === $category) {
return;
@@ -188,11 +192,41 @@ class MessageThreadRepository extends ServiceEntityRepository
return;
}
// Primary also holds whatever is in a category the reader switched
// off. The re-sort a switch queues moves those messages for good, but
// it takes minutes on a big mailbox, and a conversation somebody
// pinned there is not its to move: until then they would be in no tab
// at all.
if (MessageCategory::Primary === $tab->builtIn && [] !== $absorbed) {
$qb->andWhere('t.userCategory IS NULL')
->andWhere('t.category IN (:categories)')
->setParameter('categories', [MessageCategory::Primary->value, ...$absorbed]);
return;
}
$qb->andWhere('t.userCategory IS NULL')
->andWhere('t.category = :category')
->setParameter('category', $tab->builtIn);
}
/**
* The built-in categories this reader switched off, as stored values.
*
* @return list<string>
*/
private function switchedOffBy(UserInterface $user): array
{
if (false === $user instanceof \App\Entity\User\User) {
return [];
}
return array_map(
static fn (MessageCategory $category): string => $category->value,
$user->categorySorting->switchedOff(),
);
}
/**
* Which tab a grouped row belongs to: the reader's own when the thread is
* filed in one, else the built-in category.
@@ -202,17 +236,18 @@ class MessageThreadRepository extends ServiceEntityRepository
* different tabs. The key is InboxTab's, which for a built-in is the value
* it always was.
*/
private function tabKeyOf(mixed $category, mixed $userCategoryId): string
/**
* @param list<string> $absorbed switched-off built-ins, counted as Primary
*/
private function tabKeyOf(mixed $category, mixed $userCategoryId, array $absorbed = []): string
{
if (null !== $userCategoryId) {
return InboxTab::CUSTOM_PREFIX . (int) $userCategoryId;
}
if ($category instanceof MessageCategory) {
return $category->value;
}
$value = $category instanceof MessageCategory ? $category->value : (string) $category;
return (string) $category;
return true === in_array($value, $absorbed, true) ? MessageCategory::Primary->value : $value;
}
/**
@@ -224,15 +259,16 @@ class MessageThreadRepository extends ServiceEntityRepository
* last of them would report a fraction of the tab.
*
* @param iterable<array<string,mixed>> $rows
* @param list<string> $absorbed switched-off built-ins, counted as Primary
*
* @return array<string,int>
*/
private function foldByTab(iterable $rows, string $countField): array
private function foldByTab(iterable $rows, string $countField, array $absorbed = []): array
{
$counts = [];
foreach ($rows as $row) {
$key = $this->tabKeyOf($row['category'], $row['userCategory'] ?? null);
$key = $this->tabKeyOf($row['category'], $row['userCategory'] ?? null, $absorbed);
$counts[$key] = ($counts[$key] ?? 0) + (int) $row[$countField];
}
@@ -260,7 +296,7 @@ class MessageThreadRepository extends ServiceEntityRepository
->setParameter('user', $user)
->setParameter('inbox', LabelRole::Inbox);
$this->narrowToCategory($qb, $category);
$this->narrowToCategory($qb, $category, $this->switchedOffBy($user));
// The same exclusion findForUnifiedInbox() applies, and it was missing
// here. A thread keeps its Inbox label when it goes to the bin — the
@@ -298,7 +334,7 @@ class MessageThreadRepository extends ServiceEntityRepository
$this->excludeTrashed($qb);
return $this->foldByTab($qb->getQuery()->getResult(), 'threadCount');
return $this->foldByTab($qb->getQuery()->getResult(), 'threadCount', $this->switchedOffBy($user));
}
/**
@@ -333,7 +369,7 @@ class MessageThreadRepository extends ServiceEntityRepository
$this->excludeTrashed($qb);
return $this->foldByTab($qb->getQuery()->getResult(), 'threadCount');
return $this->foldByTab($qb->getQuery()->getResult(), 'threadCount', $this->switchedOffBy($user));
}
/**
@@ -983,7 +1019,7 @@ class MessageThreadRepository extends ServiceEntityRepository
$this->restrictToNew($qb, $now);
$this->excludeTrashed($qb);
return $this->foldByTab($qb->getQuery()->getResult(), 'newCount');
return $this->foldByTab($qb->getQuery()->getResult(), 'newCount', $this->switchedOffBy($user));
}
/**
@@ -1009,6 +1045,8 @@ class MessageThreadRepository extends ServiceEntityRepository
\DateTimeImmutable $now,
int $perCategory = 3,
): array {
$absorbed = $this->switchedOffBy($user);
$qb = $this->createQueryBuilder('t')
->select(
't.id AS threadId',
@@ -1053,7 +1091,7 @@ class MessageThreadRepository extends ServiceEntityRepository
}
$threads[$id] = [
'category' => $this->tabKeyOf($row['category'], $row['userCategory'] ?? null),
'category' => $this->tabKeyOf($row['category'], $row['userCategory'] ?? null, $absorbed),
'arrivedAt' => $row['arrivedAt'],
'name' => $name,
'at' => $at,
@@ -195,12 +195,43 @@ final readonly class ConfigBackupUserRestorer
$this->retargetAccountCalendars($accounts, $calendars);
$this->applyAliasOverrides($accounts, $this->rows($document, 'accounts'));
$this->createRules($user, $this->rows($document, 'rules'), $accounts, $labels, $integrations, $categories);
$this->retargetTabOrder($user, $categories);
$this->entityManager->flush();
return $user;
}
/**
* The tab order names the person's own tabs by their category ids, which
* were rows in the source database. Each is rewritten to the row it became,
* and dropped when the file did not carry that category — left as it was,
* the number could name somebody else's tab on this installation, though
* InboxTabs would only ever show the reader their own.
*
* @param array<int, InboxCategory> $categories keyed by the id in the file
*/
private function retargetTabOrder(User $user, array $categories): void
{
$keys = [];
foreach ($user->categorySorting->tabOrder as $key) {
if (false === \App\Domain\DTO\Mail\InboxTab::looksCustom($key, $match)) {
$keys[] = $key;
continue;
}
$category = $categories[(int) $match[1]] ?? null;
if (null !== $category?->id) {
$keys[] = $category->tabKey();
}
}
$user->categorySorting->arrange($keys);
}
// ── Private: the user row ─────────────────────────────────────────────────
/**
@@ -569,7 +600,6 @@ final readonly class ConfigBackupUserRestorer
$category = new InboxCategory();
$category->usr = $user;
$category->name = $name;
$category->position = $this->number($row, 'position') ?? 0;
$this->entityManager->persist($category);
+2 -3
View File
@@ -624,9 +624,8 @@ final readonly class ConfigBackupUsers
foreach ($this->inboxCategories->findForUserOrdered($user) as $category) {
$exported[] = [
'id' => $category->id,
'name' => $category->name,
'position' => $category->position,
'id' => $category->id,
'name' => $category->name,
];
}
@@ -57,7 +57,6 @@ final readonly class InboxCategoryManager
$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();
+69 -6
View File
@@ -6,6 +6,8 @@ namespace App\Service\Mail;
use App\Domain\DTO\Mail\InboxTab;
use App\Domain\Enum\Mail\MessageCategory;
use App\Entity\Embeddable\CategorySorting;
use App\Entity\User\User;
use App\Repository\Mail\InboxCategoryRepository;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Contracts\Service\ResetInterface;
@@ -29,6 +31,14 @@ use Symfony\Contracts\Service\ResetInterface;
* (ThreadListQueryBudgetTest), and that single read is why the inbox's own
* ceiling is one higher than the other lists'.
*
* ORDER AND ON/OFF ARE APPLIED HERE TOO, so no surface draws the strip its
* own way. Primary first, always; then CategorySorting::$tabOrder; then any tab
* that list does not name — built-ins in their usual order, the reader's own
* oldest first. A built-in the reader switched off is not a tab at all: not in
* the strip, not a drop target, and its key resolves to nothing, exactly like a
* deleted category's. The settings page is the one place that lists it, through
* managedFor().
*
* Memoised for the request, and resettable for the reason NewMailMarkers is: a
* worker serves many readers one after another, and a memo that outlived its
* request would show the first reader's tabs to the second.
@@ -59,16 +69,61 @@ final class InboxTabs implements ResetInterface
}
/**
* Every tab the reader could be shown: the built-in five, then their own.
* Every tab the reader could be shown, in their order, without the built-in
* ones they switched off.
*
* @return list<InboxTab>
*/
public function allFor(UserInterface $user): array
{
return [
...array_map(InboxTab::builtIn(...), MessageCategory::cases()),
...$this->customFor($user),
];
$off = self::sorting($user)->switchedOff();
return array_values(array_filter(
$this->managedFor($user),
static fn (InboxTab $tab): bool => null === $tab->builtIn || false === in_array($tab->builtIn, $off, true),
));
}
/**
* Every tab there is for this reader, switched off or not, in their order —
* what the settings page lists, with a switch beside each built-in.
*
* @return list<InboxTab>
*/
public function managedFor(UserInterface $user): array
{
$byKey = [];
foreach (MessageCategory::cases() as $category) {
$byKey[$category->value] = InboxTab::builtIn($category);
}
foreach ($this->customFor($user) as $tab) {
$byKey[$tab->value] = $tab;
}
$ordered = [MessageCategory::Primary->value => $byKey[MessageCategory::Primary->value]];
foreach (self::sorting($user)->tabOrder as $key) {
if (true === isset($byKey[$key]) && false === isset($ordered[$key])) {
$ordered[$key] = $byKey[$key];
}
}
// What the stored order does not name, in the order it was built in
// above: built-ins first, then the reader's own oldest first. A tab
// made after the reader last arranged the strip lands at its end.
return array_values($ordered + $byKey);
}
/**
* The built-in categories the reader switched off. Primary never is.
*
* @return list<MessageCategory>
*/
public function switchedOffFor(UserInterface $user): array
{
return self::sorting($user)->switchedOff();
}
/**
@@ -79,7 +134,9 @@ final class InboxTabs implements ResetInterface
$builtIn = MessageCategory::tryFrom($key);
if (null !== $builtIn) {
return InboxTab::builtIn($builtIn);
// Switched off is no tab, like a deleted category: a stale link
// opens Primary and a drop onto it is refused.
return true === self::sorting($user)->isSwitchedOff($builtIn) ? null : InboxTab::builtIn($builtIn);
}
if (false === InboxTab::looksCustom($key)) {
@@ -95,6 +152,12 @@ final class InboxTabs implements ResetInterface
return InboxTab::parse($key, $names);
}
/** The reader's sorting choices; the shipped ones for anything not a User. */
private static function sorting(UserInterface $user): CategorySorting
{
return $user instanceof User ? $user->categorySorting : new CategorySorting();
}
/**
* @return list<InboxTab>
*/
+38
View File
@@ -67,6 +67,7 @@ final class MessageCategorizer
true === $sorting?->overrideProvider,
true === $sorting?->ignoresAi(),
true === $sorting?->assistantFirst(),
$sorting?->switchedOff() ?? [],
)['category'];
}
@@ -132,6 +133,15 @@ final class MessageCategorizer
* the message view offers both answers side by side, so the difference
* is something to look at rather than something to discover later.
*
* @param list<MessageCategory> $switchedOff
* Categories the reader switched off (CategorySorting::$disabled).
* Whatever the cascade would file under one of them — a header rule,
* Google's label, the assistant — is in Primary instead, with the
* reason `switched_off` and the category it would have been as the
* signal. Applied to the ANSWER rather than by skipping steps: "Forums
* is off" means a mailing list is ordinary mail, not that the next
* rule down gets to call it Promotions for its List-Unsubscribe.
*
* @return array{category: MessageCategory, reason: string, signal: string|null}
*/
public function explain(
@@ -140,6 +150,34 @@ final class MessageCategorizer
bool $ignoreProviderLabels = false,
bool $ignoreAi = false,
bool $aiFirst = false,
array $switchedOff = [],
): array {
$decided = $this->decide($message, $correspondentEmails, $ignoreProviderLabels, $ignoreAi, $aiFirst);
if (true === in_array($decided['category'], $switchedOff, true) && MessageCategory::Primary !== $decided['category']) {
return [
'category' => MessageCategory::Primary,
'reason' => 'switched_off',
'signal' => $decided['category']->value,
];
}
return $decided;
}
/**
* The cascade itself, before the reader's switched-off tabs are applied.
*
* @param array<string,true> $correspondentEmails
*
* @return array{category: MessageCategory, reason: string, signal: string|null}
*/
private function decide(
Message $message,
array $correspondentEmails,
bool $ignoreProviderLabels,
bool $ignoreAi,
bool $aiFirst,
): array {
$gmailLabelIds = $message->gmailLabelIds;
+5 -1
View File
@@ -156,6 +156,7 @@ final class MessageCategoryExtension extends AbstractExtension
$sorting->overrideProvider,
$sorting->ignoresAi(),
$sorting->assistantFirst(),
$sorting->switchedOff(),
),
// Gmail's own answer, on its own line, because it is one of the
@@ -188,7 +189,8 @@ final class MessageCategoryExtension extends AbstractExtension
}
/**
* @param array<string,true> $correspondents
* @param array<string,true> $correspondents
* @param list<MessageCategory> $switchedOff
*
* @return array{category: string, reason: string, signal: string|null}
*/
@@ -198,6 +200,7 @@ final class MessageCategoryExtension extends AbstractExtension
bool $ignoreProviderLabels,
bool $ignoreAi,
bool $aiFirst = false,
array $switchedOff = [],
): array {
$explanation = $this->categorizer->explain(
$message,
@@ -205,6 +208,7 @@ final class MessageCategoryExtension extends AbstractExtension
$ignoreProviderLabels,
$ignoreAi,
$aiFirst,
$switchedOff,
);
return [
@@ -267,6 +267,10 @@ mailed-by and signed-by were silently missing from every IMAP message.
— {{ 'message.details.categorised.pinned'|trans }}
{%- elseif category.own %}
— {{ 'message.details.categorised.own_filter'|trans }}
{%- elseif category.effective.reason == 'switched_off' %}
{#- The signal is the category it would have been, named
the way its tab was. #}
— {{ 'message.details.categorised.switched_off'|trans({'%category%': ('tabs.' ~ category.effective.signal)|trans}) }}
{%- else %}
— {{ ('message.details.categorised.' ~ category.effective.reason)|trans }}
{%- if category.effective.signal %}
+12 -13
View File
@@ -52,21 +52,21 @@ final class InboxCategoryTest extends KernelTestCase
parent::tearDown();
}
public function testACategoryIsStoredForItsOwnerAndKeepsItsOrder(): void
public function testACategoryIsStoredForItsOwner(): void
{
$account = $this->seedAccount();
$first = $this->category($account->usr, 'Shipping', 0);
$second = $this->category($account->usr, 'Receipts', 1);
$first = $this->category($account->usr, 'Shipping');
$second = $this->category($account->usr, 'Receipts');
$this->em->clear();
$rows = $this->connection->fetchAllAssociative(
'SELECT name, position FROM inbox_category WHERE usr_id = :usr ORDER BY position',
'SELECT name FROM inbox_category WHERE usr_id = :usr ORDER BY id',
['usr' => $account->usr->id],
);
self::assertSame([['name' => 'Shipping', 'position' => 0], ['name' => 'Receipts', 'position' => 1]], $rows);
self::assertSame([['name' => 'Shipping'], ['name' => 'Receipts']], $rows);
self::assertNotNull($first->id);
self::assertNotNull($second->id);
}
@@ -76,7 +76,7 @@ final class InboxCategoryTest extends KernelTestCase
// A user with no account: an account's own foreign key to its user is
// not what this is about, and would refuse the delete before it began.
$user = $this->seedUser();
$this->category($user, 'Shipping', 0);
$this->category($user, 'Shipping');
$userId = (int) $user->id;
$this->connection->executeStatement('DELETE FROM "user" WHERE id = :id', ['id' => $userId]);
@@ -96,8 +96,8 @@ final class InboxCategoryTest extends KernelTestCase
public function testRemovingACategoryUnfilesItsMailAndThreadsAndNothingElse(): void
{
$account = $this->seedAccount();
$doomed = $this->category($account->usr, 'Shipping', 0);
$survivor = $this->category($account->usr, 'Receipts', 1);
$doomed = $this->category($account->usr, 'Shipping');
$survivor = $this->category($account->usr, 'Receipts');
$inDoomed = $this->threadWithMessage($account, 'filed under the doomed category');
$inSurvivor = $this->threadWithMessage($account, 'filed under the surviving category');
@@ -137,12 +137,11 @@ final class InboxCategoryTest extends KernelTestCase
// ── fixture ──────────────────────────────────────────────────────────────
private function category(User $user, string $name, int $position): InboxCategory
private function category(User $user, string $name): InboxCategory
{
$category = new InboxCategory();
$category->usr = $user;
$category->name = $name;
$category->position = $position;
$category = new InboxCategory();
$category->usr = $user;
$category->name = $name;
$this->em->persist($category);
$this->em->flush();
@@ -171,6 +171,37 @@ final class InboxTabQueriesTest extends KernelTestCase
self::assertSame(['Promo Co'], $senders['promotions'] ?? null);
}
/**
* Promotions switched off: its conversation is listed and counted under
* Primary — until the re-sort that the switch queues has moved it, and for
* good when somebody had pinned it there. The one filed in the reader's own
* tab stays where it is; switching a built-in off says nothing about that.
*/
public function testASwitchedOffCategoryIsListedAndCountedUnderPrimary(): void
{
$this->user->categorySorting->switchOff([MessageCategory::Promotions]);
$primary = InboxTab::builtIn(MessageCategory::Primary);
self::assertSame(
['primary, read', 'promo, new'],
$this->subjects($this->repository->findForUnifiedInbox($this->user, $primary)),
);
self::assertSame(2, $this->repository->countForUnifiedInbox($this->user, $primary));
$key = 'c' . $this->shipping->id;
self::assertEquals(
[$key => 2, 'primary' => 2],
$this->sorted($this->repository->countByCategoryForUnifiedInbox($this->user)),
);
self::assertEquals(
[$key => 1, 'primary' => 1],
$this->sorted($this->repository->countUnreadByCategoryForUnifiedInbox($this->user)),
);
self::assertSame(['Promo Co'], $this->repository->newSendersByCategoryForUnifiedInbox($this->user, new DateTimeImmutable())['primary'] ?? null);
}
// ── fixture ──────────────────────────────────────────────────────────────
/**
@@ -104,6 +104,31 @@ final class ConfigBackupInboxCategoriesTest extends KernelTestCase
);
}
/**
* The order of the strip and the switched-off tabs come back too, with the
* order's own tabs named by the ids the restore gave them. A key the file
* carries no category for is dropped.
*/
public function testTheTabOrderAndTheSwitchedOffTabsComeBackWithTheirNewIds(): void
{
$this->seedReader();
$document = $this->exporter->document();
$this->becomeADifferentInstallation();
$this->importer->apply($document);
$this->entityManager->flush();
$this->entityManager->clear();
$user = static::getContainer()->get(UserRepository::class)->findOneBy(['email' => self::EMAIL]);
self::assertInstanceOf(User::class, $user);
[$shipping, $receipts] = static::getContainer()->get(InboxCategoryRepository::class)->findForUserOrdered($user);
self::assertSame([$receipts->tabKey(), 'promotions', $shipping->tabKey()], $user->categorySorting->tabOrder);
self::assertSame(['forums'], $user->categorySorting->disabled);
}
/**
* A reference to a category the file does not carry is dropped, not carried
* as a number that now names somebody else's row or nothing at all. The
@@ -162,9 +187,9 @@ final class ConfigBackupInboxCategoriesTest extends KernelTestCase
$document = $this->exporter->document();
$document['users'][self::EMAIL]['inboxCategories'] = [
['id' => 1, 'name' => str_repeat('x', 60), 'position' => 0],
['id' => 2, 'name' => ' ', 'position' => 1],
['id' => 3, 'name' => 'Receipts', 'position' => 2],
['id' => 1, 'name' => str_repeat('x', 60)],
['id' => 2, 'name' => ' '],
['id' => 3, 'name' => 'Receipts'],
];
$this->becomeADifferentInstallation();
@@ -199,20 +224,23 @@ final class ConfigBackupInboxCategoriesTest extends KernelTestCase
$user->roles = ['ROLE_USER'];
$this->entityManager->persist($user);
$shipping = new InboxCategory();
$shipping->usr = $user;
$shipping->name = 'Shipping';
$shipping->position = 0;
$shipping = new InboxCategory();
$shipping->usr = $user;
$shipping->name = 'Shipping';
$this->entityManager->persist($shipping);
$receipts = new InboxCategory();
$receipts->usr = $user;
$receipts->name = 'Receipts';
$receipts->position = 1;
$receipts = new InboxCategory();
$receipts->usr = $user;
$receipts->name = 'Receipts';
$this->entityManager->persist($receipts);
$this->entityManager->flush();
// Receipts before Promotions before Shipping, and a key for a category
// that is gone; Forums switched off.
$user->categorySorting->arrange([$receipts->tabKey(), 'promotions', $shipping->tabKey(), 'c999999']);
$user->categorySorting->switchOff([\App\Domain\Enum\Mail\MessageCategory::Forums]);
$rule = new MailRule();
$rule->usr = $user;
$rule->name = 'Carriers';
@@ -67,15 +67,12 @@ final class InboxCategoryManagerTest extends KernelTestCase
parent::tearDown();
}
public function testACategoryIsTrimmedAndAppendedAfterTheOnesThereAlready(): void
public function testACategoryIsTrimmed(): void
{
$first = $this->manager->create($this->user, ' Shipping ');
$second = $this->manager->create($this->user, 'Receipts');
$first = $this->manager->create($this->user, ' Shipping ');
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');
}
/**
+52 -1
View File
@@ -74,7 +74,7 @@ final class InboxTabsTest extends TestCase
self::assertNull($tabs->resolve($this->user('stranger'), 'c12'), 'resolved a category the asking reader does not own');
}
public function testAllForListsTheFiveThenTheReadersOwnInTheirOrder(): void
public function testWithNoOrderStoredTheFiveComeFirstThenTheReadersOwnOldestFirst(): void
{
$repository = $this->createStub(InboxCategoryRepository::class);
$repository->method('findForUserOrdered')->willReturn([$this->category('Shipping', 12), $this->category('Receipts', 40)]);
@@ -90,6 +90,47 @@ final class InboxTabsTest extends TestCase
);
}
/**
* Primary first whatever the stored order says; then the reader's order,
* mixing built-ins and their own; then whatever the order does not name.
* A stale key — a category since deleted — is skipped.
*/
public function testTheReadersOrderPutsTheirTabsAmongTheBuiltInsAfterPrimary(): void
{
$repository = $this->createStub(InboxCategoryRepository::class);
$repository->method('findForUserOrdered')->willReturn([$this->category('Shipping', 12), $this->category('Receipts', 40)]);
$reader = $this->user('reader');
$reader->categorySorting->tabOrder = ['c40', 'updates', 'primary', 'c99', 'social'];
self::assertSame(
['primary', 'c40', 'updates', 'social', 'promotions', 'forums', 'c12'],
$this->keys((new InboxTabs($repository))->allFor($reader)),
);
}
/**
* A switched-off built-in is no tab: not listed, and its key resolves to
* nothing, as a deleted category's does. The settings page still lists it,
* in its place, so it can be switched back on.
*/
public function testASwitchedOffBuiltInIsNoTabButIsStillManaged(): void
{
$repository = $this->createStub(InboxCategoryRepository::class);
$repository->method('findForUserOrdered')->willReturn([]);
$reader = $this->user('reader');
$reader->categorySorting->switchOff([MessageCategory::Forums, MessageCategory::Primary]);
$tabs = new InboxTabs($repository);
self::assertNotContains('forums', $this->keys($tabs->allFor($reader)));
self::assertContains('primary', $this->keys($tabs->allFor($reader)), 'Primary cannot be switched off');
self::assertNull($tabs->resolve($reader, 'forums'));
self::assertSame('updates', $tabs->resolve($reader, 'updates')?->value);
self::assertSame(['primary', 'social', 'promotions', 'updates', 'forums'], $this->keys($tabs->managedFor($reader)));
}
public function testAResetForgetsTheLastReader(): void
{
$repository = $this->createMock(InboxCategoryRepository::class);
@@ -102,6 +143,16 @@ final class InboxTabsTest extends TestCase
$tabs->customFor($this->user('reader'));
}
/**
* @param list<\App\Domain\DTO\Mail\InboxTab> $tabs
*
* @return list<string>
*/
private function keys(array $tabs): array
{
return array_map(static fn ($tab): string => $tab->value, $tabs);
}
private function user(string $email): User
{
$user = new User();
@@ -427,6 +427,61 @@ final class MessageCategorizerTest extends TestCase
);
}
/**
* A switched-off category takes no mail, whoever would have put it there —
* the header rules, Google's label or the assistant — and what it would
* have taken is in Primary. Not the next rule down: a mailing list carries
* List-Unsubscribe too, and "Forums is off" does not mean "lists are
* adverts".
*
* @return iterable<string, array{Message, ?CategorySorting}>
*/
public static function switchedOffDeciders(): iterable
{
$list = new Message();
$list->headers = ['List-Post' => '<mailto:dev@lists.test>', 'List-Unsubscribe' => '<https://lists.test/u>'];
$list->fromAddress = 'dev@lists.test';
yield 'the header rules' => [$list, null];
$gmail = new Message();
$gmail->fromAddress = 'dev@lists.test';
$gmail->gmailLabelIds = ['INBOX', 'CATEGORY_FORUMS'];
yield "Google's label" => [$gmail, null];
$asked = new Message();
$asked->fromAddress = 'someone@example.test';
$asked->aiCategory = MessageCategory::Forums;
$assistant = new CategorySorting();
$assistant->source = 'assistant';
yield 'the assistant' => [$asked, $assistant];
}
#[\PHPUnit\Framework\Attributes\DataProvider('switchedOffDeciders')]
public function testASwitchedOffCategoryTakesNoMail(Message $message, ?CategorySorting $sorting): void
{
$sorting ??= new CategorySorting();
self::assertSame(MessageCategory::Forums, $this->categorizer->categorize($message, [], $sorting), 'the fixture must decide Forums while it is on');
$sorting->switchOff([MessageCategory::Forums]);
self::assertSame(MessageCategory::Primary, $this->categorizer->categorize($message, [], $sorting));
$explained = $this->categorizer->explain($message, [], $sorting->overrideProvider, $sorting->ignoresAi(), $sorting->assistantFirst(), $sorting->switchedOff());
self::assertSame('switched_off', $explained['reason']);
self::assertSame('forums', $explained['signal'], 'the panel names what it would have been');
}
public function testSwitchingOneCategoryOffLeavesTheOthersAlone(): void
{
$sorting = new CategorySorting();
$sorting->switchOff([MessageCategory::Forums]);
$newsletter = $this->message(['List-Unsubscribe' => '<https://shop.test/u>'], 'newsletter@shop.test');
self::assertSame(MessageCategory::Promotions, $this->categorizer->categorize($newsletter, [], $sorting));
}
/**
* No preference is the shipped behaviour, unchanged.
*
+1
View File
@@ -2344,6 +2344,7 @@ message:
pinned: 'du hast sie hierher verschoben'
promotion: 'ein Massenmail-Header: '
social: 'die Absenderdomain '
switched_off: "es wäre %category%, und das hast du ausgeschaltet"
thread_differs: 'Diese Nachricht allein wäre %category% — eine Unterhaltung übernimmt die Kategorie ihrer neuesten Nachricht.'
update: 'ein Hinweis auf automatischen Versand: '
category: Kategorie
+1
View File
@@ -2351,6 +2351,7 @@ message:
pinned: 'you moved it here'
promotion: 'a bulk-mail header: '
social: 'the sender domain '
switched_off: "it would be %category%, which you switched off"
# A conversation takes ONE category, from its newest message, so a
# reply can carry the whole thread out of the tab this message
# belongs in. Said outright, because without it the panel reads as
+1
View File
@@ -2223,6 +2223,7 @@ message:
pinned: 'ye hauled it here yerself'
promotion: 'a bulk-cargo header: '
social: 'the sender port '
switched_off: "it'd be %category%, an' ye scuttled that one"
thread_differs: "This message alone would be %category% — a yarn takes th' category o' its newest message."
update: 'a sign of an unmanned sender: '
category: rank
+1
View File
@@ -2362,6 +2362,7 @@ message:
pinned: 'vous l’avez déplacé ici'
promotion: 'un en-tête de courrier de masse : '
social: 'le domaine de l’expéditeur '
switched_off: "ce serait %category%, que vous avez désactivé"
# A conversation takes ONE category, from its newest message, so a
# reply can carry the whole thread out of the tab this message
# belongs in. Said outright, because without it the panel reads as