Settings → Categories: one page for the inbox tabs

What sorts mail into tabs and the tabs themselves were two cards under
General. They are their own page now, in the Mail group: the sorting
card, then every tab on one list in the order the inbox draws them.

- Built-in tabs carry an On/Off switch. Off releases conversations
  somebody dragged into the category and queues the re-sort the other
  sorting choices queue; on queues it too, and the mail comes back.
- The reader's own tabs keep rename and delete.
- Everything but Primary is dragged into place, or moved with buttons
  from a keyboard or a phone, as on the account list. Primary sits
  above the list: always first, never off.

The order endpoint keeps only keys the reader has, so a request cannot
plant a key naming somebody else's category.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5.5 committed 2026-10-14 11:39:20 +02:00
1 parent 3c0769c1e6
commit aa735e88a0
15 files changed
+642 -122

No files matched your search

@@ -0,0 +1,143 @@
import { Controller } from "@hotwired/stimulus"
import Sortable from "sortablejs"
import { requestFailed } from "../../request_errors.js"
/**
* The order of the inbox tabs: drag on a pointer, buttons everywhere else.
*
* The account list's controller (settings/account_order_controller) with tab
* keys where it has ids — the same reasons hold: sortablejs directly so the
* request can carry its CSRF token, the whole order in every write so a
* duplicated request lands on the same arrangement, one write in flight and
* only the latest order sent, and the rows put back if the server refuses.
*
* Primary is not in this list. It always comes first, so the page draws it
* above the list rather than as a row that refuses to move.
*
* Values:
* url — InboxCategoryController::order
* csrf — csrf_token('settings-categories')
*/
export default class extends Controller {
static targets = ["list"]
static values = { url: String, csrf: String }
connect() {
this.sortable = Sortable.create(this.listTarget, {
handle: ".tab-drag-handle",
animation: 150,
onEnd: () => {
this.syncEdges()
this.persist()
},
})
this.syncEdges()
this.saved = this.currentKeys()
this.inFlight = false
this.pending = false
}
disconnect() {
this.sortable?.destroy()
}
/** Keyboard- and touch-reachable equivalent of a drag. */
move(event) {
const row = event.currentTarget.closest("[data-tab-key]")
const direction = event.currentTarget.dataset.direction === "up" ? -1 : 1
const rows = this.rows()
const index = rows.indexOf(row)
const target = index + direction
if (target < 0 || target >= rows.length) {
return
}
direction === -1
? rows[target].before(row)
: rows[target].after(row)
this.syncEdges()
// Focus stays on the control that was pressed, or on its twin when
// this move just made it a dead end — see the account list.
const pressed = event.currentTarget.dataset.direction
const same = row.querySelector(`[data-direction="${pressed}"]`)
const other = row.querySelector(`[data-direction="${pressed === "up" ? "down" : "up"}"]`)
;(same?.disabled ? other : same)?.focus()
this.persist()
}
syncEdges() {
const rows = this.rows()
rows.forEach((row, index) => {
const up = row.querySelector('[data-direction="up"]')
const down = row.querySelector('[data-direction="down"]')
if (up) up.disabled = index === 0
if (down) down.disabled = index === rows.length - 1
})
}
rows() {
return [...this.listTarget.querySelectorAll("[data-tab-key]")]
}
currentKeys() {
return this.rows().map((row) => row.dataset.tabKey)
}
persist() {
if (this.inFlight) {
this.pending = true
return
}
const keys = this.currentKeys()
this.inFlight = true
this.pending = false
fetch(this.urlValue, {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRF-Token": this.csrfValue },
body: JSON.stringify({ keys }),
keepalive: true,
}).then((response) => {
if (requestFailed(response)) {
this.revert()
} else {
this.saved = keys
}
}).catch(() => {
requestFailed(null)
this.revert()
}).finally(() => {
this.inFlight = false
if (this.pending) {
this.persist()
}
})
}
revert() {
this.pending = false
const rows = new Map(this.rows().map((row) => [row.dataset.tabKey, row]))
this.saved.forEach((key) => {
const row = rows.get(key)
if (row) this.listTarget.append(row)
})
this.syncEdges()
}
}
@@ -126,7 +126,7 @@ final class CategorySortingController extends AbstractController
$this->bus->dispatch(new ResortMailboxMessage((int) $user->id)); $this->bus->dispatch(new ResortMailboxMessage((int) $user->id));
} }
return $this->redirectToRoute('app_settings_index', ['section' => 'general']); return $this->redirectToRoute('app_settings_index', ['section' => 'categories']);
} }
/** /**
@@ -163,6 +163,6 @@ final class CategorySortingController extends AbstractController
$this->bus->dispatch(new ReclassifyRecentMessage((int) $user->id, $limit)); $this->bus->dispatch(new ReclassifyRecentMessage((int) $user->id, $limit));
return $this->redirectToRoute('app_settings_index', ['section' => 'general']); return $this->redirectToRoute('app_settings_index', ['section' => 'categories']);
} }
} }
@@ -5,20 +5,28 @@ declare(strict_types=1);
namespace App\Controller\Settings; namespace App\Controller\Settings;
use App\Controller\ChecksCsrf; use App\Controller\ChecksCsrf;
use App\Domain\Enum\Mail\MessageCategory;
use App\Domain\Exception\InvalidInboxCategoryException; use App\Domain\Exception\InvalidInboxCategoryException;
use App\Entity\Mail\InboxCategory; use App\Entity\Mail\InboxCategory;
use App\Entity\User\User; use App\Entity\User\User;
use App\Infrastructure\Messaging\Message\ResortMailboxMessage;
use App\Repository\Mail\MessageThreadRepository;
use App\Security\Voter\OwnershipVoter; use App\Security\Voter\OwnershipVoter;
use App\Service\Mail\InboxCategoryManager; use App\Service\Mail\InboxCategoryManager;
use App\Service\Mail\InboxTabs;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
/** /**
* The inbox tabs a reader makes themselves — Settings → General. * The inbox tabs — Settings → Categories: the reader's own, which built-in ones
* are switched on, and the order of all of them.
* *
* Plain form posts that redirect back to the page, like the sorting card beside * 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 * it: three small writes on a short list do not need a frame of their own, and
@@ -40,9 +48,103 @@ final class InboxCategoryController extends AbstractController
public function __construct( public function __construct(
private readonly InboxCategoryManager $manager, private readonly InboxCategoryManager $manager,
private readonly TranslatorInterface $translator, private readonly TranslatorInterface $translator,
private readonly InboxTabs $tabs,
private readonly MessageThreadRepository $threads,
private readonly EntityManagerInterface $em,
private readonly MessageBusInterface $bus,
) { ) {
} }
/**
* Switch one built-in tab on or off.
*
* Off is not a matter of display: nothing sorts into the category any more
* and its mail moves to Primary (CategorySorting::$disabled), so the change
* queues the same re-sort the other sorting choices do. On queues it too,
* and the mail comes back, because every message's category is recomputed
* from what is stored.
*
* A conversation somebody dragged into the category is released first:
* the re-sort skips pinned conversations, and one pinned to a tab that is
* not there would stay in no tab for ever — the case InboxCategoryManager
* handles for a deleted category of the reader's own.
*/
#[Route('/switch', name: 'switch', methods: ['POST'])]
public function switch(Request $request): Response
{
$this->assertCsrf($request, self::CSRF);
/** @var User $user */
$user = $this->getUser();
$category = MessageCategory::tryFrom((string) $request->request->get('key', ''));
// Primary is refused rather than ignored silently: the page never
// offers it, so a request for it is not one the page made.
if (null === $category || MessageCategory::Primary === $category) {
return $this->back();
}
$on = '1' === (string) $request->request->get('on', '');
$off = array_values(array_filter(
$user->categorySorting->switchedOff(),
static fn (MessageCategory $switched): bool => $switched !== $category,
));
if (false === $on) {
$off[] = $category;
}
if ($off === $user->categorySorting->switchedOff()) {
return $this->back();
}
if (false === $on) {
$this->threads->unpinFromCategory($user, $category);
}
$user->categorySorting->switchOff($off);
$this->em->flush();
// After the flush, so the worker reads the new setting.
$this->bus->dispatch(new ResortMailboxMessage((int) $user->id));
return $this->back();
}
/**
* The order of the tabs after Primary, as the whole list of keys.
*
* Kept to the keys the reader has — a built-in name or one of their own
* categories — so a request cannot plant a key that names somebody else's.
* A tab the list leaves out keeps its place after the ones it names
* (InboxTabs::managedFor()), which makes a partial or duplicated request
* harmless rather than destructive.
*/
#[Route('/order', name: 'order', methods: ['POST'])]
public function order(Request $request): JsonResponse
{
$this->assertCsrf($request, self::CSRF);
$keys = $request->toArray()['keys'] ?? null;
if (false === is_array($keys)) {
return $this->json(['ok' => false], Response::HTTP_BAD_REQUEST);
}
/** @var User $user */
$user = $this->getUser();
$known = array_map(static fn ($tab): string => $tab->value, $this->tabs->managedFor($user));
$user->categorySorting->arrange(array_values(array_filter(
array_map(static fn (mixed $key): string => is_string($key) ? $key : '', $keys),
static fn (string $key): bool => true === in_array($key, $known, true),
)));
$this->em->flush();
return $this->json(['ok' => true]);
}
#[Route('', name: 'create', methods: ['POST'])] #[Route('', name: 'create', methods: ['POST'])]
public function create(Request $request): Response public function create(Request $request): Response
{ {
@@ -100,6 +202,6 @@ final class InboxCategoryController extends AbstractController
private function back(): Response private function back(): Response
{ {
return $this->redirectToRoute('app_settings_index', ['section' => 'general']); return $this->redirectToRoute('app_settings_index', ['section' => 'categories']);
} }
} }
@@ -22,7 +22,7 @@ use App\Repository\Calendar\BookingPageRepository;
use App\Repository\Calendar\CalendarRepository; use App\Repository\Calendar\CalendarRepository;
use App\Repository\Calendar\CalendarShareLinkRepository; use App\Repository\Calendar\CalendarShareLinkRepository;
use App\Repository\Mail\AccountRepository; use App\Repository\Mail\AccountRepository;
use App\Repository\Mail\InboxCategoryRepository; use App\Service\Mail\InboxTabs;
use App\Repository\Push\PushDeliveryRepository; use App\Repository\Push\PushDeliveryRepository;
use App\Repository\User\ApiTokenRepository; use App\Repository\User\ApiTokenRepository;
use App\Repository\User\PushSubscriptionRepository; use App\Repository\User\PushSubscriptionRepository;
@@ -63,7 +63,7 @@ final class SettingsController extends AbstractController
/** Where /settings opens: the first entry in the navigation. */ /** Where /settings opens: the first entry in the navigation. */
private const string DEFAULT_SECTION = 'profile'; private const string DEFAULT_SECTION = 'profile';
private const array SECTIONS = ['health', 'accounts', 'profile', 'security', 'labels', 'calendars', 'sharing', 'filters', 'insights', 'ai', 'integrations', 'appearance', 'aliases', 'read-receipts', 'signature', 'templates', 'app-passwords', 'notifications', 'general', 'reading-pane', 'shortcuts', 'disposal', 'calendar-behaviour']; private const array SECTIONS = ['health', 'accounts', 'profile', 'security', 'labels', 'calendars', 'sharing', 'filters', 'insights', 'ai', 'integrations', 'appearance', 'aliases', 'read-receipts', 'signature', 'templates', 'app-passwords', 'notifications', 'general', 'categories', 'reading-pane', 'shortcuts', 'disposal', 'calendar-behaviour'];
/** /**
* One icon per AI feature, here rather than on the enum. * One icon per AI feature, here rather than on the enum.
@@ -88,7 +88,7 @@ final class SettingsController extends AbstractController
private readonly CalendarShareLinkRepository $shareLinkRepository, private readonly CalendarShareLinkRepository $shareLinkRepository,
private readonly BookingPageRepository $bookingPageRepository, private readonly BookingPageRepository $bookingPageRepository,
private readonly MailRuleRepository $mailRuleRepository, private readonly MailRuleRepository $mailRuleRepository,
private readonly InboxCategoryRepository $inboxCategoryRepository, private readonly InboxTabs $inboxTabs,
private readonly PushSubscriptionRegistry $pushSubscriptionRegistry, private readonly PushSubscriptionRegistry $pushSubscriptionRegistry,
private readonly PushSubscriptionRepository $pushSubscriptions, private readonly PushSubscriptionRepository $pushSubscriptions,
private readonly PushDeliveryRepository $pushDeliveries, private readonly PushDeliveryRepository $pushDeliveries,
@@ -157,10 +157,11 @@ final class SettingsController extends AbstractController
'labels' => $this->labelRepository->findForUserTreeOrdered($this->getUser()), 'labels' => $this->labelRepository->findForUserTreeOrdered($this->getUser()),
'calendars' => $this->calendarRepository->findForUser($this->getUser()), 'calendars' => $this->calendarRepository->findForUser($this->getUser()),
'rules' => $this->mailRuleRepository->findForUserOrdered($this->getUser()), 'rules' => $this->mailRuleRepository->findForUserOrdered($this->getUser()),
// Only the General section draws them, and every other settings page // Only the Categories section draws them, and every other settings
// would otherwise pay a read for a list it never shows. // page would otherwise pay a read for a list it never shows. Every
'inboxCategories' => 'general' === $section // tab, switched off or not, in the reader's order.
? $this->inboxCategoryRepository->findForUserOrdered($this->getUser()) 'categoryTabs' => 'categories' === $section && $user instanceof User
? $this->inboxTabs->managedFor($user)
: [], : [],
'apiTokens' => $this->apiTokenRepository->findForUser($this->getUser()), 'apiTokens' => $this->apiTokenRepository->findForUser($this->getUser()),
'apiTokenForm' => $this->createForm(ApiTokenType::class)->createView(), 'apiTokenForm' => $this->createForm(ApiTokenType::class)->createView(),
@@ -2528,6 +2528,32 @@ class MessageThreadRepository extends ServiceEntityRepository
); );
} }
/**
* Release this reader's conversations pinned to a built-in category, before
* that category is switched off.
*
* The recompute statements skip a pinned conversation, so one dragged into
* a category that is then switched off would never be resolved again. Only
* conversations shown in that category: one in a tab of the reader's own
* keeps its pin, since that tab is still there.
*/
public function unpinFromCategory(\App\Entity\User\User $user, MessageCategory $category): int
{
return (int) $this->getEntityManager()->getConnection()->executeStatement(
<<<'SQL'
UPDATE message_thread t
SET category_pinned_at = NULL
FROM account a
WHERE a.id = t.account_id
AND a.usr_id = :user
AND t.category = :category
AND t.user_category_id IS NULL
AND t.category_pinned_at IS NOT NULL
SQL,
['user' => $user->id, 'category' => $category->value],
);
}
public function recomputeCategoriesForAccount(Account $account): int public function recomputeCategoriesForAccount(Account $account): int
{ {
$sql = <<<'SQL' $sql = <<<'SQL'
@@ -1,69 +0,0 @@
{#
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>
+149
View File
@@ -0,0 +1,149 @@
{#
settings/_inbox_tabs.html.twig
─────────────────────────────────────────────────────────────────────────────
Every inbox tab on one list, in the order the inbox draws them: the built-in
ones with a switch, the reader's own with rename and delete, and all of them
but Primary in an order the reader drags into place.
ONE LIST, NOT TWO. The strip is one row of tabs whichever kind each one is, so
arranging it is one list too — a reader's "Shipping" belongs between Updates
and Promotions if that is where they look for it.
PRIMARY IS NOT IN THE SORTABLE LIST. It is always first and cannot be switched
off: it is the tab the inbox opens on and where a switched-off tab's mail goes.
A row that looked like the others but refused to move would be a puzzle; a row
above the list that says why it stays is not.
A switch posts on change, like every other switch on these pages, and
re-sorts the mailbox (InboxCategoryController::switch). The order is written as
one JSON list after every move (settings--tab-order). Rename, delete and add
are plain posts that come back to this page.
What fills a tab of the reader's own is 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.
Accepts:
tabs InboxTab[] every tab, switched off or not, in order
switchedOff MessageCategory[] the built-ins switched off
#}
{% set segText = 'flex h-full items-center justify-center px-2.5 py-1 text-center text-[11px] leading-tight text-ink-muted
peer-checked:bg-accent peer-checked:text-accent-ink
peer-focus-visible:ring-2 peer-focus-visible:ring-inset peer-focus-visible:ring-accent
transition-colors cursor-pointer' %}
{% set moveButton = 'w-5 h-4 flex items-center justify-center rounded text-ink-faint hover:text-ink hover:bg-hover transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed' %}
<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>
<div class="mt-3 rounded-pane border border-line">
{% set primary = tabs|first %}
<div class="flex items-center gap-3 px-3 py-2.5 border-b border-line" data-tab-fixed="{{ primary.value }}">
<span class="w-5 shrink-0 flex justify-center text-ink-faint" aria-hidden="true">
<i class="fa-solid fa-thumbtack text-[10px]"></i>
</span>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-ink">{{ ('tabs.' ~ primary.value)|trans }}</p>
<p class="text-[11px] text-ink-faint">{{ 'settings.categories.primary_fixed'|trans }}</p>
</div>
</div>
<ul class="divide-y divide-line"
data-controller="settings--tab-order"
data-settings--tab-order-url-value="{{ path('app_settings_categories_order') }}"
data-settings--tab-order-csrf-value="{{ csrf_token('settings-categories') }}"
data-settings--tab-order-target="list">
{% for tab in tabs|slice(1) %}
{% set label = tab.builtIn ? ('tabs.' ~ tab.value)|trans : tab.name %}
{% set isOff = tab.builtIn and tab.builtIn in switchedOff %}
<li class="flex items-center gap-3 px-3 py-2.5" data-tab-key="{{ tab.value }}">
{# Buttons as well as a handle, as on the account list: a drag
is unreachable from a keyboard and fiddly on a phone. #}
<div class="flex flex-col items-center shrink-0">
<button type="button" data-action="settings--tab-order#move" data-direction="up"
class="{{ moveButton }}" {{ loop.first ? 'disabled' : '' }}
aria-label="{{ 'settings.categories.move_up'|trans({ '%tab%': label }) }}">
<i class="fa-solid fa-chevron-up text-[9px]" aria-hidden="true"></i>
</button>
<span class="tab-drag-handle w-5 h-4 flex items-center justify-center text-ink-faint hover:text-ink-muted cursor-grab active:cursor-grabbing touch-none"
title="{{ 'settings.categories.reorder'|trans }}" aria-hidden="true">
<i class="fa-solid fa-grip-vertical text-[10px]"></i>
</span>
<button type="button" data-action="settings--tab-order#move" data-direction="down"
class="{{ moveButton }}" {{ loop.last ? 'disabled' : '' }}
aria-label="{{ 'settings.categories.move_down'|trans({ '%tab%': label }) }}">
<i class="fa-solid fa-chevron-down text-[9px]" aria-hidden="true"></i>
</button>
</div>
{% if tab.builtIn %}
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium {{ isOff ? 'text-ink-faint' : 'text-ink' }}">{{ label }}</p>
<p class="text-[11px] text-ink-faint">
{{ (isOff ? 'settings.categories.built_in_off' : 'settings.categories.built_in')|trans }}
</p>
</div>
<form method="post" action="{{ path('app_settings_categories_switch') }}" data-turbo="false" data-controller="ui--auto-submit" class="shrink-0">
<input type="hidden" name="_token" value="{{ csrf_token('settings-categories') }}">
<input type="hidden" name="key" value="{{ tab.value }}">
<div class="grid grid-flow-col auto-cols-fr rounded-pane border border-line overflow-hidden"
role="radiogroup" aria-label="{{ label }}">
{% for on in [true, false] %}
<label>
<input type="radio" name="on" class="peer sr-only"
value="{{ on ? '1' : '0' }}"
data-action="change->ui--auto-submit#submit"
{{ isOff != on ? 'checked' : '' }}>
<span class="{{ segText }}">{{ (on ? 'settings.categories.on' : 'settings.categories.off')|trans }}</span>
</label>
{% endfor %}
</div>
</form>
{% else %}
<form method="post" action="{{ path('app_settings_categories_rename', { id: tab.categoryId }) }}"
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="{{ tab.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: tab.categoryId }) }}"
data-turbo="false" class="shrink-0"
data-turbo-confirm="{{ 'settings.categories.delete_confirm'|trans({ '%name%': tab.name }) }}">
<input type="hidden" name="_token" value="{{ csrf_token('settings-categories') }}">
<button type="submit"
class="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>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
<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>
+13 -4
View File
@@ -152,6 +152,7 @@
{ section: 'read-receipts', icon: 'fa-solid fa-envelope-open-text', label: 'settings.nav.read_receipts' }, { section: 'read-receipts', icon: 'fa-solid fa-envelope-open-text', label: 'settings.nav.read_receipts' },
{ section: 'signature', icon: 'fa-solid fa-signature' }, { section: 'signature', icon: 'fa-solid fa-signature' },
{ section: 'templates', icon: 'fa-regular fa-file-lines' }, { section: 'templates', icon: 'fa-regular fa-file-lines' },
{ section: 'categories', icon: 'fa-solid fa-inbox' },
{ section: 'labels', icon: 'fa-solid fa-tag' }, { section: 'labels', icon: 'fa-solid fa-tag' },
{ section: 'filters', icon: 'fa-solid fa-filter' }, { section: 'filters', icon: 'fa-solid fa-filter' },
{ section: 'disposal', icon: 'fa-solid fa-trash-can' }, { section: 'disposal', icon: 'fa-solid fa-trash-can' },
@@ -398,6 +399,14 @@
{% endblock %} {% endblock %}
{% endembed %} {% endembed %}
</div>
{% elseif section == 'categories' %}
{# Everything about the inbox tabs on one page: what sorts
mail into them, then the tabs themselves — which built-in
ones are on, the reader's own, and the order of all of
them. The sorting card used to sit under General, and the
tabs it sorts into were a second card beside it. #}
<div class="space-y-4">
{% embed 'settings/_card.html.twig' with { {% embed 'settings/_card.html.twig' with {
heading: 'settings.sorting.heading'|trans, heading: 'settings.sorting.heading'|trans,
subheading: 'settings.sorting.subheading'|trans, subheading: 'settings.sorting.subheading'|trans,
@@ -412,15 +421,15 @@
{% endblock %} {% endblock %}
{% endembed %} {% 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 { {% embed 'settings/_card.html.twig' with {
heading: 'settings.categories.heading'|trans, heading: 'settings.categories.heading'|trans,
subheading: 'settings.categories.subheading'|trans, subheading: 'settings.categories.subheading'|trans,
} %} } %}
{% block body %} {% block body %}
{% include 'settings/_inbox_categories.html.twig' with { categories: inboxCategories } %} {% include 'settings/_inbox_tabs.html.twig' with {
tabs: categoryTabs,
switchedOff: app.user.categorySorting.switchedOff(),
} %}
{% endblock %} {% endblock %}
{% endembed %} {% endembed %}
</div> </div>
@@ -138,7 +138,7 @@ final class CategorySortingTest extends WebTestCase
$this->em->persist($settings); $this->em->persist($settings);
$this->em->flush(); $this->em->flush();
$warning = fn (): int => $client->request('GET', '/settings?section=general') $warning = fn (): int => $client->request('GET', '/settings?section=categories')
->filter('[data-sorting-tabs-warning]') ->filter('[data-sorting-tabs-warning]')
->count(); ->count();
@@ -302,7 +302,7 @@ final class CategorySortingTest extends WebTestCase
private function token(KernelBrowser $client): string private function token(KernelBrowser $client): string
{ {
$crawler = $client->request('GET', '/settings?section=general'); $crawler = $client->request('GET', '/settings?section=categories');
return (string) $crawler return (string) $crawler
->filter('form[action="/settings/sorting"] input[name="_token"]') ->filter('form[action="/settings/sorting"] input[name="_token"]')
@@ -35,27 +35,138 @@ final class InboxCategoryControllerTest extends WebTestCase
parent::tearDown(); parent::tearDown();
} }
public function testTheCardIsOnTheGeneralPageAndListsTheReadersCategories(): void /**
* One list, in the reader's order: Primary fixed above it, then the rest
* — built-ins and their own mixed — with a switched-off built-in still
* listed, so it can be switched back on.
*/
public function testThePageListsEveryTabInTheReadersOrder(): void
{ {
$client = $this->signIn(); $client = $this->signIn();
$this->category($this->user, 'Shipping'); $shipping = $this->category($this->user, 'Shipping');
$crawler = $client->request('GET', '/settings?section=general'); $this->user->categorySorting->arrange(['updates', $shipping->tabKey()]);
$this->user->categorySorting->switchOff([\App\Domain\Enum\Mail\MessageCategory::Forums]);
$this->em->flush();
$crawler = $client->request('GET', '/settings?section=categories');
self::assertResponseIsSuccessful(); self::assertResponseIsSuccessful();
self::assertStringContainsString('Your own tabs', $crawler->text()); self::assertSame('primary', $crawler->filter('[data-tab-fixed]')->attr('data-tab-fixed'));
self::assertSame('Shipping', $crawler->filter('[data-inbox-categories] input[name="name"]')->first()->attr('value')); self::assertSame(
['updates', $shipping->tabKey(), 'social', 'promotions', 'forums'],
$crawler->filter('[data-tab-key]')->each(static fn ($row): string => (string) $row->attr('data-tab-key')),
);
self::assertSame('Shipping', $crawler->filter('[data-tab-key="' . $shipping->tabKey() . '"] input[name="name"]')->attr('value'));
self::assertNotNull(
$crawler->filter('[data-tab-key="forums"] input[name="on"][value="0"]')->attr('checked'),
'the switched-off tab is listed, shown as off',
);
}
/**
* Off releases the conversations dragged into the category — the re-sort
* skips pinned ones, and one pinned to a tab that is not there would be in
* no tab for ever — and queues the re-sort. A conversation pinned to a
* different built-in keeps its pin.
*/
public function testSwitchingABuiltInOffReleasesItsPinsAndResorts(): void
{
$client = $this->signIn();
$account = new \App\Entity\Mail\Account();
$account->usr = $this->user;
$account->name = 'Mail';
$account->email = 'switch-' . uniqid('', true) . '@example.test';
$account->username = $account->email;
$account->authType = \App\Domain\Enum\Account\AuthType::Password->value;
$account->imapHost = 'imap.example.test';
$account->isActive = true;
$this->em->persist($account);
$forum = $this->pinnedThread($account, \App\Domain\Enum\Mail\MessageCategory::Forums);
$updates = $this->pinnedThread($account, \App\Domain\Enum\Mail\MessageCategory::Updates);
$this->em->flush();
$client->request('POST', '/settings/categories/switch', [
'_token' => $this->token($client),
'key' => 'forums',
'on' => '0',
]);
self::assertResponseRedirects('/settings?section=categories');
$this->em->clear();
$user = $this->em->find(User::class, $this->user->id);
self::assertSame(['forums'], $user->categorySorting->disabled);
$pins = $this->connection->fetchAllKeyValue(
'SELECT id, category_pinned_at IS NOT NULL FROM message_thread WHERE id IN (:a, :b)',
['a' => $forum->id, 'b' => $updates->id],
);
self::assertFalse((bool) $pins[$forum->id], 'a conversation pinned to the switched-off tab kept its pin');
self::assertTrue((bool) $pins[$updates->id], 'a pin to another tab was released too');
/** @var \Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport $transport */
$transport = static::getContainer()->get('messenger.transport.maintenance');
$sent = $transport->getSent();
self::assertCount(1, array_filter(
$sent,
static fn ($envelope): bool => $envelope->getMessage() instanceof \App\Infrastructure\Messaging\Message\ResortMailboxMessage,
));
}
/** Primary is where a switched-off tab's mail goes, so it cannot be one. */
public function testPrimaryCannotBeSwitchedOff(): void
{
$client = $this->signIn();
$client->request('POST', '/settings/categories/switch', [
'_token' => $this->token($client),
'key' => 'primary',
'on' => '0',
]);
$this->em->clear();
self::assertSame([], $this->em->find(User::class, $this->user->id)->categorySorting->disabled);
}
/**
* The order keeps only keys the reader has: a key for somebody else's
* category, a made-up one and Primary are dropped, the rest kept in the
* order sent.
*/
public function testTheOrderKeepsOnlyTheReadersOwnKeys(): void
{
$client = $this->signIn();
$mine = $this->category($this->user, 'Shipping');
$stranger = $this->category($this->seedUser('stranger'), 'Theirs');
$client->request(
'POST',
'/settings/categories/order',
server: ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => $this->token($client)],
content: (string) json_encode(['keys' => ['forums', $stranger->tabKey(), 'quatsch', $mine->tabKey(), 'primary', 'social']]),
);
self::assertResponseIsSuccessful();
$this->em->clear();
self::assertSame(
['forums', $mine->tabKey(), 'social'],
$this->em->find(User::class, $this->user->id)->categorySorting->tabOrder,
);
} }
public function testACategoryIsCreatedThroughTheRealForm(): void public function testACategoryIsCreatedThroughTheRealForm(): void
{ {
$client = $this->signIn(); $client = $this->signIn();
$crawler = $client->request('GET', '/settings?section=general'); $crawler = $client->request('GET', '/settings?section=categories');
$form = $crawler->filter('form[action$="/settings/categories"]')->form(['name' => ' Shipping ']); $form = $crawler->filter('form[action$="/settings/categories"]')->form(['name' => ' Shipping ']);
$client->submit($form); $client->submit($form);
self::assertResponseRedirects('/settings?section=general'); self::assertResponseRedirects('/settings?section=categories');
$rows = $this->connection->fetchFirstColumn('SELECT name FROM inbox_category WHERE usr_id = :id', ['id' => $this->user->id]); $rows = $this->connection->fetchFirstColumn('SELECT name FROM inbox_category WHERE usr_id = :id', ['id' => $this->user->id]);
self::assertSame(['Shipping'], $rows); self::assertSame(['Shipping'], $rows);
@@ -70,10 +181,10 @@ final class InboxCategoryControllerTest extends WebTestCase
$client = $this->signIn(); $client = $this->signIn();
$this->category($this->user, 'Shipping'); $this->category($this->user, 'Shipping');
$crawler = $client->request('GET', '/settings?section=general'); $crawler = $client->request('GET', '/settings?section=categories');
$client->submit($crawler->filter('form[action$="/settings/categories"]')->form(['name' => 'shipping'])); $client->submit($crawler->filter('form[action$="/settings/categories"]')->form(['name' => 'shipping']));
self::assertResponseRedirects('/settings?section=general'); self::assertResponseRedirects('/settings?section=categories');
$client->followRedirect(); $client->followRedirect();
self::assertStringContainsString('You already have a category with that name', (string) $client->getResponse()->getContent()); self::assertStringContainsString('You already have a category with that name', (string) $client->getResponse()->getContent());
@@ -88,13 +199,13 @@ final class InboxCategoryControllerTest extends WebTestCase
$client = $this->signIn(); $client = $this->signIn();
$shipping = $this->category($this->user, 'Shipping'); $shipping = $this->category($this->user, 'Shipping');
$crawler = $client->request('GET', '/settings?section=general'); $crawler = $client->request('GET', '/settings?section=categories');
$client->submit($crawler->filter('form[action$="/settings/categories/' . $shipping->id . '/rename"]')->form(['name' => 'Parcels'])); $client->submit($crawler->filter('form[action$="/settings/categories/' . $shipping->id . '/rename"]')->form(['name' => 'Parcels']));
self::assertResponseRedirects(); self::assertResponseRedirects();
self::assertSame('Parcels', $this->connection->fetchOne('SELECT name FROM inbox_category WHERE id = :id', ['id' => $shipping->id])); self::assertSame('Parcels', $this->connection->fetchOne('SELECT name FROM inbox_category WHERE id = :id', ['id' => $shipping->id]));
$crawler = $client->request('GET', '/settings?section=general'); $crawler = $client->request('GET', '/settings?section=categories');
$client->submit($crawler->filter('form[action$="/settings/categories/' . $shipping->id . '/delete"]')->form()); $client->submit($crawler->filter('form[action$="/settings/categories/' . $shipping->id . '/delete"]')->form());
self::assertResponseRedirects(); self::assertResponseRedirects();
@@ -124,7 +235,7 @@ final class InboxCategoryControllerTest extends WebTestCase
public function testAWriteWithoutTheTokenIsRefused(): void public function testAWriteWithoutTheTokenIsRefused(): void
{ {
$client = $this->signIn(); $client = $this->signIn();
$client->request('GET', '/settings?section=general'); $client->request('GET', '/settings?section=categories');
$client->request('POST', '/settings/categories', ['name' => 'Sneaky']); $client->request('POST', '/settings/categories', ['name' => 'Sneaky']);
@@ -162,9 +273,25 @@ final class InboxCategoryControllerTest extends WebTestCase
// ── fixtures ───────────────────────────────────────────────────────────── // ── fixtures ─────────────────────────────────────────────────────────────
private function pinnedThread(\App\Entity\Mail\Account $account, \App\Domain\Enum\Mail\MessageCategory $category): \App\Entity\Mail\MessageThread
{
$thread = new \App\Entity\Mail\MessageThread();
$thread->account = $account;
$thread->subject = $category->value;
$thread->normalizedSubject = $category->value;
$thread->threadingMethod = \App\Domain\Enum\Mail\ThreadingMethod::SubjectFallback;
$thread->lastMessageAt = new \DateTimeImmutable('-1 hour');
$thread->unreadCount = 0;
$thread->category = $category;
$thread->categoryPinnedAt = new \DateTimeImmutable('-1 hour');
$this->em->persist($thread);
return $thread;
}
private function token(KernelBrowser $client): string private function token(KernelBrowser $client): string
{ {
$crawler = $client->request('GET', '/settings?section=general'); $crawler = $client->request('GET', '/settings?section=categories');
return (string) $crawler->filter('input[name="_token"][value]')->last()->attr('value'); return (string) $crawler->filter('input[name="_token"][value]')->last()->attr('value');
} }
+2 -2
View File
@@ -14,7 +14,7 @@ import { ajaxPost, seed } from "./support/config";
const STRIP = 'nav a[href*="tab="]'; const STRIP = 'nav a[href*="tab="]';
async function setTabs(page: Page, option: "Tabs" | "One list"): Promise<void> { async function setTabs(page: Page, option: "Tabs" | "One list"): Promise<void> {
await page.goto("/settings?section=general"); await page.goto("/settings?section=categories");
const group = page.getByRole("radiogroup", { name: "Inbox tabs" }); const group = page.getByRole("radiogroup", { name: "Inbox tabs" });
@@ -22,7 +22,7 @@ async function setTabs(page: Page, option: "Tabs" | "One list"): Promise<void> {
// The control submits on change and comes back to this page. // The control submits on change and comes back to this page.
if (false === (await group.getByRole("radio", { name: option, exact: true }).isChecked())) { if (false === (await group.getByRole("radio", { name: option, exact: true }).isChecked())) {
await Promise.all([ await Promise.all([
page.waitForURL(/section=general/), page.waitForURL(/section=categories/),
group.getByText(option, { exact: true }).click(), group.getByText(option, { exact: true }).click(),
]); ]);
} }
+13 -5
View File
@@ -3778,6 +3778,7 @@ settings:
appearance: Darstellung appearance: Darstellung
calendar_behaviour: Verhalten calendar_behaviour: Verhalten
calendars: Kalender calendars: Kalender
categories: "Kategorien"
disposal: Entsorgen disposal: Entsorgen
filters: Filter filters: Filter
general: Allgemein general: Allgemein
@@ -3988,31 +3989,38 @@ settings:
subheading: 'Was am Ende der Nachrichten steht, die du schreibst.' subheading: 'Was am Ende der Nachrichten steht, die du schreibst.'
categories: categories:
add: Hinzufügen add: Hinzufügen
built_in: "Eingebaut"
built_in_off: "Eingebaut · ausgeschaltet — die Mail liegt in Allgemein"
created: 'Kategorie „%name%“ angelegt.' created: 'Kategorie „%name%“ angelegt.'
delete: Kategorie löschen 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.' 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.' deleted: 'Kategorie „%name%“ gelöscht.'
empty: 'Du hast noch keine eigenen Tabs.'
error: error:
empty: 'Eine Kategorie braucht einen Namen.' empty: 'Eine Kategorie braucht einen Namen.'
reserved: 'Dieser Name gehört zu einem der eingebauten Tabs.' reserved: 'Dieser Name gehört zu einem der eingebauten Tabs.'
taken: 'Du hast bereits eine Kategorie mit diesem Namen.' taken: 'Du hast bereits eine Kategorie mit diesem Namen.'
too_long: 'Dieser Name ist zu lang (höchstens 40 Zeichen).' too_long: 'Dieser Name ist zu lang (höchstens 40 Zeichen).'
filters_link: 'Einstellungen → Filter.' filters_link: 'Einstellungen → Filter.'
heading: Deine eigenen Tabs heading: "Posteingangs-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' hint: "Ein eigener 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"
move_down: "%tab% nach unten"
move_up: "%tab% nach oben"
name_label: Name name_label: Name
off: "Aus"
on: "An"
placeholder: 'z. B. Sendungen' placeholder: 'z. B. Sendungen'
primary_fixed: "Immer zuerst und immer an — Mail aus einem Tab, den du ausschaltest, landet hier."
rename: Umbenennen rename: Umbenennen
renamed: 'Umbenannt in „%name%“.' renamed: 'Umbenannt in „%name%“.'
subheading: 'Posteingangs-Tabs, die du selbst benennst, neben den fünf, die plMail mitbringt.' reorder: "Zum Umsortieren ziehen"
subheading: "Schalte die eingebauten Tabs ein oder aus, leg eigene an und zieh sie in die Reihenfolge, die du willst."
sorting: sorting:
again: again:
button: Nochmal fragen button: Nochmal fragen
hint: Der Assistent antwortet einmal pro Nachricht und behält die Antwort. Frag noch einmal, wenn sich Modell, Anweisung oder eine plMail-Version geändert haben — es kostet einen Modell-Aufruf pro Nachricht und gilt deshalb nur für deine neueste Mail. hint: Der Assistent antwortet einmal pro Nachricht und behält die Antwort. Frag noch einmal, wenn sich Modell, Anweisung oder eine plMail-Version geändert haben — es kostet einen Modell-Aufruf pro Nachricht und gilt deshalb nur für deine neueste Mail.
label: Neueste Mail neu einsortieren label: Neueste Mail neu einsortieren
applies_to_new: Wenn du hier etwas änderst, wird deine vorhandene Mail im Hintergrund neu einsortiert — bei einem großen Postfach dauert das ein paar Minuten, gib den Tabs also einen Moment. Gespräche, die du selbst in einen Tab geschoben hast, bleiben, wo sie sind. applies_to_new: Wenn du hier etwas änderst, wird deine vorhandene Mail im Hintergrund neu einsortiert — bei einem großen Postfach dauert das ein paar Minuten, gib den Tabs also einen Moment. Gespräche, die du selbst in einen Tab geschoben hast, bleiben, wo sie sind.
heading: Kategorien heading: "Sortierung"
override: override:
hint: Ein Gmail-Konto kommt bereits von Google sortiert an. Hier entscheidest du, ob plMail das so lässt oder selbst antwortet. hint: Ein Gmail-Konto kommt bereits von Google sortiert an. Hier entscheidest du, ob plMail das so lässt oder selbst antwortet.
label: Bei Gmail-Konten label: Bei Gmail-Konten
+13 -5
View File
@@ -3765,6 +3765,7 @@ settings:
appearance: Appearance appearance: Appearance
calendar_behaviour: Behaviour calendar_behaviour: Behaviour
calendars: Calendars calendars: Calendars
categories: "Categories"
disposal: Disposal disposal: Disposal
filters: Filters filters: Filters
general: General general: General
@@ -3969,31 +3970,38 @@ settings:
subheading: 'What is added to the bottom of the messages you write.' subheading: 'What is added to the bottom of the messages you write.'
categories: categories:
add: Add add: Add
built_in: "Built in"
built_in_off: "Built in · switched off — its mail is in Primary"
created: 'Created "%name%".' created: 'Created "%name%".'
delete: Delete category delete: Delete category
delete_confirm: 'Delete "%name%"? Mail filed there goes back to the tab it would otherwise be in. No mail is deleted.' delete_confirm: 'Delete "%name%"? Mail filed there goes back to the tab it would otherwise be in. No mail is deleted.'
deleted: 'Deleted "%name%".' deleted: 'Deleted "%name%".'
empty: 'You have no tabs of your own yet.'
error: error:
empty: 'A category needs a name.' empty: 'A category needs a name.'
reserved: 'That name belongs to one of the built-in tabs.' reserved: 'That name belongs to one of the built-in tabs.'
taken: 'You already have a category with that name.' taken: 'You already have a category with that name.'
too_long: 'That name is too long (40 characters at most).' too_long: 'That name is too long (40 characters at most).'
filters_link: 'Settings → Filters.' filters_link: 'Settings → Filters.'
heading: Your own tabs heading: "Inbox 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' hint: "A tab of your own 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"
move_down: "Move %tab% down"
move_up: "Move %tab% up"
name_label: Name name_label: Name
off: "Off"
on: "On"
placeholder: 'e.g. Shipping' placeholder: 'e.g. Shipping'
primary_fixed: "Always first and always on — mail from a tab you switch off lands here."
rename: Rename rename: Rename
renamed: 'Renamed to "%name%".' renamed: 'Renamed to "%name%".'
subheading: 'Inbox tabs you name yourself, beside the five that come with plMail.' reorder: "Drag to reorder"
subheading: "Switch the built-in tabs on or off, add your own, and drag them into the order you want."
sorting: sorting:
again: again:
button: Ask again button: Ask again
hint: The assistant answers once per message and keeps the answer. Ask again after the model, the prompt or a plMail release has changed — it costs a model call per message, so it is bounded to your newest mail. hint: The assistant answers once per message and keeps the answer. Ask again after the model, the prompt or a plMail release has changed — it costs a model call per message, so it is bounded to your newest mail.
label: Sort recent mail again label: Sort recent mail again
applies_to_new: Changing either of these re-files the mail you already have, in the background — a large mailbox takes a few minutes, so give the tabs a moment to settle. Conversations you moved to a tab yourself are left alone. applies_to_new: Changing either of these re-files the mail you already have, in the background — a large mailbox takes a few minutes, so give the tabs a moment to settle. Conversations you moved to a tab yourself are left alone.
heading: Categories heading: "Sorting"
override: override:
hint: A Gmail account arrives already sorted by Google. This decides whether plMail leaves that alone or answers for itself. hint: A Gmail account arrives already sorted by Google. This decides whether plMail leaves that alone or answers for itself.
label: On Gmail accounts label: On Gmail accounts
+13 -5
View File
@@ -3578,6 +3578,7 @@ settings:
appearance: Look o' the ship appearance: Look o' the ship
calendar_behaviour: Behaviour calendar_behaviour: Behaviour
calendars: Almanacs calendars: Almanacs
categories: "Holds"
disposal: Disposal disposal: Disposal
filters: Sieves filters: Sieves
general: General general: General
@@ -3771,31 +3772,38 @@ settings:
subheading: 'What be carved at the foot o'' the scrolls ye write.' subheading: 'What be carved at the foot o'' the scrolls ye write.'
categories: categories:
add: Add add: Add
built_in: "Ship's own"
built_in_off: "Ship's own · struck — its loot be on the Main deck"
created: 'Built the hold "%name%".' created: 'Built the hold "%name%".'
delete: Scuttle the hold 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.' 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%".' deleted: 'Scuttled "%name%".'
empty: 'Ye have no holds o'' yer own yet.'
error: error:
empty: 'A hold needs a name.' empty: 'A hold needs a name.'
reserved: 'That name belongs to one o'' the holds the ship came with.' reserved: 'That name belongs to one o'' the holds the ship came with.'
taken: 'Ye already have a hold by that name.' taken: 'Ye already have a hold by that name.'
too_long: 'That name be too long (forty letters at most).' too_long: 'That name be too long (forty letters at most).'
filters_link: 'Settings → Sieves.' filters_link: 'Settings → Sieves.'
heading: Yer own holds heading: "Tabs o' the inbox"
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' hint: "A tab o' yer own shows in the inbox while it holds letters. Letters go into it through a filter whose order be \"File in category\" — rig one up under"
move_down: "Haul %tab% down"
move_up: "Haul %tab% up"
name_label: Name name_label: Name
off: "Nay"
on: "Aye"
placeholder: 'e.g. Parcels' placeholder: 'e.g. Parcels'
primary_fixed: "Always first an' always flyin' — loot from a tab ye strike lands here."
rename: Rename rename: Rename
renamed: 'Renamed to "%name%".' renamed: 'Renamed to "%name%".'
subheading: 'Holds ye name yerself, beside the five the ship came with.' reorder: "Drag t' reorder"
subheading: "Raise or strike the ship's own tabs, add yer own, an' haul 'em into whatever order ye fancy."
sorting: sorting:
again: again:
button: "Ask again" button: "Ask again"
hint: "Th' oracle answers once per message an' keeps th' answer. Ask again when th' oracle, its orders or a plMail release have changed — it costs an askin' per message, so it be bounded to yer newest post." hint: "Th' oracle answers once per message an' keeps th' answer. Ask again when th' oracle, its orders or a plMail release have changed — it costs an askin' per message, so it be bounded to yer newest post."
label: "Stow recent post again" label: "Stow recent post again"
applies_to_new: "Changin' either o' these re-stows the post ye already have, below decks — a big hold takes a few minutes, so give the tabs a moment. Yarns ye stowed by hand yerself be left be." applies_to_new: "Changin' either o' these re-stows the post ye already have, below decks — a big hold takes a few minutes, so give the tabs a moment. Yarns ye stowed by hand yerself be left be."
heading: Categories heading: "Sortin'"
override: override:
hint: "A Gmail account arrives already stowed by Google. This decides whether plMail leaves it be or answers for itself." hint: "A Gmail account arrives already stowed by Google. This decides whether plMail leaves it be or answers for itself."
label: "On Gmail accounts" label: "On Gmail accounts"
+13 -5
View File
@@ -2899,24 +2899,31 @@ settings:
system: 'système' system: 'système'
categories: categories:
add: "Ajouter" add: "Ajouter"
built_in: "Intégré"
built_in_off: "Intégré · désactivé — son courrier est dans Principal"
created: "Catégorie « %name% » créée." created: "Catégorie « %name% » créée."
delete: "Supprimer la catégorie" delete: "Supprimer la catégorie"
delete_confirm: "Supprimer « %name% » ? Le courrier qui y est classé retourne dans l’onglet où il serait sinon. Aucun courrier n’est supprimé." delete_confirm: "Supprimer « %name% » ? Le courrier qui y est classé retourne dans l’onglet où il serait sinon. Aucun courrier n’est supprimé."
deleted: "Catégorie « %name% » supprimée." deleted: "Catégorie « %name% » supprimée."
empty: "Vous n’avez pas encore d’onglet personnel."
error: error:
empty: "Une catégorie a besoin d’un nom." empty: "Une catégorie a besoin d’un nom."
reserved: "Ce nom appartient à l’un des onglets intégrés." reserved: "Ce nom appartient à l’un des onglets intégrés."
taken: "Vous avez déjà une catégorie de ce nom." taken: "Vous avez déjà une catégorie de ce nom."
too_long: "Ce nom est trop long (40 caractères au maximum)." too_long: "Ce nom est trop long (40 caractères au maximum)."
filters_link: "Réglages → Filtres." filters_link: "Réglages → Filtres."
heading: "Vos propres onglets" heading: "Onglets de la boîte de réception"
hint: "Un onglet apparaît dans la boîte de réception tant qu’il contient du courrier. Le courrier y arrive par un filtre dont l’action est « Classer dans une catégorie » — configurez-en un dans" hint: "Un onglet personnel apparaît dans la boîte de réception tant qu’il contient du courrier. Le courrier y arrive par un filtre dont l’action est « Classer dans une catégorie » — configurez-en un dans"
move_down: "Descendre %tab%"
move_up: "Monter %tab%"
name_label: "Nom" name_label: "Nom"
off: "Désactivé"
on: "Activé"
placeholder: "p. ex. Livraisons" placeholder: "p. ex. Livraisons"
primary_fixed: "Toujours en premier et toujours actif — le courrier d’un onglet que vous désactivez arrive ici."
rename: "Renommer" rename: "Renommer"
renamed: "Renommée en « %name% »." renamed: "Renommée en « %name% »."
subheading: "Des onglets de boîte de réception que vous nommez vous-même, à côté des cinq fournis avec plMail." reorder: "Faire glisser pour réordonner"
subheading: "Activez ou désactivez les onglets intégrés, ajoutez les vôtres et faites-les glisser dans l’ordre que vous voulez."
clock: clock:
heading: Horloge heading: Horloge
subheading: Si les heures s’écrivent au format 12 ou 24 heures, et où l’heure actuelle s’affiche. Rien n’est stocké différemment — seuls les chiffres changent. subheading: Si les heures s’écrivent au format 12 ou 24 heures, et où l’heure actuelle s’affiche. Rien n’est stocké différemment — seuls les chiffres changent.
@@ -3806,6 +3813,7 @@ settings:
appearance: Apparence appearance: Apparence
calendar_behaviour: Comportement calendar_behaviour: Comportement
calendars: Agendas calendars: Agendas
categories: "Catégories"
disposal: Suppression disposal: Suppression
filters: Filtres filters: Filtres
general: Général general: Général
@@ -4017,7 +4025,7 @@ settings:
hint: L’assistant répond une fois par message et garde la réponse. Redemandez après un changement de modèle, d’instruction ou de version de plMail — cela coûte un appel au modèle par message, c’est donc limité à votre courrier le plus récent. hint: L’assistant répond une fois par message et garde la réponse. Redemandez après un changement de modèle, d’instruction ou de version de plMail — cela coûte un appel au modèle par message, c’est donc limité à votre courrier le plus récent.
label: Trier à nouveau le courrier récent label: Trier à nouveau le courrier récent
applies_to_new: Changer l’un de ces réglages reclasse en arrière-plan le courrier que vous avez déjà — une grosse boîte prend quelques minutes, laissez donc un moment aux onglets pour se stabiliser. Les conversations que vous avez vous-même déplacées dans un onglet ne sont pas touchées. applies_to_new: Changer l’un de ces réglages reclasse en arrière-plan le courrier que vous avez déjà — une grosse boîte prend quelques minutes, laissez donc un moment aux onglets pour se stabiliser. Les conversations que vous avez vous-même déplacées dans un onglet ne sont pas touchées.
heading: Catégories heading: "Tri"
override: override:
hint: Un compte Gmail arrive déjà trié par Google. Ceci décide si plMail laisse ce tri tel quel ou répond lui-même. hint: Un compte Gmail arrive déjà trié par Google. Ceci décide si plMail laisse ce tri tel quel ou répond lui-même.
label: Sur les comptes Gmail label: Sur les comptes Gmail