mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
feat(mail): show the reader's own categories as inbox tabs
A category the reader made is a tab beside the five, shown while it holds mail (or is being looked at) and otherwise only as a drop target. A conversation is in exactly one tab: its own when it has been filed in one, else the built-in one, so a carrier's notice is listed in Shipping and no longer in Promotions. The grouped counts, the new-mail dots, the sender hint, the select-all view and the counts endpoint all key a tab the same way. InboxTab is the one value for "a tab", whose key for a built-in is exactly what it always was. InboxTabs resolves a key against the asking reader's own categories, answers a key that cannot be custom without a query, and reads the categories once per request. Another reader's category, or a deleted one, is no tab and opens the inbox like any unknown key. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
34ffa5590a
commit
b2b9a90840
11 files changed
+1067
-83
No files matched your search
@@ -3,6 +3,7 @@
|
||||
namespace App\Controller\Mail;
|
||||
|
||||
use App\Controller\ChecksCsrf;
|
||||
use App\Domain\DTO\Mail\InboxTab;
|
||||
use App\Domain\Enum\Mail\LabelRole;
|
||||
use App\Domain\Enum\Mail\ListSortOrder;
|
||||
use App\Domain\Enum\Mail\MessageCategory;
|
||||
@@ -22,6 +23,7 @@ use App\Security\Voter\OwnershipVoter;
|
||||
use App\Service\Ai\ThreadSummariser;
|
||||
use App\Service\Ai\ThreadSummaryStore;
|
||||
use App\Service\Ai\ThreadTranscript;
|
||||
use App\Service\Mail\InboxTabs;
|
||||
use App\Service\Mail\NewMailMarkers;
|
||||
use App\Service\Mail\SidebarCounts;
|
||||
use App\Service\Mail\ThreadListRenderer;
|
||||
@@ -53,6 +55,7 @@ final class MailController extends AbstractController
|
||||
private readonly ThreadSummaryStore $summaries,
|
||||
private readonly ThreadRows $threadRows,
|
||||
private readonly ThreadSenderFacts $senderFacts,
|
||||
private readonly InboxTabs $inboxTabs,
|
||||
)
|
||||
{
|
||||
}
|
||||
@@ -214,8 +217,14 @@ final class MailController extends AbstractController
|
||||
// ValueError out of an enum here surfaced as a 500 on ?tab=quatsch.
|
||||
// Falling back to Primary shows the inbox, which is what the person
|
||||
// following that link was after.
|
||||
$tab = MessageCategory::tryFrom((string) $request->query->get('tab', ''))
|
||||
?? MessageCategory::Primary;
|
||||
//
|
||||
// Resolved against the reader's OWN tabs as well as the five: a key for
|
||||
// another person's category, or for one since deleted, is no tab here
|
||||
// and takes the same fall-back — see InboxTabs::resolve().
|
||||
$tab = $user instanceof User
|
||||
? $this->inboxTabs->resolve($user, (string) $request->query->get('tab', ''))
|
||||
: null;
|
||||
$tab ??= InboxTab::builtIn(MessageCategory::Primary);
|
||||
|
||||
// One list, for somebody who has switched the tabs off (#29). `?tab=`
|
||||
// is then a link from before the switch, or from a bookmark, and it
|
||||
@@ -257,10 +266,15 @@ final class MailController extends AbstractController
|
||||
// shows while it holds anything at all, and the tab being LOOKED AT
|
||||
// stays even when its last thread just left, so the ground does not
|
||||
// vanish underfoot; it disappears on the next natural navigation.
|
||||
//
|
||||
// The reader's own tabs follow the five and obey the same rule: one
|
||||
// shows while it holds mail, or while it is being looked at.
|
||||
$allTabs = $tabbed && $user instanceof User ? $this->inboxTabs->allFor($user) : [];
|
||||
|
||||
$tabs = false === $tabbed ? [] : array_values(array_filter(
|
||||
MessageCategory::cases(),
|
||||
static fn (MessageCategory $case): bool => MessageCategory::Primary === $case
|
||||
|| $case === $tab
|
||||
$allTabs,
|
||||
static fn (InboxTab $case): bool => MessageCategory::Primary === $case->builtIn
|
||||
|| $case->value === $tab?->value
|
||||
|| ($tabTotals[$case->value] ?? 0) > 0,
|
||||
));
|
||||
|
||||
@@ -273,9 +287,11 @@ final class MailController extends AbstractController
|
||||
// Forums stays empty forever because nothing can be dropped there until
|
||||
// something is already there. These render as ghost tabs, visible only
|
||||
// while a drag is in flight. See mail/inbox.html.twig.
|
||||
$shownKeys = array_map(static fn (InboxTab $shown): string => $shown->value, $tabs);
|
||||
|
||||
$dropTabs = false === $tabbed ? [] : array_values(array_filter(
|
||||
MessageCategory::cases(),
|
||||
static fn (MessageCategory $case): bool => false === in_array($case, $tabs, true),
|
||||
$allTabs,
|
||||
static fn (InboxTab $case): bool => false === in_array($case->value, $shownKeys, true),
|
||||
));
|
||||
|
||||
$this->threadRows->preload($threads);
|
||||
@@ -770,10 +786,13 @@ final class MailController extends AbstractController
|
||||
$tabbed = $countsUser instanceof User && true === $countsUser->categorySorting->tabs;
|
||||
$tabUnread = $tabbed ? $this->threadRepository->countUnreadByCategoryForUnifiedInbox($countsUser) : [];
|
||||
|
||||
foreach ($tabbed ? MessageCategory::cases() : [] as $category) {
|
||||
$payload[NewMailMarkers::categoryKey($category)] = $newMail->forCategory($category);
|
||||
$payload[NewMailMarkers::categorySendersKey($category)] = implode(', ', $newMail->sendersForCategory($category));
|
||||
$payload['category:' . $category->value] = $tabUnread[$category->value] ?? 0;
|
||||
// The reader's own tabs ride on the same payload as the five, under the
|
||||
// same three keys: the strip patches itself by key, and a tab drawn with
|
||||
// no matching key here is one whose dot and number never update.
|
||||
foreach ($tabbed && $countsUser instanceof User ? $this->inboxTabs->allFor($countsUser) : [] as $tab) {
|
||||
$payload[NewMailMarkers::categoryKey($tab)] = $newMail->forTab($tab);
|
||||
$payload[NewMailMarkers::categorySendersKey($tab)] = implode(', ', $newMail->sendersForTab($tab));
|
||||
$payload['category:' . $tab->value] = $tabUnread[$tab->value] ?? 0;
|
||||
}
|
||||
|
||||
// forRoleBadge(), not forRole(): Trash and Drafts show a total rather
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Domain\DTO\Mail;
|
||||
|
||||
use App\Domain\Enum\Mail\MessageCategory;
|
||||
|
||||
/**
|
||||
* One tab of the inbox: either one of the five built-in categories, or one the
|
||||
* reader made themselves.
|
||||
*
|
||||
* The two live in different columns on purpose — a built-in is
|
||||
* MessageThread::$category, whose enum mapping JMAP's closed vocabulary is built
|
||||
* on, and a reader's own is MessageThread::$userCategory — so there is no single
|
||||
* type that holds both, and every surface that names "a tab" would otherwise
|
||||
* grow its own union. This is the one place that does.
|
||||
*
|
||||
* `value` is the key, and it is the ONE string every surface agrees on: the
|
||||
* `?tab=` in the URL, the `data-dnd-category` a conversation is dropped on, the
|
||||
* keys the counts endpoint patches the strip by. A built-in's key is exactly
|
||||
* what it was before custom tabs existed — a bookmark, a cached page or an open
|
||||
* tab must not change meaning — and a custom key is `c` and the category's id,
|
||||
* which no built-in name can collide with (none starts `c` and ends in digits).
|
||||
*
|
||||
* It is named `value` so the templates, which read `tabCase.value` of an enum
|
||||
* case today, read it off this unchanged.
|
||||
*/
|
||||
final readonly class InboxTab
|
||||
{
|
||||
/** What a custom key starts with, before the category's id. */
|
||||
public const string CUSTOM_PREFIX = 'c';
|
||||
|
||||
private function __construct(
|
||||
public string $value,
|
||||
public ?MessageCategory $builtIn,
|
||||
public ?int $categoryId,
|
||||
public ?string $name,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function builtIn(MessageCategory $category): self
|
||||
{
|
||||
return new self($category->value, $category, null, null);
|
||||
}
|
||||
|
||||
public static function custom(int $categoryId, string $name): self
|
||||
{
|
||||
return new self(self::CUSTOM_PREFIX . $categoryId, null, $categoryId, $name);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab a key names, or null when it names none the reader has.
|
||||
*
|
||||
* Null rather than a throw because the key arrives in a URL anybody can
|
||||
* edit, and a stale or mistyped one is a link from before something was
|
||||
* deleted — it opens the inbox, the same answer an unknown built-in gets.
|
||||
*
|
||||
* An id is the reader's only if it is in $namesById. That lookup is the
|
||||
* ownership check: a custom key for somebody else's category, or one that
|
||||
* was deleted, simply is not a tab here.
|
||||
*
|
||||
* @param array<int,string> $namesById the reader's own categories, id => name
|
||||
*/
|
||||
public static function parse(string $key, array $namesById): ?self
|
||||
{
|
||||
$category = MessageCategory::tryFrom($key);
|
||||
|
||||
if (null !== $category) {
|
||||
return self::builtIn($category);
|
||||
}
|
||||
|
||||
if (false === self::looksCustom($key, $match)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$id = (int) $match[1];
|
||||
|
||||
if (false === array_key_exists($id, $namesById)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::custom($id, $namesById[$id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a key has the shape of a custom tab's — the cheap test that lets a
|
||||
* caller skip looking up the reader's categories for a key that could not
|
||||
* name one. A built-in name, an empty string and a mistyped URL are all
|
||||
* answered without touching the database.
|
||||
*
|
||||
* Digits only, and no leading sign or zero games: `c1x`, `c-12` and `C12` are
|
||||
* not keys this class ever produced, so none of them is a tab.
|
||||
*
|
||||
* @param array<int,string>|null $match filled with the regex match, so parse()
|
||||
* reads the id without matching twice
|
||||
*/
|
||||
public static function looksCustom(string $key, ?array &$match = null): bool
|
||||
{
|
||||
return 1 === preg_match('/^' . self::CUSTOM_PREFIX . '([1-9][0-9]*)$/', $key, $match);
|
||||
}
|
||||
|
||||
public function isCustom(): bool
|
||||
{
|
||||
return null !== $this->categoryId;
|
||||
}
|
||||
|
||||
/** The same tab, whatever it has since been renamed. */
|
||||
public function is(self $other): bool
|
||||
{
|
||||
return $this->value === $other->value;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Domain\DTO\ParsedSearchQuery;
|
||||
use App\Domain\Enum\Ai\SemanticSkipReason;
|
||||
use App\Domain\Enum\Mail\LabelRole;
|
||||
use App\Domain\Enum\Mail\ListSortOrder;
|
||||
use App\Domain\DTO\Mail\InboxTab;
|
||||
use App\Domain\Enum\Mail\MessageCategory;
|
||||
use App\Domain\Enum\Mail\SearchSortOrder;
|
||||
use App\Entity\Label\Label;
|
||||
@@ -135,7 +136,7 @@ class MessageThreadRepository extends ServiceEntityRepository
|
||||
* tabs off — see CategorySorting::$tabs. Null and not a default argument,
|
||||
* so that every caller has to say which of the two it means.
|
||||
*/
|
||||
public function findForUnifiedInbox(UserInterface $user, ?MessageCategory $category, int $page = 1, int $perPage = 50, ListSortOrder $sort = ListSortOrder::Newest, bool $unreadOnly = false): array
|
||||
public function findForUnifiedInbox(UserInterface $user, MessageCategory|InboxTab|null $category, int $page = 1, int $perPage = 50, ListSortOrder $sort = ListSortOrder::Newest, bool $unreadOnly = false): array
|
||||
{
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
@@ -166,18 +167,81 @@ 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 $category): void
|
||||
private function narrowToCategory(QueryBuilder $qb, MessageCategory|InboxTab|null $category): void
|
||||
{
|
||||
if (null === $category) {
|
||||
return;
|
||||
}
|
||||
|
||||
$qb->andWhere('t.category = :category')
|
||||
->setParameter('category', $category);
|
||||
$tab = $category instanceof MessageCategory ? InboxTab::builtIn($category) : $category;
|
||||
|
||||
// A thread is in exactly ONE tab: the reader's own when it has been
|
||||
// filed in one, else the built-in one. So a custom tab is a plain match
|
||||
// on the reader's column, and a built-in tab must also exclude whatever
|
||||
// has left it for one of the reader's — otherwise a carrier's notice is
|
||||
// listed in Promotions AND in Shipping, and the tab counts stop adding
|
||||
// up to the inbox.
|
||||
if (true === $tab->isCustom()) {
|
||||
$qb->andWhere('IDENTITY(t.userCategory) = :userCategory')
|
||||
->setParameter('userCategory', $tab->categoryId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$qb->andWhere('t.userCategory IS NULL')
|
||||
->andWhere('t.category = :category')
|
||||
->setParameter('category', $tab->builtIn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which tab a grouped row belongs to: the reader's own when the thread is
|
||||
* filed in one, else the built-in category.
|
||||
*
|
||||
* One definition, because the three grouped counts and the sender hint must
|
||||
* key a tab identically or the strip's numbers and its dots describe
|
||||
* 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
|
||||
{
|
||||
if (null !== $userCategoryId) {
|
||||
return InboxTab::CUSTOM_PREFIX . (int) $userCategoryId;
|
||||
}
|
||||
|
||||
if ($category instanceof MessageCategory) {
|
||||
return $category->value;
|
||||
}
|
||||
|
||||
return (string) $category;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold grouped rows into tab => count.
|
||||
*
|
||||
* Added up rather than assigned: the query groups by the built-in category
|
||||
* AND the reader's tab, so one of the reader's tabs arrives as several rows
|
||||
* — one per built-in category its threads happen to have — and keeping the
|
||||
* last of them would report a fraction of the tab.
|
||||
*
|
||||
* @param iterable<array<string,mixed>> $rows
|
||||
*
|
||||
* @return array<string,int>
|
||||
*/
|
||||
private function foldByTab(iterable $rows, string $countField): array
|
||||
{
|
||||
$counts = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$key = $this->tabKeyOf($row['category'], $row['userCategory'] ?? null);
|
||||
|
||||
$counts[$key] = ($counts[$key] ?? 0) + (int) $row[$countField];
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/** Same two joins as findForUnifiedInbox(), so the same reason to keep it. */
|
||||
public function countForUnifiedInbox(UserInterface $user, ?MessageCategory $category, bool $unreadOnly = false): int
|
||||
public function countForUnifiedInbox(UserInterface $user, MessageCategory|InboxTab|null $category, bool $unreadOnly = false): int
|
||||
{
|
||||
// COUNT(DISTINCT t.id), not select-DISTINCT: the label join is to-many,
|
||||
// and ->distinct() would put the DISTINCT on the aggregate rather than
|
||||
@@ -221,33 +285,20 @@ class MessageThreadRepository extends ServiceEntityRepository
|
||||
public function countByCategoryForUnifiedInbox(UserInterface $user): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('t')
|
||||
->select('t.category AS category', 'COUNT(DISTINCT t.id) AS threadCount')
|
||||
->select('t.category AS category', 'IDENTITY(t.userCategory) AS userCategory', 'COUNT(DISTINCT t.id) AS threadCount')
|
||||
->join('t.account', 'a')
|
||||
->join('t.labels', 'l')
|
||||
->where('a.usr = :user')
|
||||
->andWhere('a.isActive = true')
|
||||
->andWhere('l.role = :inbox')
|
||||
->groupBy('t.category')
|
||||
->addGroupBy('t.userCategory')
|
||||
->setParameter('user', $user)
|
||||
->setParameter('inbox', LabelRole::Inbox);
|
||||
|
||||
$this->excludeTrashed($qb);
|
||||
|
||||
$rows = $qb->getQuery()->getResult();
|
||||
|
||||
$counts = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$categoryValue = $row['category'];
|
||||
|
||||
if ($categoryValue instanceof MessageCategory) {
|
||||
$categoryValue = $categoryValue->value;
|
||||
}
|
||||
|
||||
$counts[$categoryValue] = (int) $row['threadCount'];
|
||||
}
|
||||
|
||||
return $counts;
|
||||
return $this->foldByTab($qb->getQuery()->getResult(), 'threadCount');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -268,7 +319,7 @@ class MessageThreadRepository extends ServiceEntityRepository
|
||||
public function countUnreadByCategoryForUnifiedInbox(UserInterface $user): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('t')
|
||||
->select('t.category AS category', 'COUNT(DISTINCT t.id) AS threadCount')
|
||||
->select('t.category AS category', 'IDENTITY(t.userCategory) AS userCategory', 'COUNT(DISTINCT t.id) AS threadCount')
|
||||
->join('t.account', 'a')
|
||||
->join('t.labels', 'l')
|
||||
->where('a.usr = :user')
|
||||
@@ -276,26 +327,13 @@ class MessageThreadRepository extends ServiceEntityRepository
|
||||
->andWhere('l.role = :inbox')
|
||||
->andWhere('t.unreadCount > 0')
|
||||
->groupBy('t.category')
|
||||
->addGroupBy('t.userCategory')
|
||||
->setParameter('user', $user)
|
||||
->setParameter('inbox', LabelRole::Inbox);
|
||||
|
||||
$this->excludeTrashed($qb);
|
||||
|
||||
$rows = $qb->getQuery()->getResult();
|
||||
|
||||
$counts = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$categoryValue = $row['category'];
|
||||
|
||||
if ($categoryValue instanceof MessageCategory) {
|
||||
$categoryValue = $categoryValue->value;
|
||||
}
|
||||
|
||||
$counts[$categoryValue] = (int) $row['threadCount'];
|
||||
}
|
||||
|
||||
return $counts;
|
||||
return $this->foldByTab($qb->getQuery()->getResult(), 'threadCount');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -931,32 +969,21 @@ class MessageThreadRepository extends ServiceEntityRepository
|
||||
public function countNewByCategoryForUnifiedInbox(UserInterface $user, \DateTimeImmutable $now): array
|
||||
{
|
||||
$qb = $this->createQueryBuilder('t')
|
||||
->select('t.category AS category', 'COUNT(DISTINCT t.id) AS newCount')
|
||||
->select('t.category AS category', 'IDENTITY(t.userCategory) AS userCategory', 'COUNT(DISTINCT t.id) AS newCount')
|
||||
->join('t.account', 'a')
|
||||
->join('t.labels', 'l')
|
||||
->where('a.usr = :user')
|
||||
->andWhere('a.isActive = true')
|
||||
->andWhere('l.role = :inbox')
|
||||
->groupBy('t.category')
|
||||
->addGroupBy('t.userCategory')
|
||||
->setParameter('user', $user)
|
||||
->setParameter('inbox', LabelRole::Inbox);
|
||||
|
||||
$this->restrictToNew($qb, $now);
|
||||
$this->excludeTrashed($qb);
|
||||
|
||||
$counts = [];
|
||||
|
||||
foreach ($qb->getQuery()->getResult() as $row) {
|
||||
$category = $row['category'];
|
||||
|
||||
if ($category instanceof MessageCategory) {
|
||||
$category = $category->value;
|
||||
}
|
||||
|
||||
$counts[(string) $category] = (int) $row['newCount'];
|
||||
}
|
||||
|
||||
return $counts;
|
||||
return $this->foldByTab($qb->getQuery()->getResult(), 'newCount');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -986,6 +1013,7 @@ class MessageThreadRepository extends ServiceEntityRepository
|
||||
->select(
|
||||
't.id AS threadId',
|
||||
't.category AS category',
|
||||
'IDENTITY(t.userCategory) AS userCategory',
|
||||
't.lastMessageAt AS arrivedAt',
|
||||
'm.fromName AS fromName',
|
||||
'm.fromAddress AS fromAddress',
|
||||
@@ -1018,12 +1046,6 @@ class MessageThreadRepository extends ServiceEntityRepository
|
||||
continue;
|
||||
}
|
||||
|
||||
$category = $row['category'];
|
||||
|
||||
if ($category instanceof MessageCategory) {
|
||||
$category = $category->value;
|
||||
}
|
||||
|
||||
$name = trim((string) ($row['fromName'] ?? ''));
|
||||
|
||||
if ('' === $name) {
|
||||
@@ -1031,7 +1053,7 @@ class MessageThreadRepository extends ServiceEntityRepository
|
||||
}
|
||||
|
||||
$threads[$id] = [
|
||||
'category' => (string) $category,
|
||||
'category' => $this->tabKeyOf($row['category'], $row['userCategory'] ?? null),
|
||||
'arrivedAt' => $row['arrivedAt'],
|
||||
'name' => $name,
|
||||
'at' => $at,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Mail;
|
||||
|
||||
use App\Domain\DTO\Mail\InboxTab;
|
||||
use App\Domain\Enum\Mail\MessageCategory;
|
||||
use App\Repository\Mail\InboxCategoryRepository;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Contracts\Service\ResetInterface;
|
||||
|
||||
/**
|
||||
* The tabs a reader has: the five built-in ones and the ones they made.
|
||||
*
|
||||
* One place that knows how a tab key in a URL, a drop target or a posted body
|
||||
* becomes a tab, because each of those is untrusted input and each of them used
|
||||
* to be `MessageCategory::tryFrom()`. The ownership check lives here too: a
|
||||
* custom key resolves only against the categories THIS reader owns, so another
|
||||
* person's category, or one that has since been deleted, is simply not a tab.
|
||||
*
|
||||
* NOBODY WHO NEVER MADE A CATEGORY PAYS FOR THIS. A key that could not be a
|
||||
* custom one — every built-in name, an empty string, a mistyped URL — is
|
||||
* answered without a query, and the reader's categories are read at most once
|
||||
* per request however many callers ask. The inbox page has a query budget
|
||||
* (ThreadListQueryBudgetTest) and this feature must not spend it on the people
|
||||
* it is not for.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
final class InboxTabs implements ResetInterface
|
||||
{
|
||||
/** @var array<string, list<InboxTab>> user identifier => their own tabs */
|
||||
private array $custom = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly InboxCategoryRepository $categories,
|
||||
) {
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->custom = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The reader's own tabs, in the order they arranged them.
|
||||
*
|
||||
* @return list<InboxTab>
|
||||
*/
|
||||
public function customFor(UserInterface $user): array
|
||||
{
|
||||
return $this->custom[$user->getUserIdentifier()] ??= $this->read($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every tab the reader could be shown: the built-in five, then their own.
|
||||
*
|
||||
* @return list<InboxTab>
|
||||
*/
|
||||
public function allFor(UserInterface $user): array
|
||||
{
|
||||
return [
|
||||
...array_map(InboxTab::builtIn(...), MessageCategory::cases()),
|
||||
...$this->customFor($user),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab a key names for this reader, or null when it names none of theirs.
|
||||
*/
|
||||
public function resolve(UserInterface $user, string $key): ?InboxTab
|
||||
{
|
||||
$builtIn = MessageCategory::tryFrom($key);
|
||||
|
||||
if (null !== $builtIn) {
|
||||
return InboxTab::builtIn($builtIn);
|
||||
}
|
||||
|
||||
if (false === InboxTab::looksCustom($key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$names = [];
|
||||
|
||||
foreach ($this->customFor($user) as $tab) {
|
||||
$names[(int) $tab->categoryId] = (string) $tab->name;
|
||||
}
|
||||
|
||||
return InboxTab::parse($key, $names);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<InboxTab>
|
||||
*/
|
||||
private function read(UserInterface $user): array
|
||||
{
|
||||
$tabs = [];
|
||||
|
||||
foreach ($this->categories->findForUserOrdered($user) as $category) {
|
||||
$tabs[] = InboxTab::custom((int) $category->id, (string) $category->name);
|
||||
}
|
||||
|
||||
return $tabs;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Mail;
|
||||
|
||||
use App\Domain\DTO\Mail\InboxTab;
|
||||
use App\Domain\Enum\Mail\LabelRole;
|
||||
use App\Domain\Enum\Mail\MessageCategory;
|
||||
use App\Entity\Mail\MessageThread;
|
||||
@@ -60,6 +61,7 @@ final readonly class ListViewResolver
|
||||
public function __construct(
|
||||
private MessageThreadRepository $threads,
|
||||
private LabelRepository $labels,
|
||||
private InboxTabs $tabs,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -109,8 +111,12 @@ final readonly class ListViewResolver
|
||||
// all, delete" has to mean the list the server would have drawn —
|
||||
// all of it for somebody without tabs, never Primary alone because
|
||||
// the page sent an empty string (#29).
|
||||
//
|
||||
// The reader's own tabs count: the value is a tab key, and one of
|
||||
// theirs resolves to their category's conversations, while a key
|
||||
// that is not theirs falls back to Primary like any unknown one.
|
||||
$category = true === $user->categorySorting->tabs
|
||||
? MessageCategory::tryFrom($value) ?? MessageCategory::Primary
|
||||
? $this->tabs->resolve($user, $value) ?? InboxTab::builtIn(MessageCategory::Primary)
|
||||
: null;
|
||||
|
||||
return $this->threads->findForUnifiedInbox($user, $category, $page, self::CHUNK, unreadOnly: $unreadOnly);
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Mail;
|
||||
|
||||
use App\Domain\DTO\Mail\InboxTab;
|
||||
use App\Domain\Enum\Mail\LabelRole;
|
||||
use App\Domain\Enum\Mail\MessageCategory;
|
||||
use App\Entity\Label\Label;
|
||||
@@ -116,6 +117,17 @@ class NewMailMarkers implements ResetInterface
|
||||
}
|
||||
|
||||
public function forCategory(MessageCategory $category): int
|
||||
{
|
||||
return $this->forTab(InboxTab::builtIn($category));
|
||||
}
|
||||
|
||||
/**
|
||||
* New conversations in one tab — a built-in one, or one the reader made.
|
||||
*
|
||||
* forCategory() above is this for a built-in, kept because every existing
|
||||
* caller names one.
|
||||
*/
|
||||
public function forTab(InboxTab $tab): int
|
||||
{
|
||||
if (null === $this->byCategory) {
|
||||
$user = $this->security->getUser();
|
||||
@@ -125,7 +137,7 @@ class NewMailMarkers implements ResetInterface
|
||||
: $this->threadRepository->countNewByCategoryForUnifiedInbox($user, $this->now());
|
||||
}
|
||||
|
||||
return $this->byCategory[$category->value] ?? 0;
|
||||
return $this->byCategory[$tab->value] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,6 +151,14 @@ class NewMailMarkers implements ResetInterface
|
||||
* @return list<string> newest arrival first, at most three
|
||||
*/
|
||||
public function sendersForCategory(MessageCategory $category): array
|
||||
{
|
||||
return $this->sendersForTab(InboxTab::builtIn($category));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string> newest arrival first, at most three
|
||||
*/
|
||||
public function sendersForTab(InboxTab $tab): array
|
||||
{
|
||||
if (null === $this->sendersByCategory) {
|
||||
$user = $this->security->getUser();
|
||||
@@ -148,7 +168,7 @@ class NewMailMarkers implements ResetInterface
|
||||
: $this->threadRepository->newSendersByCategoryForUnifiedInbox($user, $this->now());
|
||||
}
|
||||
|
||||
return $this->sendersByCategory[$category->value] ?? [];
|
||||
return $this->sendersByCategory[$tab->value] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -216,7 +236,7 @@ class NewMailMarkers implements ResetInterface
|
||||
* the string is built here rather than in both places, which is how
|
||||
* "node:" keys once outlived the markup that read them.
|
||||
*/
|
||||
public static function categoryKey(MessageCategory $category): string
|
||||
public static function categoryKey(MessageCategory|InboxTab $category): string
|
||||
{
|
||||
return 'new:category:' . $category->value;
|
||||
}
|
||||
@@ -226,7 +246,7 @@ class NewMailMarkers implements ResetInterface
|
||||
* the unread badge keys: their values are STRINGS — joined sender names —
|
||||
* and a patcher expecting a number must never be able to reach one.
|
||||
*/
|
||||
public static function categorySendersKey(MessageCategory $category): string
|
||||
public static function categorySendersKey(MessageCategory|InboxTab $category): string
|
||||
{
|
||||
return 'senders:category:' . $category->value;
|
||||
}
|
||||
|
||||
@@ -86,6 +86,16 @@
|
||||
forums: 'text-violet-600 dark:text-violet-400',
|
||||
} %}
|
||||
|
||||
{# The tabs the reader made themselves have no identity colour or glyph of
|
||||
their own — the five above are Gmail's, and a category is a name somebody
|
||||
typed — so they share one neutral set. A folder because that is what a
|
||||
tab you made is: somewhere you put things. Slate rather than an accent
|
||||
tone, so a custom tab never reads as one of Gmail's. #}
|
||||
{% set customIcon = 'M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z' %}
|
||||
{% set customIconSolid = 'M19.5 21a3 3 0 0 0 3-3v-4.5a3 3 0 0 0-3-3h-15a3 3 0 0 0-3 3V18a3 3 0 0 0 3 3h15ZM1.5 10.146V6a3 3 0 0 1 3-3h5.379a2.25 2.25 0 0 1 1.59.659l2.122 2.121c.14.141.331.22.53.22H19.5a3 3 0 0 1 3 3v1.146A4.483 4.483 0 0 0 19.5 9h-15a4.483 4.483 0 0 0-3 1.146Z' %}
|
||||
{% set customTone = 'bg-slate-600 text-white' %}
|
||||
{% set customIconTone = 'text-slate-600 dark:text-slate-400' %}
|
||||
|
||||
{# A lone Primary tab is a heading pretending to be a choice — when no
|
||||
other category has mail, the whole row stays home. #}
|
||||
{# What a tab link carries with it.
|
||||
@@ -144,9 +154,9 @@
|
||||
<nav class="relative flex text-sm overflow-x-auto overflow-y-hidden pb-px -mb-px [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
data-controller="ui--scroll-active">
|
||||
{% for tabCase in tabs %}
|
||||
{% set isActive = tabCase === tab %}
|
||||
{% set tabName = ('tabs.' ~ tabCase.value)|trans %}
|
||||
{% set newCount = newMail.forCategory(tabCase) %}
|
||||
{% set isActive = tab is not null and tabCase.value == tab.value %}
|
||||
{% set tabName = tabCase.isCustom ? tabCase.name : ('tabs.' ~ tabCase.value)|trans %}
|
||||
{% set newCount = newMail.forTab(tabCase) %}
|
||||
|
||||
{# Rendered on every tab except the one being looked at. The tint answers "where
|
||||
is the mail I cannot see"; the active tab is the one place on
|
||||
@@ -167,8 +177,8 @@
|
||||
know which of the five a given tab wears. #}
|
||||
{% set tintable = false === isActive %}
|
||||
{% set unreadHere = tintable ? (tab_unread[tabCase.value]|default(0)) : 0 %}
|
||||
{% set iconTone = tabIconTones[tabCase.value]|default('') %}
|
||||
{% set senderNames = newCount > 0 ? newMail.sendersForCategory(tabCase)|join(', ') : '' %}
|
||||
{% set iconTone = tabIconTones[tabCase.value]|default(customIconTone) %}
|
||||
{% set senderNames = newCount > 0 ? newMail.sendersForTab(tabCase)|join(', ') : '' %}
|
||||
|
||||
{# min-h rather than padding for the vertical size: the tab is
|
||||
one line without a sender hint and two with one, and a
|
||||
@@ -192,12 +202,12 @@
|
||||
{% if isActive %}
|
||||
<svg class="w-5 h-5 shrink-0{{ unreadHere > 0 ? ' ' ~ iconTone : '' }}" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"
|
||||
{% if tintable %}data-tab-icon data-count-key="category:{{ tabCase.value }}" data-tone-class="{{ iconTone }}"{% endif %}>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="{{ tabIconsSolid[tabCase.value] }}"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="{{ tabIconsSolid[tabCase.value]|default(customIconSolid) }}"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg class="w-5 h-5 shrink-0{{ unreadHere > 0 ? ' ' ~ iconTone : '' }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"
|
||||
{% if tintable %}data-tab-icon data-count-key="category:{{ tabCase.value }}" data-tone-class="{{ iconTone }}"{% endif %}>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="{{ tabIcons[tabCase.value] }}"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="{{ tabIcons[tabCase.value]|default(customIcon) }}"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
|
||||
@@ -258,7 +268,7 @@
|
||||
carries the data-count-key the counts endpoint
|
||||
emits, and the patcher rewrites text as well as
|
||||
visibility. #}
|
||||
<span class="ml-1.5 inline-flex align-middle">{{ newMarkers.new_pill(newCount, 'new:category:' ~ tabCase.value, tabName, tabTones[tabCase.value] ?? null) }}</span>
|
||||
<span class="ml-1.5 inline-flex align-middle">{{ newMarkers.new_pill(newCount, 'new:category:' ~ tabCase.value, tabName, tabTones[tabCase.value] ?? customTone) }}</span>
|
||||
</span>
|
||||
|
||||
{# Who the new mail is from — Gmail's second line.
|
||||
@@ -303,7 +313,7 @@
|
||||
class="flex items-center shrink-0 px-3 @xl:px-5 py-1.5 min-h-14 text-sm font-medium
|
||||
text-ink-faint border-b-2 border-dashed border-transparent
|
||||
whitespace-nowrap transition-colors"
|
||||
>{{ ('tabs.' ~ tabCase.value)|trans }}</span>
|
||||
>{{ tabCase.isCustom ? tabCase.name : ('tabs.' ~ tabCase.value)|trans }}</span>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Mail;
|
||||
|
||||
use App\Domain\Enum\Mail\LabelRole;
|
||||
use App\Domain\Enum\Mail\MessageCategory;
|
||||
use App\Entity\Mail\InboxCategory;
|
||||
use App\Entity\Mail\MessageThread;
|
||||
use App\Entity\User\User;
|
||||
use App\Tests\Support\Mail\SeedsMarkerFixtures;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
|
||||
/**
|
||||
* The inbox of somebody who has a tab of their own.
|
||||
*
|
||||
* What is pinned is what the reader sees and can do with it: the tab exists
|
||||
* while it holds mail and not before (the strip's own rule), it lists its own
|
||||
* conversations and the built-in tab it left no longer does, an empty one is
|
||||
* still somewhere to drop a conversation, and a key that is not theirs opens the
|
||||
* inbox rather than another person's tab or an error.
|
||||
*
|
||||
* The counts endpoint is here because the strip patches itself from it after
|
||||
* every sync, by key: a tab drawn with no matching key in that payload is a tab
|
||||
* whose dot and number never update.
|
||||
*/
|
||||
final class InboxCustomTabTest extends WebTestCase
|
||||
{
|
||||
use SeedsMarkerFixtures;
|
||||
|
||||
private KernelBrowser $client;
|
||||
private Connection $connection;
|
||||
private InboxCategory $shipping;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = static::createClient();
|
||||
$this->client->disableReboot();
|
||||
|
||||
$container = static::getContainer();
|
||||
$this->em = $container->get(EntityManagerInterface::class);
|
||||
$this->connection = $container->get(Connection::class);
|
||||
|
||||
$this->connection->beginTransaction();
|
||||
|
||||
$this->user = $this->seedUser();
|
||||
$this->account = $this->seedAccount();
|
||||
$this->inbox = $this->seedLabel('Inbox', LabelRole::Inbox);
|
||||
|
||||
$this->client->loginUser($this->user);
|
||||
|
||||
$this->shipping = $this->category($this->user, 'Shipping');
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (true === isset($this->connection) && true === $this->connection->isTransactionActive()) {
|
||||
$this->connection->rollBack();
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testACategoryHoldingMailIsATabOfItsOwnAndListsItsMail(): void
|
||||
{
|
||||
$this->filed('a carrier notice', $this->shipping);
|
||||
$this->thread('an ordinary promotion', MessageCategory::Promotions, unread: 1);
|
||||
$this->thread('a normal one', unread: 1);
|
||||
|
||||
$crawler = $this->client->request('GET', '/mail/inbox');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$strip = $crawler->filter('[data-list-region="tabs"]');
|
||||
self::assertStringContainsString('Shipping', $strip->text(), 'a category holding mail has no tab');
|
||||
self::assertContains('tab=c' . $this->shipping->id, $this->tabQueries($crawler));
|
||||
|
||||
$this->client->request('GET', '/mail/inbox?tab=c' . $this->shipping->id);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$own = (string) $this->client->getResponse()->getContent();
|
||||
self::assertStringContainsString('a carrier notice', $own);
|
||||
self::assertStringNotContainsString('an ordinary promotion', $own);
|
||||
self::assertStringNotContainsString('a normal one', $own);
|
||||
}
|
||||
|
||||
/**
|
||||
* The conversation is in ONE tab. Promotions must not list the notice that
|
||||
* has a tab of its own, though its built-in category is still Promotions —
|
||||
* and it must still list the promotion that has none.
|
||||
*/
|
||||
public function testTheBuiltInTabNoLongerListsWhatLeftItForTheReadersOwn(): void
|
||||
{
|
||||
$this->filed('a carrier notice', $this->shipping, MessageCategory::Promotions);
|
||||
$this->thread('an ordinary promotion', MessageCategory::Promotions, unread: 1);
|
||||
|
||||
$this->client->request('GET', '/mail/inbox?tab=promotions');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
$promotions = (string) $this->client->getResponse()->getContent();
|
||||
|
||||
self::assertStringContainsString('an ordinary promotion', $promotions);
|
||||
self::assertStringNotContainsString('a carrier notice', $promotions, 'a conversation is listed in two tabs');
|
||||
}
|
||||
|
||||
public function testACategoryHoldingNothingHasNoTabButIsStillSomewhereToDrop(): void
|
||||
{
|
||||
$this->thread('a normal one', unread: 1);
|
||||
$this->thread('a promo', MessageCategory::Promotions, unread: 1);
|
||||
|
||||
$crawler = $this->client->request('GET', '/mail/inbox');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertNotContains('tab=c' . $this->shipping->id, $this->tabQueries($crawler), 'an empty category was drawn as a tab');
|
||||
|
||||
$ghost = $crawler->filter('[data-dnd-category="c' . $this->shipping->id . '"][data-dnd-ghost]');
|
||||
self::assertCount(1, $ghost, 'an empty category cannot be dropped onto');
|
||||
self::assertStringContainsString('Shipping', $ghost->text());
|
||||
}
|
||||
|
||||
public function testAKeyThatIsNotTheirsOpensTheInboxRatherThanSomebodyElsesTab(): void
|
||||
{
|
||||
$stranger = $this->category($this->seedUser(), 'Not yours');
|
||||
$this->thread('a normal one', unread: 1);
|
||||
|
||||
$this->client->request('GET', '/mail/inbox?tab=c' . $stranger->id);
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertStringContainsString('a normal one', (string) $this->client->getResponse()->getContent());
|
||||
}
|
||||
|
||||
public function testTheCountsEndpointCarriesTheReadersTabForTheStripToPatch(): void
|
||||
{
|
||||
$this->filed('a carrier notice', $this->shipping, MessageCategory::Promotions, unread: 1);
|
||||
|
||||
$this->client->request('GET', '/mail/sidebar/counts');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
|
||||
$payload = json_decode((string) $this->client->getResponse()->getContent(), true, flags: JSON_THROW_ON_ERROR);
|
||||
$key = 'c' . $this->shipping->id;
|
||||
|
||||
self::assertSame(1, $payload['category:' . $key] ?? null, 'the unread count of the tab is missing');
|
||||
self::assertArrayHasKey('new:category:' . $key, $payload);
|
||||
self::assertArrayHasKey('senders:category:' . $key, $payload);
|
||||
// And the built-in it was filed away from is not counting it.
|
||||
self::assertSame(0, $payload['category:promotions'] ?? null);
|
||||
}
|
||||
|
||||
// ── fixture ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** @return list<string> the query string of every tab link in the strip */
|
||||
private function tabQueries(Crawler $crawler): array
|
||||
{
|
||||
return $crawler
|
||||
->filter('[data-list-region="tabs"] a[href*="/mail/inbox"]')
|
||||
->each(static fn (Crawler $node): string => (string) parse_url((string) $node->attr('href'), PHP_URL_QUERY));
|
||||
}
|
||||
|
||||
private function filed(
|
||||
string $subject,
|
||||
InboxCategory $category,
|
||||
MessageCategory $builtIn = MessageCategory::Promotions,
|
||||
int $unread = 1,
|
||||
): MessageThread {
|
||||
$thread = $this->thread($subject, $builtIn, unread: $unread);
|
||||
$thread->userCategory = $category;
|
||||
$this->em->flush();
|
||||
|
||||
return $thread;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Domain\DTO\Mail;
|
||||
|
||||
use App\Domain\DTO\Mail\InboxTab;
|
||||
use App\Domain\Enum\Mail\MessageCategory;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* What a tab IS, now that it is either one of the five built-ins or one the
|
||||
* reader made.
|
||||
*
|
||||
* The key is the one string every surface agrees on — the URL's `?tab=`, the
|
||||
* drop target a conversation is dragged onto, the keys the counts endpoint
|
||||
* patches the strip by — so the two properties worth pinning are that a built-in
|
||||
* key is EXACTLY what it was before this existed (nothing already bookmarked,
|
||||
* cached or patched may change), and that a custom key can never be mistaken for
|
||||
* a built-in one or for somebody else's category.
|
||||
*/
|
||||
final class InboxTabTest extends TestCase
|
||||
{
|
||||
public function testABuiltInTabKeepsTheKeyItAlwaysHad(): void
|
||||
{
|
||||
foreach (MessageCategory::cases() as $category) {
|
||||
$tab = InboxTab::builtIn($category);
|
||||
|
||||
self::assertSame($category->value, $tab->value);
|
||||
self::assertFalse($tab->isCustom());
|
||||
self::assertSame($category, $tab->builtIn);
|
||||
self::assertNull($tab->categoryId);
|
||||
}
|
||||
}
|
||||
|
||||
public function testACustomTabIsKeyedByItsIdAndCarriesItsName(): void
|
||||
{
|
||||
$tab = InboxTab::custom(12, 'Shipping');
|
||||
|
||||
self::assertSame('c12', $tab->value);
|
||||
self::assertTrue($tab->isCustom());
|
||||
self::assertSame(12, $tab->categoryId);
|
||||
self::assertSame('Shipping', $tab->name);
|
||||
self::assertNull($tab->builtIn);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,string> $namesById the reader's own categories
|
||||
*/
|
||||
#[DataProvider('parseCases')]
|
||||
public function testParsing(string $key, array $namesById, ?string $expectedValue): void
|
||||
{
|
||||
self::assertSame($expectedValue, InboxTab::parse($key, $namesById)?->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string, array<int,string>, ?string}>
|
||||
*/
|
||||
public static function parseCases(): iterable
|
||||
{
|
||||
$own = [12 => 'Shipping', 40 => 'Receipts'];
|
||||
|
||||
yield 'a built-in' => ['promotions', $own, 'promotions'];
|
||||
yield 'one of the reader\'s own' => ['c12', $own, 'c12'];
|
||||
yield 'another one of theirs' => ['c40', $own, 'c40'];
|
||||
// The two that matter: an id the reader does not own is somebody else's
|
||||
// category or a deleted one, and either way it is not a tab they have.
|
||||
yield 'a custom id that is not theirs' => ['c99', $own, null];
|
||||
yield 'a custom key with nothing of their own' => ['c12', [], null];
|
||||
yield 'not a tab at all' => ['quatsch', $own, null];
|
||||
yield 'empty' => ['', $own, null];
|
||||
yield 'the prefix alone' => ['c', $own, null];
|
||||
yield 'a malformed id' => ['c1x', $own, null];
|
||||
yield 'a negative id' => ['c-12', $own, null];
|
||||
yield 'a built-in name wearing the prefix' => ['cprimary', $own, null];
|
||||
yield 'the prefix in capitals' => ['C12', $own, null];
|
||||
}
|
||||
|
||||
public function testTwoTabsAreTheSameTabWhenTheirKeysAre(): void
|
||||
{
|
||||
self::assertTrue(InboxTab::custom(12, 'Shipping')->is(InboxTab::custom(12, 'Renamed since')));
|
||||
self::assertFalse(InboxTab::custom(12, 'Shipping')->is(InboxTab::custom(13, 'Shipping')));
|
||||
self::assertFalse(InboxTab::custom(12, 'Shipping')->is(InboxTab::builtIn(MessageCategory::Primary)));
|
||||
self::assertTrue(InboxTab::builtIn(MessageCategory::Primary)->is(InboxTab::builtIn(MessageCategory::Primary)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Repository\Mail;
|
||||
|
||||
use App\Domain\DTO\Mail\InboxTab;
|
||||
use App\Domain\Enum\Mail\LabelRole;
|
||||
use App\Domain\Enum\Mail\MessageCategory;
|
||||
use App\Domain\Enum\Mail\ThreadingMethod;
|
||||
use App\Entity\Label\Label;
|
||||
use App\Entity\Mail\Account;
|
||||
use App\Entity\Mail\InboxCategory;
|
||||
use App\Entity\Mail\Message;
|
||||
use App\Entity\Mail\MessageThread;
|
||||
use App\Entity\User\User;
|
||||
use App\Repository\Mail\MessageThreadRepository;
|
||||
use App\Service\Label\ThreadLabelSynchronizer;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* Every question the tab strip asks, answered for a reader who has a tab of
|
||||
* their own.
|
||||
*
|
||||
* ONE FIXTURE, CHOSEN TO BE THE AWKWARD CASE. A carrier's notice has the
|
||||
* built-in category Promotions AND sits in the reader's Shipping tab; beside it
|
||||
* is a genuine promotion whose built-in category is also Promotions and which is
|
||||
* in no tab of the reader's. A query that read only the built-in column would
|
||||
* put both in Promotions and leave Shipping empty; one that read only the
|
||||
* reader's would drop every ordinary promotion. A thread is in exactly one tab,
|
||||
* and which one is "the reader's, if it has one".
|
||||
*
|
||||
* The built-in tabs are asserted by name as well as the new one, so a refactor
|
||||
* that made Shipping work by breaking Promotions cannot pass.
|
||||
*
|
||||
* Hand-derived figures, from the fixture below:
|
||||
*
|
||||
* thread built-in reader's tab unread new
|
||||
* carrierNew promotions Shipping yes yes
|
||||
* promoNew promotions - yes yes
|
||||
* primaryRead primary - no no
|
||||
* updateRead updates Shipping no no
|
||||
*
|
||||
* Shipping = {carrierNew, updateRead} -> 2 threads, 1 unread, 1 new
|
||||
* Promotions = {promoNew} -> 1 thread, 1 unread, 1 new
|
||||
* Primary = {primaryRead} -> 1 thread, 0 unread, 0 new
|
||||
* Updates = {} -> updateRead left it for Shipping
|
||||
*/
|
||||
final class InboxTabQueriesTest extends KernelTestCase
|
||||
{
|
||||
private EntityManagerInterface $em;
|
||||
private Connection $connection;
|
||||
private MessageThreadRepository $repository;
|
||||
private User $user;
|
||||
private Account $account;
|
||||
private Label $inbox;
|
||||
private InboxCategory $shipping;
|
||||
|
||||
private MessageThread $carrierNew;
|
||||
private MessageThread $promoNew;
|
||||
private MessageThread $primaryRead;
|
||||
private MessageThread $updateRead;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
|
||||
$container = self::getContainer();
|
||||
$this->em = $container->get(EntityManagerInterface::class);
|
||||
$this->connection = $container->get(Connection::class);
|
||||
$this->repository = $container->get(MessageThreadRepository::class);
|
||||
|
||||
$this->connection->beginTransaction();
|
||||
|
||||
$this->user = $this->seedUser();
|
||||
$this->account = $this->seedAccount($this->user);
|
||||
$this->inbox = $this->seedInboxLabel();
|
||||
|
||||
$this->shipping = new InboxCategory();
|
||||
$this->shipping->usr = $this->user;
|
||||
$this->shipping->name = 'Shipping';
|
||||
$this->em->persist($this->shipping);
|
||||
$this->em->flush();
|
||||
|
||||
$this->carrierNew = $this->inboxThread('carrier, new', MessageCategory::Promotions, $this->shipping, unread: 1);
|
||||
$this->promoNew = $this->inboxThread('promo, new', MessageCategory::Promotions, null, unread: 1);
|
||||
$this->primaryRead = $this->inboxThread('primary, read', MessageCategory::Primary, null, unread: 0);
|
||||
$this->updateRead = $this->inboxThread('update, read', MessageCategory::Updates, $this->shipping, unread: 0);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (true === $this->connection->isTransactionActive()) {
|
||||
$this->connection->rollBack();
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testTheReadersOwnTabHoldsWhatWasFiledInItWhateverItsBuiltInCategory(): void
|
||||
{
|
||||
$shipping = InboxTab::custom((int) $this->shipping->id, 'Shipping');
|
||||
|
||||
self::assertSame(
|
||||
['carrier, new', 'update, read'],
|
||||
$this->subjects($this->repository->findForUnifiedInbox($this->user, $shipping)),
|
||||
);
|
||||
self::assertSame(2, $this->repository->countForUnifiedInbox($this->user, $shipping));
|
||||
}
|
||||
|
||||
public function testABuiltInTabNoLongerHoldsWhatLeftItForTheReadersOwn(): void
|
||||
{
|
||||
$promotions = InboxTab::builtIn(MessageCategory::Promotions);
|
||||
|
||||
self::assertSame(['promo, new'], $this->subjects($this->repository->findForUnifiedInbox($this->user, $promotions)));
|
||||
self::assertSame(1, $this->repository->countForUnifiedInbox($this->user, $promotions));
|
||||
|
||||
self::assertSame(
|
||||
['primary, read'],
|
||||
$this->subjects($this->repository->findForUnifiedInbox($this->user, InboxTab::builtIn(MessageCategory::Primary))),
|
||||
);
|
||||
self::assertSame(
|
||||
[],
|
||||
$this->subjects($this->repository->findForUnifiedInbox($this->user, InboxTab::builtIn(MessageCategory::Updates))),
|
||||
'updateRead is in Shipping, so Updates is empty',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every existing caller passes the enum, and every one of them must keep
|
||||
* getting what it always got — narrowed now, since a thread in the reader's
|
||||
* tab is not in the built-in one.
|
||||
*/
|
||||
public function testTheEnumStillWorksAsATabArgument(): void
|
||||
{
|
||||
self::assertSame(
|
||||
['promo, new'],
|
||||
$this->subjects($this->repository->findForUnifiedInbox($this->user, MessageCategory::Promotions)),
|
||||
);
|
||||
self::assertSame(1, $this->repository->countForUnifiedInbox($this->user, MessageCategory::Promotions));
|
||||
}
|
||||
|
||||
public function testNoTabAtAllIsTheWholeInbox(): void
|
||||
{
|
||||
self::assertSame(4, $this->repository->countForUnifiedInbox($this->user, null));
|
||||
}
|
||||
|
||||
public function testTheGroupedCountsKeyTheReadersTabAndAddItsThreadsUpAcrossBuiltIns(): void
|
||||
{
|
||||
$key = 'c' . $this->shipping->id;
|
||||
|
||||
// carrierNew (promotions) and updateRead (updates) are one tab's two
|
||||
// threads: the figure is 2, not a last-row-wins 1.
|
||||
self::assertEquals(
|
||||
[$key => 2, 'promotions' => 1, 'primary' => 1],
|
||||
$this->sorted($this->repository->countByCategoryForUnifiedInbox($this->user)),
|
||||
);
|
||||
self::assertEquals(
|
||||
[$key => 1, 'promotions' => 1],
|
||||
$this->sorted($this->repository->countUnreadByCategoryForUnifiedInbox($this->user)),
|
||||
);
|
||||
self::assertEquals(
|
||||
[$key => 1, 'promotions' => 1],
|
||||
$this->sorted($this->repository->countNewByCategoryForUnifiedInbox($this->user, new DateTimeImmutable())),
|
||||
);
|
||||
}
|
||||
|
||||
public function testTheSendersHintNamesWhoWroteIntoTheReadersTab(): void
|
||||
{
|
||||
$senders = $this->repository->newSendersByCategoryForUnifiedInbox($this->user, new DateTimeImmutable());
|
||||
|
||||
self::assertSame(['Carrier Co'], $senders['c' . $this->shipping->id] ?? null);
|
||||
self::assertSame(['Promo Co'], $senders['promotions'] ?? null);
|
||||
}
|
||||
|
||||
// ── fixture ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param array<string,int> $counts
|
||||
*
|
||||
* @return array<string,int>
|
||||
*/
|
||||
private function sorted(array $counts): array
|
||||
{
|
||||
ksort($counts);
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<MessageThread> $threads
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function subjects(array $threads): array
|
||||
{
|
||||
$subjects = array_map(static fn (MessageThread $thread): string => (string) $thread->subject, $threads);
|
||||
sort($subjects);
|
||||
|
||||
return $subjects;
|
||||
}
|
||||
|
||||
private function inboxThread(string $subject, MessageCategory $builtIn, ?InboxCategory $own, int $unread): MessageThread
|
||||
{
|
||||
$at = new DateTimeImmutable('-1 hour');
|
||||
|
||||
$thread = new MessageThread();
|
||||
$thread->account = $this->account;
|
||||
$thread->subject = $subject;
|
||||
$thread->normalizedSubject = mb_strtolower($subject);
|
||||
$thread->threadingMethod = ThreadingMethod::SubjectFallback;
|
||||
$thread->lastMessageAt = $at;
|
||||
$thread->category = $builtIn;
|
||||
$thread->userCategory = $own;
|
||||
$thread->unreadCount = $unread;
|
||||
$thread->messageCount = 1;
|
||||
$this->em->persist($thread);
|
||||
|
||||
$message = new Message();
|
||||
$message->account = $this->account;
|
||||
$message->thread = $thread;
|
||||
$message->subject = $subject;
|
||||
$message->fromAddress = 'sender@example.test';
|
||||
$message->fromName = str_contains($subject, 'carrier') ? 'Carrier Co' : 'Promo Co';
|
||||
$message->receivedAt = $at;
|
||||
$message->sentAt = $at;
|
||||
$message->hasAttachments = false;
|
||||
$message->flags = [];
|
||||
$message->category = $builtIn;
|
||||
$message->addLabel($this->inbox);
|
||||
$thread->addMessage($message);
|
||||
$this->em->persist($message);
|
||||
$this->em->flush();
|
||||
|
||||
self::getContainer()->get(ThreadLabelSynchronizer::class)->sync($thread);
|
||||
$this->em->flush();
|
||||
|
||||
return $thread;
|
||||
}
|
||||
|
||||
private function seedInboxLabel(): Label
|
||||
{
|
||||
$label = new Label();
|
||||
$label->usr = $this->user;
|
||||
$label->name = 'Inbox';
|
||||
$label->role = LabelRole::Inbox;
|
||||
$label->isVisible = true;
|
||||
$this->em->persist($label);
|
||||
$this->em->flush();
|
||||
|
||||
return $label;
|
||||
}
|
||||
|
||||
private function seedAccount(User $user): Account
|
||||
{
|
||||
$account = new Account();
|
||||
$account->usr = $user;
|
||||
$account->name = 'Tab fixture';
|
||||
$account->email = 'tabs@example.test';
|
||||
$account->username = uniqid('tabs-', true);
|
||||
$account->imapHost = 'imap.example.test';
|
||||
$account->imapPort = 993;
|
||||
$account->imapEncryption = 'ssl';
|
||||
$account->authType = 'password';
|
||||
$account->isActive = true;
|
||||
$this->em->persist($account);
|
||||
$this->em->flush();
|
||||
|
||||
return $account;
|
||||
}
|
||||
|
||||
private function seedUser(): User
|
||||
{
|
||||
$user = new User();
|
||||
$user->email = 'tabs-' . uniqid('', true) . '@example.test';
|
||||
$user->nameFirst = 'Tabs';
|
||||
$user->nameLast = 'Fixture';
|
||||
$user->roles = ['ROLE_USER'];
|
||||
$user->password = 'x';
|
||||
$this->em->persist($user);
|
||||
$this->em->flush();
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\Mail;
|
||||
|
||||
use App\Domain\Enum\Mail\MessageCategory;
|
||||
use App\Entity\Mail\InboxCategory;
|
||||
use App\Entity\User\User;
|
||||
use App\Repository\Mail\InboxCategoryRepository;
|
||||
use App\Service\Mail\InboxTabs;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* The cost and the privacy of resolving a tab.
|
||||
*
|
||||
* Cost: the inbox has a query budget, and somebody who never made a category
|
||||
* must not pay for a feature they are not using. So the repository is a mock
|
||||
* that FAILS when it is asked for something it should not have been — every
|
||||
* refusal below is a case where the answer is known without a query.
|
||||
*
|
||||
* Privacy: the categories read are the asking reader's, and a worker that serves
|
||||
* two readers in a row must not answer the second from the first's memo.
|
||||
*/
|
||||
final class InboxTabsTest extends TestCase
|
||||
{
|
||||
public function testABuiltInKeyAndAKeyThatCannotBeCustomCostNoQuery(): void
|
||||
{
|
||||
$repository = $this->createMock(InboxCategoryRepository::class);
|
||||
$repository->expects(self::never())->method('findForUserOrdered');
|
||||
|
||||
$tabs = new InboxTabs($repository);
|
||||
$reader = $this->user('reader');
|
||||
|
||||
self::assertSame('promotions', $tabs->resolve($reader, 'promotions')?->value);
|
||||
self::assertNull($tabs->resolve($reader, ''));
|
||||
self::assertNull($tabs->resolve($reader, 'quatsch'));
|
||||
self::assertNull($tabs->resolve($reader, 'c1x'));
|
||||
self::assertNull($tabs->resolve($reader, 'cprimary'));
|
||||
}
|
||||
|
||||
public function testReadingTheReadersCategoriesHappensOnceHoweverManyAsk(): void
|
||||
{
|
||||
$repository = $this->createMock(InboxCategoryRepository::class);
|
||||
$repository->expects(self::once())->method('findForUserOrdered')->willReturn([]);
|
||||
|
||||
$tabs = new InboxTabs($repository);
|
||||
$reader = $this->user('reader');
|
||||
|
||||
$tabs->resolve($reader, 'c12');
|
||||
$tabs->resolve($reader, 'c40');
|
||||
$tabs->customFor($reader);
|
||||
$tabs->allFor($reader);
|
||||
}
|
||||
|
||||
/**
|
||||
* The presence companion for the two above: a key that could be custom, from
|
||||
* a reader who has such a category, DOES resolve — so "never queries" cannot
|
||||
* be a resolver that never resolves.
|
||||
*/
|
||||
public function testAKeyThatIsTheirsResolvesAndAnotherReadersDoesNot(): void
|
||||
{
|
||||
$shipping = $this->category('Shipping', 12);
|
||||
|
||||
$repository = $this->createStub(InboxCategoryRepository::class);
|
||||
$repository->method('findForUserOrdered')->willReturnCallback(
|
||||
static fn (User $user): array => 'reader' === $user->getUserIdentifier() ? [$shipping] : [],
|
||||
);
|
||||
|
||||
$tabs = new InboxTabs($repository);
|
||||
|
||||
self::assertSame('c12', $tabs->resolve($this->user('reader'), 'c12')?->value);
|
||||
self::assertSame('Shipping', $tabs->resolve($this->user('reader'), 'c12')?->name);
|
||||
self::assertNull($tabs->resolve($this->user('stranger'), 'c12'), 'resolved a category the asking reader does not own');
|
||||
}
|
||||
|
||||
public function testAllForListsTheFiveThenTheReadersOwnInTheirOrder(): void
|
||||
{
|
||||
$repository = $this->createStub(InboxCategoryRepository::class);
|
||||
$repository->method('findForUserOrdered')->willReturn([$this->category('Shipping', 12), $this->category('Receipts', 40)]);
|
||||
|
||||
$values = array_map(
|
||||
static fn ($tab): string => $tab->value,
|
||||
(new InboxTabs($repository))->allFor($this->user('reader')),
|
||||
);
|
||||
|
||||
self::assertSame(
|
||||
[...array_map(static fn (MessageCategory $c): string => $c->value, MessageCategory::cases()), 'c12', 'c40'],
|
||||
$values,
|
||||
);
|
||||
}
|
||||
|
||||
public function testAResetForgetsTheLastReader(): void
|
||||
{
|
||||
$repository = $this->createMock(InboxCategoryRepository::class);
|
||||
$repository->expects(self::exactly(2))->method('findForUserOrdered')->willReturn([]);
|
||||
|
||||
$tabs = new InboxTabs($repository);
|
||||
|
||||
$tabs->customFor($this->user('reader'));
|
||||
$tabs->reset();
|
||||
$tabs->customFor($this->user('reader'));
|
||||
}
|
||||
|
||||
private function user(string $email): User
|
||||
{
|
||||
$user = new User();
|
||||
$user->email = $email;
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/** A category with an id: the property is private(set), so it is persisted-shaped by reflection. */
|
||||
private function category(string $name, int $id): InboxCategory
|
||||
{
|
||||
$category = new InboxCategory();
|
||||
$category->name = $name;
|
||||
|
||||
new \ReflectionProperty(InboxCategory::class, 'id')->setValue($category, $id);
|
||||
|
||||
return $category;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user