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