fix(accounts,sidebar): ordering a list stops deciding who you are

Eight defects from one manual pass. Three of them were the same line.

**Order is not identity.** account.sort_order decided where a row sat, which
account was primary, AND which of eight colours its dot was painted with —
all in AccountCreator::resequence(). So dragging a row to tidy the settings
list silently reassigned the address Compose sends from, and swapped two
accounts' identity marks in the sidebar and on every message row at once.
Nothing on screen said either would happen.

The three are separated. sortOrder is display position. isPrimary is a stored
choice with a "Make primary" button that says what it does, and an invariant
(ensurePrimary) closing the two ways the flag can now be lost — deleting the
account that held it, and the very first account, which has nothing to
inherit from. colorIndex is handed out at creation from the lowest free slot
and never moves, which keeps the "first eight accounts are guaranteed
different" property sortOrder was chosen for without inheriting its habit of
changing when the list does. The migration backfills colour from sort_order,
so no existing install sees its dots move on deploy.

The paths that create accounts without AccountCreator — OAuth linking, config
restore, the e2e seeder — now state the primary too, or a user whose only
account is an OAuth one would have none. Backups carry colorIndex, falling
back to sortOrder so an older file restores the colours it actually had.

**CSRF.** The reorder endpoint had none, with a comment explaining why:
@stimulus-components/sortable builds its own multipart body and offers
nowhere to put a token. The rules list had already dropped that wrapper for
exactly this reason. Accounts now do the same — sortablejs in a controller of
our own, the whole order as JSON with an X-CSRF-Token header, which also
makes the write idempotent. The wrapper is unregistered and out of the
importmap rather than left for the next list to reach for, and
StateChangingPostsNeedCsrfTest covers both reorder routes and the new
primary switch.

**The dot.** It was never a status light — it is the account's identity
colour, the same tone its rows wear in the unified lists. But it read
sortOrder, and it had no title, aria-label or legend of any kind, so two
coloured circles could not be interpreted at all and the obvious reading was
status. It now derives from the account, and says out loud which account it
marks; the one piece of state it carries, "switched off", is named too.
Account health remains the place status is reported, and the two no longer
appear to contradict each other because only one of them is claiming to
answer that question.

**Badges.** The rule was right and is unchanged: Trash and Drafts count the
total, everything else counts unread. What was wrong is that saying so relied
on colour alone — both were the same filled pill in the same place, one red
and one a faint grey, and a bare number in a pill is a bare number. The two
kinds now differ in shape as well as tone (filled pill vs outlined tally) and
both carry an accessible name, since to a screen reader they were both simply
the word "five". The names are patched alongside the numbers on every sync.

**Duplicate ids.** The drawer and the desktop rail both render the sidebar
into one document, so every account frame and expand toggle existed twice
under one id — invalid HTML, and Turbo resolves frame targets by id. Each
sidebar's ids carry its own suffix, and the frame id travels with the request
so the reply wears the id of the frame that asked.

**"ich, ich".** ThreadParticipants de-duplicated by address, and an account
owns several — both resolved to "me". Collapsed by name, which also fixes a
correspondent writing from two of their own addresses, and lets a
self-written thread fall back to its recipients as it was meant to.

**Untranslated German.** Settings → Labels printed the English creation name
of system labels while the sidebar beside it printed translations — the two
lists disagreeing on screen at the same time. One |label_name filter now
answers for both, going to the sidebar's own key rather than adding a
parallel table. And the German Updates tab is "Benachrichtigungen".

**Theme preset contrast.** Each label was painted in the palette of the theme
it names: the tile carried data-theme, and every theme block in app.css is
selected by exactly that attribute, so the tile redeclared the whole palette
onto itself. The tiles say data-theme-name now and the blocks are :root-scoped,
so neither half can come back alone. Swatches still preview their own colours,
which is their job. Measured after: worst label 7.58:1, against 1.22:1 for
"Nord" in Solar before.

phpunit 3454 tests / 8957 assertions green; composer stan [OK] with no
baseline growth; 46 e2e across accounts, appearance, filters and labels green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5 committed 2026-08-13 13:01:40 +02:00
1 parent 9eec6cee64
commit ab13ba242a
40 files changed
+1923 -128

No files matched your search

@@ -0,0 +1,110 @@
import { Controller } from "@hotwired/stimulus"
import Sortable from "sortablejs"
/**
* Display order for the account list: drag on a pointer, buttons everywhere else.
*
* Uses sortablejs directly rather than @stimulus-components/sortable, for the
* same reason rules/rule_order_controller does: that wrapper builds its own
* multipart body and offers no way to attach a CSRF token, which is why the
* account reorder endpoint had none for as long as it did. Sending the whole
* order as JSON also makes the write idempotent — a duplicated request lands on
* the same arrangement instead of shifting a row twice.
*
* This order is COSMETIC. It decides where rows sit and nothing else; which
* account new mail is sent from is the "make primary" button on the row, which
* posts its own form. Dragging used to do both silently.
*/
export default class extends Controller {
static targets = ["list"]
static values = { url: String, csrf: String }
connect() {
this.sortable = Sortable.create(this.listTarget, {
handle: ".account-drag-handle",
animation: 150,
// Without this a touch-drag scrolls the page instead of moving the
// row; the handle also carries touch-none for the same reason.
forceFallback: false,
onEnd: () => {
this.syncEdges()
this.persist()
},
})
this.syncEdges()
}
disconnect() {
this.sortable?.destroy()
}
/** Keyboard- and touch-reachable equivalent of a drag. */
move(event) {
const row = event.currentTarget.closest("[data-account-id]")
const direction = event.currentTarget.dataset.direction === "up" ? -1 : 1
const rows = [...this.listTarget.querySelectorAll("[data-account-id]")]
const index = rows.indexOf(row)
const target = index + direction
if (target < 0 || target >= rows.length) {
return
}
// Move in the DOM first so the list is already correct if the request
// is slow; the server is told what the user can already see.
direction === -1
? rows[target].before(row)
: rows[target].after(row)
this.syncEdges()
// Keep focus on the control that was pressed, or a keyboard user loses
// their place after every step. Re-queried because syncEdges() may have
// just disabled this very button — if it did, move focus to the other
// direction rather than leaving it on a dead control.
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()
}
/**
* Re-evaluate which move buttons are dead ends.
*
* The server renders these disabled states from loop.first/loop.last, but a
* move rearranges the DOM without re-rendering — so without this the top
* row keeps an enabled "up" and the row that used to be first has a
* permanently disabled one.
*/
syncEdges() {
const rows = [...this.listTarget.querySelectorAll("[data-account-id]")]
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
})
}
persist() {
const ids = [...this.listTarget.querySelectorAll("[data-account-id]")].map(
(row) => Number(row.dataset.accountId),
)
fetch(this.urlValue, {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRF-Token": this.csrfValue },
body: JSON.stringify({ ids }),
keepalive: true,
}).catch(() => {
// The order on screen is what the user chose; a failed write means
// the next load shows the stored order again, which is honest.
})
}
}
@@ -36,17 +36,27 @@ export default class extends Controller {
this.queue();
}
/**
* The tiles name their theme in `data-theme-name`, NOT `data-theme`.
*
* `data-theme` is the palette switch: every block in app.css that declares
* one is selected by that attribute, so a tile carrying it re-declared the
* whole palette on itself and its label was painted in the colours of the
* theme it names rather than the theme you are looking at. That is what put
* "Nord" on screen at 1.22:1 in Solar. The blocks are :root-scoped now as
* well, so neither half of the fault can come back on its own.
*/
pickTheme(event) {
const theme = event.currentTarget.dataset.theme;
const theme = event.currentTarget.dataset.themeName;
this.themeTarget.value = theme;
this.root.dataset.theme = theme;
this.root.classList.toggle('dark', this.resolvesDark(event.currentTarget));
this.element.querySelectorAll('[data-theme]').forEach((button) => {
button.classList.toggle('ring-2', button.dataset.theme === theme);
button.classList.toggle('ring-accent', button.dataset.theme === theme);
this.element.querySelectorAll('[data-theme-name]').forEach((button) => {
button.classList.toggle('ring-2', button.dataset.themeName === theme);
button.classList.toggle('ring-accent', button.dataset.themeName === theme);
});
// A theme may seed knobs too, the way a layout does. The accent has to
@@ -85,7 +95,7 @@ export default class extends Controller {
* light palette until the next navigation put it back.
*/
resolvesDark(button) {
if (button.dataset.theme === 'system') {
if (button.dataset.themeName === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
+25 -1
View File
@@ -90,7 +90,7 @@ function patchNewDots(counts) {
export default class extends Controller {
static targets = ["link", "badge", "scroller", "tree"];
static values = { countsUrl: String };
static values = { countsUrl: String, i18n: Object };
connect() {
// Both before the first paint of this element, so nothing is seen in
@@ -185,6 +185,13 @@ export default class extends Controller {
badge.textContent = count;
badge.classList.toggle("hidden", count === 0);
// The number and the name it is read out with are one statement.
// Patching only the text left a screen reader saying "3 unread"
// over a badge that had moved on to 7 — and on Trash and Drafts it
// would have said "unread" over a total, which is the very thing
// the two badge shapes exist to keep apart.
this._nameBadge(badge, count);
});
patchNewDots(counts);
@@ -192,6 +199,23 @@ export default class extends Controller {
this._updateTitle(counts);
}
/**
* Re-label one badge for the count it now shows.
*
* A badge with no `data-badge-kind`, or a page that shipped no strings, is
* left exactly as the server rendered it: a wrong name is worse than the
* one that is already correct.
*/
_nameBadge(badge, count) {
const template = this.hasI18nValue ? this.i18nValue[badge.dataset.badgeKind] : null;
if (!template) {
return;
}
badge.setAttribute("aria-label", template.replace("%count%", String(count)));
}
/**
* Keep the "(n)" in the tab in step with the badges.
*
+8 -2
View File
@@ -1,5 +1,4 @@
import { startStimulusApp } from '@symfony/stimulus-bundle';
import Sortable from '@stimulus-components/sortable';
const app = startStimulusApp();
@@ -13,6 +12,13 @@ const app = startStimulusApp();
// prod goes quiet.
app.debug = document.documentElement.dataset.appDebug === 'true';
app.register('sortable', Sortable);
// No global 'sortable' controller any more. @stimulus-components/sortable was
// registered here and driven the accounts list; it builds its own request body
// and gives you nowhere to attach a CSRF token, which is precisely why the
// account reorder endpoint went without one. Both draggable lists — rules and
// accounts — now wrap sortablejs in a controller of their own that posts the
// whole order as JSON with a token, so the wrapper is gone rather than left
// registered for the next list to reach for.
//
// register any custom, 3rd party controllers here
// app.register('some_controller_name', SomeImportedController);
+5 -5
View File
@@ -203,7 +203,7 @@
Everything that separates one thing from another here is the ink colour at
a low alpha, including the shadow: a neutral-black drop shadow under a warm
cream card is the one grey thing on the screen. */
[data-theme="paper"] {
:root[data-theme="paper"] {
color-scheme: light;
--rgb-surface: 247 245 239;
@@ -280,7 +280,7 @@
every primary button paint it behind light text, so it has to be deep
enough to carry them — a pale sage, which is what the reference uses for
40px avatar circles, becomes a washed-out slab nothing can be read on. */
[data-theme="dark"] {
:root[data-theme="dark"] {
color-scheme: dark;
--rgb-surface: 28 27 26;
@@ -349,7 +349,7 @@
to borrow the neutral grey base for --rgb-ink-soft, which was both colder
than everything around it and DARKER than the --rgb-ink-muted below it, so
the two steps ran backwards. */
[data-theme="nord"] {
:root[data-theme="nord"] {
color-scheme: dark;
--rgb-surface: 46 52 64; /* nord0 #2e3440 */
@@ -406,7 +406,7 @@
backwards step Nord's did, for the same reason: --rgb-ink-soft was a
neutral grey inherited from the dark base while the ink either side of it
was tinted. */
[data-theme="dusk"] {
:root[data-theme="dusk"] {
color-scheme: dark;
--rgb-surface: 30 27 46;
@@ -462,7 +462,7 @@
the wrong sixteen: a cool slate --rgb-ink-soft between two warm greys, a
#f3f4f6 inset panel on a #fdf6e3 page, and the base link blue where
Solarized has a blue of its own. */
[data-theme="solar"] {
:root[data-theme="solar"] {
color-scheme: light;
--rgb-surface: 253 246 227; /* base3 #fdf6e3 */
-1
View File
@@ -35,7 +35,6 @@ return [
'tom-select/dist/css/tom-select.default.css' => ['version' => '2.6.2', 'type' => 'css'],
'tom-select/dist/css/tom-select.bootstrap4.css' => ['version' => '2.6.2', 'type' => 'css'],
'tom-select/dist/css/tom-select.bootstrap5.css' => ['version' => '2.6.2', 'type' => 'css'],
'@stimulus-components/sortable' => ['version' => '5.0.3'],
'sortablejs' => ['version' => '1.15.6'],
'@rails/request.js' => ['version' => '0.0.12'],
'emoji-picker-element' => ['version' => '1.29.1'],
+74
View File
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Gives the account dot a colour of its own, and pins the primary that already
* exists.
*
* Three separate things used to be read off account.sort_order: where the row
* sits in the list, which account is primary (position 0), and which entry of
* the eight-colour palette the account's dot is painted with. Dragging a row to
* tidy the list therefore reassigned the address Compose sends from AND swapped
* two accounts' colours — a mark whose only job is "this is the same account
* you saw on that message" changing meaning under the reader.
*
* account.color_index is the palette slot, and it is backfilled FROM sort_order
* so that no existing install sees its colours move on deploy: everyone keeps
* exactly the dot they have today, it simply stops following the drag handle.
*
* is_primary already exists as a column and is already written on every
* resequence, so the flag on disk is correct right now — it just was not
* authoritative. The UPDATE below is the safety net for the case the old
* derivation quietly covered: a user whose rows carry no primary at all (a
* seed, an import, a restore — anything that did not go through
* AccountCreator). Those get the lowest sort_order promoted, which is the same
* account the derivation would have picked, so nobody's From address changes.
*/
final class Version20260813120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds account.color_index (backfilled from sort_order) and guarantees every user has exactly one primary account, so ordering stops deciding both';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE account ADD color_index INT DEFAULT 0 NOT NULL');
// The colour every one of these accounts is wearing today.
$this->addSql('UPDATE account SET color_index = sort_order');
// Exactly one primary per user. Demote any extras first — two primaries
// is a state the old derivation could not produce but an import can,
// and findOneBy() would then pick between them arbitrarily.
$this->addSql(<<<'SQL'
UPDATE account SET is_primary = false
WHERE is_primary = true
AND id NOT IN (
SELECT MIN(id) FROM account WHERE is_primary = true GROUP BY usr_id
)
SQL);
// Then promote the lowest-ordered account of any user left without one.
$this->addSql(<<<'SQL'
UPDATE account SET is_primary = true
WHERE id IN (
SELECT DISTINCT ON (usr_id) id
FROM account
WHERE usr_id NOT IN (SELECT usr_id FROM account WHERE is_primary = true)
ORDER BY usr_id, sort_order ASC, id ASC
)
SQL);
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE account DROP color_index');
}
}
+11
View File
@@ -17,6 +17,7 @@ use App\Repository\Mail\MessageRepository;
use App\Repository\Mail\MessageThreadRepository;
use App\Repository\User\UserRepository;
use App\Service\Label\LabelResolver;
use App\Service\Mail\AccountCreator;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
@@ -72,6 +73,7 @@ final class SeedTestEmailCommand extends Command
private readonly MessageThreadRepository $threadRepository,
private readonly LabelResolver $labelResolver,
private readonly StateManager $stateManager,
private readonly AccountCreator $accountCreator,
#[Autowire('%kernel.environment%')]
private readonly string $environment,
) {
@@ -123,6 +125,15 @@ final class SeedTestEmailCommand extends Command
$this->entityManager->flush();
}
// isPrimary is a stored choice rather than "whichever account sorts
// first", so a hand-built fixture has to state it — otherwise the
// seeded mailbox has no primary, no PRIMARY badge in settings, and does
// not resemble an account any real path would have produced.
$this->accountCreator->ensurePrimary(
$this->accountRepository->findForUserOrdered($user),
);
$this->entityManager->flush();
$this->wipeThreads($account);
$inboxLabel = $this->labelResolver->systemLabel(LabelRole::Inbox, $account);
+25 -1
View File
@@ -462,7 +462,7 @@ final class MailController extends AbstractController
}
#[Route('/account/{account}/folders', name: 'account_folders', requirements: ['account' => '\d+'])]
public function accountFolders(Account $account): Response
public function accountFolders(Request $request, Account $account): Response
{
$this->denyAccessUnlessGranted('ROLE_USER');
@@ -481,9 +481,33 @@ final class MailController extends AbstractController
return $this->render('mail/_account_labels.html.twig', [
'account' => $account,
'labels' => $labels,
'frameId' => $this->folderFrameId($request, $account),
]);
}
/**
* The id the answering frame must wear.
*
* The desktop rail and the mobile drawer both render the sidebar into the
* same document, so each account's folder frame needs an id of its own —
* and Turbo matches a frame response by the id of the frame that asked for
* it, so the id has to come back on the reply too.
*
* Taken from the request rather than derived, because only the caller knows
* which of the two it is; validated hard, because it lands in an id
* attribute. Anything that is not this account's own frame falls back to
* the bare name, which is the desktop one.
*/
private function folderFrameId(Request $request, Account $account): string
{
$default = 'account-folders-' . $account->id;
$requested = (string) $request->query->get('frame', '');
return in_array($requested, [$default, $default . '-drawer'], true)
? $requested
: $default;
}
/**
* Remembers which account's folder list is open, so the sidebar can render
* it already expanded rather than expanding it afterwards.
+93 -19
View File
@@ -143,38 +143,112 @@ final class AccountController extends AbstractController
$this->entityManager->remove($account);
$this->entityManager->flush();
$this->resequence($this->accountRepository->findForUserOrdered($this->getUser()));
$remaining = $this->accountRepository->findForUserOrdered($this->getUser());
$this->resequence($remaining);
// Deleting the primary is one of the two ways the "exactly one" rule
// can be lost now that it is a flag rather than position 0 — the other
// being the very first account, which create() covers. Without this the
// user would be left with accounts and no default sender at all.
$this->accountCreator->ensurePrimary($remaining);
$this->entityManager->flush();
return $this->streamAccountList($request, 'account.removed');
}
#[Route('/{id}/reorder', name: 'reorder', methods: ['PATCH'])]
public function reorder(Request $request, Account $account): Response
/**
* The display order of the account list, and nothing else.
*
* A collection route taking the whole arrangement as JSON, exactly like
* MailRuleController::reorder. It replaces a per-account PATCH driven by
* @stimulus-components/sortable, which built its own multipart body and
* offered nowhere to put a token — so this was the one mutation on the page
* with no CSRF check at all. The rules list had already solved that by
* dropping the wrapper for sortablejs; the accounts list now does the same.
*
* Sending the whole order also makes the write idempotent: a duplicated
* request lands on the same arrangement instead of shifting a row twice.
*
* What it deliberately does NOT do is touch isPrimary. That coupling — top
* of the list means "sends your mail" — is the reported fault: dragging a
* row to tidy a list silently changed which address Compose sent from, with
* nothing on screen saying it would. Choosing the sender is makePrimary()
* below, and it is a button that says what it does.
*/
#[Route('/reorder', name: 'reorder', methods: ['POST'])]
public function reorder(Request $request): JsonResponse
{
$this->denyUnlessOwner($account);
if (false === $this->isCsrfTokenValid('account_reorder', (string) $request->headers->get('X-CSRF-Token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
// No CSRF token here: @stimulus-components/sortable owns the request body
// (it only sends account[position]). The action is authenticated,
// same-origin, and non-destructive. Say the word if you want parity with
// toggle/delete and I'll wire an X-CSRF-Token header + meta tag.
$position = (int) ($request->getPayload()->all('account')['position'] ?? 0);
$ids = $request->toArray()['ids'] ?? null;
$ordered = $this->accountRepository->findForUserOrdered($this->getUser());
if (false === is_array($ids)) {
return $this->json(['ok' => false], Response::HTTP_BAD_REQUEST);
}
// Pull the dragged account out, re-insert at the target index.
$ordered = array_values(array_filter($ordered, static function (Account $candidate) use ($account): bool {
return $candidate->id !== $account->id;
}));
$ids = array_values(array_filter(array_map('intval', $ids)));
$accounts = $this->accountRepository->findForUserOrdered($this->getUser());
$targetIndex = max(0, min(count($ordered), $position - 1));
array_splice($ordered, $targetIndex, 0, [$account]);
// Indexed by id, so an id that is not this user's simply finds nothing
// — the arrangement is built from rows we already fetched as theirs,
// never from the ids the request asked for.
$byId = [];
foreach ($accounts as $account) {
$byId[(int) $account->id] = $account;
}
$ordered = [];
foreach ($ids as $id) {
if (true === array_key_exists($id, $byId)) {
$ordered[] = $byId[$id];
unset($byId[$id]);
}
}
// Anything the payload left out keeps its existing relative order at
// the end, so a stale tab cannot delete rows from the list by omission.
foreach ($byId as $account) {
$ordered[] = $account;
}
$this->resequence($ordered);
$this->entityManager->flush();
return $this->render('account/_reorder.stream.html.twig', [
'manageableAccounts' => $this->accountRepository->findForUserOrdered($this->getUser()),
], new Response(headers: ['Content-Type' => 'text/vnd.turbo-stream.html']));
return $this->json(['ok' => true]);
}
/**
* Choose the account new messages are sent from.
*
* The explicit half of splitting order from identity. It used to be that
* whatever sat at position 0 was primary, so this was reachable only by
* dragging — and a person dragging a row is arranging a list, not changing
* their return address. Now the list order says nothing about it and this
* button is the only thing that does.
*/
#[Route('/{id}/primary', name: 'primary', methods: ['POST'])]
public function makePrimary(Request $request, Account $account): Response
{
$this->denyUnlessOwner($account);
if (false === $this->isCsrfTokenValid('account-primary' . $account->id, (string) $request->request->get('_token'))) {
throw $this->createAccessDeniedException();
}
$this->accountCreator->makePrimary(
$account,
$this->accountRepository->findForUserOrdered($this->getUser()),
);
$this->entityManager->flush();
return $this->streamAccountList($request, 'account.primary_set');
}
#[Route('/test-connection', name: 'test_connection', methods: ['POST'])]
+31
View File
@@ -31,12 +31,43 @@ class Account extends AccountModel
#[ORM\Column(length: 255, nullable: true)]
public ?string $name = null;
/**
* Where the account sits in the user's own arrangement of the list.
*
* DISPLAY ONLY. It used to decide two other things as well — which account
* was primary and which colour the account wore — so tidying the list by
* dragging a row silently reassigned the address Compose sent from, and
* repainted every account dot in the app. Both now live in fields of their
* own; this one moves rows and nothing else.
*/
#[ORM\Column(options: ['default' => 0])]
public int $sortOrder = 0;
/**
* The account Compose starts from. Exactly one per user, chosen explicitly.
*
* Derived from sortOrder === 0 until it was found that a drag rewrote it,
* which is not something a person tidying a list is asking for and which
* the UI never mentioned. AccountCreator::ensurePrimary() keeps the "exactly
* one" part true across creation and deletion.
*/
#[ORM\Column]
public bool $isPrimary = false;
/**
* Which entry of the account palette this account paints its dot with.
*
* Assigned once, at creation, and then left alone — that is the whole point
* of it. The dot was keyed off sortOrder, so a reorder swapped the colours
* of two accounts: the mark whose only job is "this is the same account you
* saw on that message" changed meaning under the user, on the sidebar and
* on every list row at once. Dense and lowest-free at creation, so the first
* eight accounts are still guaranteed distinct, which is why sortOrder was
* used in the first place.
*/
#[ORM\Column(options: ['default' => 0])]
public int $colorIndex = 0;
#[ORM\ManyToOne(inversedBy: 'accounts')]
#[ORM\JoinColumn(nullable: false)]
public ?User $usr = null;
@@ -21,6 +21,7 @@ use App\Entity\Rule\MailRule;
use App\Entity\User\ApiToken;
use App\Entity\User\User;
use App\Repository\User\UserRepository;
use App\Service\Mail\AccountCreator;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Throwable;
@@ -96,6 +97,7 @@ final readonly class ConfigBackupUserRestorer
public function __construct(
private UserRepository $users,
private EntityManagerInterface $entityManager,
private AccountCreator $accountCreator,
) {
}
@@ -257,6 +259,11 @@ final readonly class ConfigBackupUserRestorer
$account->email = $this->text($row, 'email');
$account->sortOrder = $this->number($row, 'sortOrder') ?? 0;
$account->isPrimary = true === ($row['isPrimary'] ?? false);
// Falls back to sortOrder, which is exactly what the colour used to
// be derived from — so a file written before colorIndex existed
// restores the colours that backup's accounts were actually wearing.
$account->colorIndex = $this->number($row, 'colorIndex')
?? $account->sortOrder;
$account->imapHost = $this->text($row, 'imapHost');
$account->imapPort = $this->number($row, 'imapPort');
$account->imapEncryption = $this->text($row, 'imapEncryption');
@@ -288,6 +295,15 @@ final readonly class ConfigBackupUserRestorer
$created[$this->number($row, 'id') ?? 0] = $account;
}
// A backup written before isPrimary was authoritative can carry none at
// all, or — from an install that had drifted — two. Either way the user
// must come out of a restore with exactly one sending account, and the
// one the old position-0 rule would have chosen.
$ordered = $created;
usort($ordered, static fn (Account $a, Account $b): int => $a->sortOrder <=> $b->sortOrder);
$this->accountCreator->ensurePrimary($ordered);
return $created;
}
+5
View File
@@ -336,6 +336,11 @@ final readonly class ConfigBackupUsers
'email' => $account->email,
'sortOrder' => $account->sortOrder,
'isPrimary' => $account->isPrimary,
// The account's dot colour, which is assigned at creation and
// is not recoverable from anything else in this file — restore
// it and every account keeps the mark the user recognises it
// by; leave it out and they all come back sharing colour 0.
'colorIndex' => $account->colorIndex,
'imapHost' => $account->imapHost,
'imapPort' => $account->imapPort,
'imapEncryption' => $account->imapEncryption,
+105 -5
View File
@@ -8,6 +8,7 @@ use App\Entity\Mail\Account;
use App\Entity\User\User;
use App\Repository\Mail\AccountRepository;
use App\Service\Calendar\CalendarProvisioner;
use App\Twig\SenderAvatarExtension;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Throwable;
@@ -61,14 +62,24 @@ final readonly class AccountCreator
$account->isActive = true;
$account->usr = $user;
// Appended, then the whole list renumbered: sortOrder and isPrimary are
// positional, so the first account added has to come out primary
// without anything having to special-case "is this the first one".
$ordered = $this->accounts->findForUserOrdered($user);
$existing = $this->accounts->findForUserOrdered($user);
// The colour is picked BEFORE the account joins the list, from the
// colours already in use — it is the account's own from here on and
// never moves again.
$account->colorIndex = $this->freeColorIndex($existing);
// Appended, then the whole list renumbered, so sortOrder stays the
// dense 0-based position the list is drawn from.
$ordered = $existing;
$ordered[] = $account;
$this->resequence($ordered);
// A first account is primary because there is nothing else it could be,
// not because it landed at position 0.
$this->ensurePrimary($ordered);
$this->entityManager->persist($account);
$this->entityManager->flush();
@@ -114,6 +125,16 @@ final readonly class AccountCreator
}
/**
* Writes sequential sortOrder over the list, in the order given.
*
* DISPLAY ORDER ONLY. This used to set isPrimary on the row that came out
* first, which meant dragging an account to the top of the settings list —
* a gesture about tidying a list — silently changed the address every new
* message would be sent from, with nothing on screen saying so. The two are
* separate now: this moves rows, makePrimary chooses the sender.
*
* Caller is responsible for flushing.
*
* @param iterable<Account> $orderedAccounts
*/
public function resequence(iterable $orderedAccounts): void
@@ -122,9 +143,88 @@ final readonly class AccountCreator
foreach ($orderedAccounts as $account) {
$account->sortOrder = $index;
$account->isPrimary = 0 === $index;
++$index;
}
}
/**
* Exactly one primary, no matter what just happened to the list.
*
* The invariant used to be free: position 0 always existed and was always
* one row. Now that the flag is chosen rather than derived, the two ways it
* can be lost have to be closed by hand — deleting the account that held it,
* and adding the very first account, which has nothing to inherit from. A
* list that has drifted (an import with two, a restore with none) is
* repaired the way the old derivation would have answered it: the
* lowest-ordered account wins, so nobody's From address moves.
*
* Caller is responsible for flushing.
*
* @param iterable<Account> $orderedAccounts in the user's display order
*/
public function ensurePrimary(iterable $orderedAccounts): void
{
$accounts = is_array($orderedAccounts) ? $orderedAccounts : iterator_to_array($orderedAccounts);
$primary = null;
foreach ($accounts as $account) {
if (true === $account->isPrimary && null === $primary) {
$primary = $account;
continue;
}
$account->isPrimary = false;
}
if (null !== $primary || [] === $accounts) {
return;
}
$accounts[array_key_first($accounts)]->isPrimary = true;
}
/**
* Make one account the primary, and the only one.
*
* @param iterable<Account> $siblings every account of the same user
*/
public function makePrimary(Account $account, iterable $siblings): void
{
foreach ($siblings as $sibling) {
$sibling->isPrimary = false;
}
$account->isPrimary = true;
}
/**
* The lowest palette slot nobody is using yet.
*
* The palette has eight entries and the point of the dot is telling
* accounts apart, so "lowest free" keeps the first eight guaranteed
* distinct — the property that made sortOrder attractive here in the first
* place — without inheriting its habit of changing when the list moves.
* Past eight accounts a colour is reused, which is what the modulo in
* SenderAvatarExtension already did.
*
* @param iterable<Account> $existing
*/
private function freeColorIndex(iterable $existing): int
{
$taken = [];
foreach ($existing as $account) {
$taken[$account->colorIndex] = true;
}
for ($index = 0; $index < SenderAvatarExtension::ACCOUNT_COLOURS; ++$index) {
if (false === array_key_exists($index, $taken)) {
return $index;
}
}
return count($taken) % SenderAvatarExtension::ACCOUNT_COLOURS;
}
}
+24 -2
View File
@@ -66,7 +66,7 @@ final class ThreadParticipants
: $this->displayName($message->fromName, $address);
}
return array_values($names);
return $this->collapse(array_values($names));
}
/**
@@ -96,7 +96,29 @@ final class ThreadParticipants
$names[$address] ??= $this->displayName($entry['name'] ?? null, $address);
}
return array_values($names);
return $this->collapse(array_values($names));
}
/**
* One name per person, not one name per address.
*
* De-duplication happens by address first, because that is what identifies
* a participant — but several addresses can resolve to the SAME display
* name, and then the column prints it twice. The reported case is a thread
* carrying two of the reader's own addresses: both map to "me", and the row
* read "me, me". A sender writing from two of their own addresses under one
* display name is the same fault with a different name in it.
*
* The first occurrence wins, so the order participants joined the
* conversation in — which is the whole point of this column — survives.
*
* @param list<string> $names
*
* @return list<string>
*/
private function collapse(array $names): array
{
return array_values(array_unique($names));
}
private function newest(MessageThread $thread): ?Message
+11
View File
@@ -10,6 +10,7 @@ use App\Domain\Exception\AccountIdentityMismatch;
use App\Entity\Mail\Account;
use App\Entity\User\User;
use App\Repository\Mail\AccountRepository;
use App\Service\Mail\AccountCreator;
use App\Service\Mail\AliasSeeder;
use App\Service\Push\PushSubscriptionRegistry;
use DateTimeImmutable;
@@ -32,6 +33,7 @@ final readonly class OAuthAccountLinker
private EntityManagerInterface $em,
private PushSubscriptionRegistry $pushRegistry,
private AliasSeeder $aliasSeeder,
private AccountCreator $accountCreator,
) {
}
@@ -225,6 +227,15 @@ final readonly class OAuthAccountLinker
$this->em->persist($account);
$this->em->flush();
// isPrimary is a stored choice now, not "whichever account sits at
// position 0" — so a path that creates an account without going through
// AccountCreator has to say who the primary is, or a user whose only
// account is an OAuth one ends up with none at all and no badge saying
// which address their mail goes out from. Existing primaries are never
// stolen; this only fills a gap.
$this->accountCreator->ensurePrimary($this->accounts->findForUserOrdered($user));
$this->em->flush();
return $account;
}
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Twig;
use App\Domain\Enum\Mail\LabelRole;
use App\Entity\Label\Label;
use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
/**
* What a label is CALLED on screen, for every surface that shows one.
*
* A system label's `name` column is its English creation name — "Inbox",
* "Trash" — because that is what LabelRole::displayName() seeds the row with
* and what the IMAP side of the app matches on. It is storage, not a caption.
* The sidebar never printed it: its rows are hardcoded `sidebar.nav.*`
* translations, which is why the German sidebar said "Posteingang".
*
* Settings → Labels printed `label.name` straight out of the database, so the
* same six folders read "Inbox / Sent / Drafts…" one panel away from
* "Posteingang / Gesendet / Entwürfe…" — the two lists disagreeing on screen at
* the same time.
*
* So there is now one path to the name and both use it, rather than a second
* translation table parallel to the sidebar's. A user label has no role and no
* translation: its name is the name the user typed, and it is returned as-is.
*/
final class LabelNameExtension extends AbstractExtension
{
public function __construct(private readonly TranslatorInterface $translator)
{
}
public function getFilters(): array
{
return [
new TwigFilter('label_name', $this->name(...)),
new TwigFilter('role_name', $this->roleName(...)),
];
}
public function name(Label $label): string
{
$role = $label->role;
return null === $role
? (string) $label->name
: $this->roleName($role);
}
/**
* The sidebar's own key, deliberately: sharing it is what keeps the two
* lists from drifting, and a `label.system.*` family beside it would be the
* same strings maintained twice.
*/
public function roleName(LabelRole $role): string
{
return $this->translator->trans('sidebar.nav.' . $role->value);
}
}
+24 -12
View File
@@ -79,22 +79,34 @@ final class SenderAvatarExtension extends AbstractExtension
];
}
/** How many distinct account colours there are to hand out. */
public const int ACCOUNT_COLOURS = 8;
/**
* A distinct colour per account, by POSITION rather than by hash.
* A distinct colour per account, ASSIGNED rather than derived.
*
* Accounts are a small ordered set — a handful, arranged by the user — not
* the unbounded stream of senders `accountTone()` was written for. Hashing
* an address into eight buckets makes a collision a coin-flip at four
* accounts (it was: two shared a colour in the sidebar), and a dot whose
* whole job is to tell accounts apart must not put two of them in the same
* paint. `sortOrder` is the dense 0-based position AccountCreator keeps, so
* the first eight accounts are guaranteed different and the same account is
* the same colour wherever it appears — the sidebar dot and the message
* row's dot included, which is the point of it.
* Accounts are a small set — a handful, arranged by the user — not the
* unbounded stream of senders `accountTone()` was written for. Hashing an
* address into eight buckets makes a collision a coin-flip at four accounts
* (it was: two shared a colour in the sidebar), and a dot whose whole job is
* to tell accounts apart must not put two of them in the same paint.
*
* So it was keyed off `sortOrder`, which is dense and 0-based and gave the
* first eight accounts guaranteed-different colours. The cost only showed
* up later: sortOrder is the user's ARRANGEMENT, so dragging a row swapped
* two accounts' colours everywhere at once — the sidebar dot and the corner
* mark on every message row — and a mark that means "this is the same
* account you saw over there" stopped being true of anything the user had
* already learned.
*
* `Account::$colorIndex` is the same dense 0-based number, handed out at
* creation from the lowest free slot and then never touched. Same guarantee,
* and now the same account really is the same colour wherever and whenever
* it appears.
*/
public function accountColor(int $sortOrder): string
public function accountColor(int $colorIndex): string
{
return self::DOT_TONES[(($sortOrder % count(self::DOT_TONES)) + count(self::DOT_TONES)) % count(self::DOT_TONES)];
return self::DOT_TONES[(($colorIndex % count(self::DOT_TONES)) + count(self::DOT_TONES)) % count(self::DOT_TONES)];
}
public function accountTone(?string $seed): string
+32 -5
View File
@@ -1,10 +1,18 @@
{% set _drawerClose = _drawerClose is defined ? _drawerClose : '' %}
{# '' for the desktop rail, '-drawer' for the mobile one. Both render this
partial into the same document, so every id below has to carry it. #}
{% set _idSuffix = _idSuffix is defined ? _idSuffix : '' %}
{% for account in accounts %}
{% set expanded = app.user.expandedAccountId == account.id %}
{% set frameId = 'account-folders-' ~ account.id ~ _idSuffix %}
{# The frame id travels with the request too. Turbo matches the response
against the id of the frame that asked, so a drawer frame fetching a
reply that says `account-folders-20` finds nothing to swap and the
folders never appear. #}
<div data-controller="settings--account-expand"
data-settings--account-expand-folders-url-value="{{ path('app_mail_account_folders', { account: account.id }) }}"
data-settings--account-expand-folders-url-value="{{ path('app_mail_account_folders', { account: account.id, frame: frameId }) }}"
data-settings--account-expand-persist-url-value="{{ path('app_mail_sidebar_account_expanded') }}"
data-settings--account-expand-account-value="{{ account.id }}"
data-settings--account-expand-active-value="{{ expanded ? 'true' : 'false' }}">
@@ -37,8 +45,27 @@
green/grey status light, which made every connected account
identical and told the list dots apart from nothing. Inactive
accounts dim rather than change hue: still recognisably the
same account, visibly asleep. #}
<span class="w-2 h-2 rounded-full flex-shrink-0 {{ account.sortOrder|account_color }}{{ account.isActive ? '' : ' opacity-40' }}"></span>
same account, visibly asleep.
Two things were wrong with it. It read `sortOrder`, so
reordering the accounts swapped their colours and the mark
stopped identifying anything; that is `colorIndex` now, which
is assigned once and never moves. And it had no name of any
kind, so an orange circle beside one address and a green one
beside another could not be interpreted at all — the obvious
reading being a status light, which it is not and never was.
Account health is where status is reported; this says which
account, and now says so out loud. The one piece of state it
does carry — switched off — is named too, and agrees with the
toggle in Settings that put it there. #}
<span class="w-2 h-2 rounded-full flex-shrink-0 {{ account.colorIndex|account_color }}{{ account.isActive ? '' : ' opacity-40' }}"
role="img"
title="{{ account.isActive
? 'sidebar.accounts.colour'|trans({ '%account%': account.displayAddress })
: 'sidebar.accounts.colour_disabled'|trans({ '%account%': account.displayAddress }) }}"
aria-label="{{ account.isActive
? 'sidebar.accounts.colour'|trans({ '%account%': account.displayAddress })
: 'sidebar.accounts.colour_disabled'|trans({ '%account%': account.displayAddress }) }}"></span>
<span class="truncate text-sm">{{ account.displayAddress }}</span>
</a>
{# aria-expanded, because this is a disclosure button and which way
@@ -57,7 +84,7 @@
aria-label="{{ 'sidebar.accounts.toggle'|trans({ '%account%': account.displayAddress }) }}"
title="{{ 'sidebar.accounts.toggle'|trans({ '%account%': account.displayAddress }) }}"
aria-expanded="{{ expanded ? 'true' : 'false' }}"
aria-controls="account-folders-{{ account.id }}"
aria-controls="{{ frameId }}"
data-settings--account-expand-target="toggle"
class="p-2 rounded-lg text-gray-400 cursor-pointer hover:text-ink"
data-action="click->settings--account-expand#toggle"
@@ -73,7 +100,7 @@
arrives one request later is a list that visibly blinks on each
navigation. Any other account still loads its frame on demand. #}
<turbo-frame
id="account-folders-{{ account.id }}"
id="{{ frameId }}"
data-settings--account-expand-target="frame"
class="{{ expanded ? '' : 'hidden' }}"
>
+1 -1
View File
@@ -50,7 +50,7 @@ Variables:
class="fa-solid fa-check w-3.5 text-accent
{{ activeLabels is defined and label.id in activeLabels ? '' : 'invisible' }}"
aria-hidden="true"></i>
<span class="truncate">{{ label.name }}</span>
<span class="truncate">{{ label|label_name }}</span>
</button>
{% endfor %}
{% endif %}
+43 -12
View File
@@ -7,21 +7,36 @@ app_mail_sidebar_counts.
{% macro unread_badge(count, key) %}
<span class="rail-hide text-xs font-semibold px-1.5 py-0.5 rounded-full bg-accent text-accent-ink{{ count > 0 ? '' : ' hidden' }}"
data-ui--sidebar-target="badge"
data-badge-kind="unread"
aria-label="{{ 'sidebar.badge.unread'|trans({ '%count%': count }) }}"
data-count-key="{{ key }}">{{ count }}</span>
{% endmacro %}
{#
The same badge for a number that is a total rather than an unread count —
Trash and Drafts, see SidebarCounts::TOTAL_ROLES.
The badge for a number that is a TOTAL rather than an unread count — Trash and
Drafts, see SidebarCounts::TOTAL_ROLES.
Neutral rather than accented, and that is the whole point of it existing: an
accent badge means "these want you", and 193 items in the bin do not. Wearing
the same red as the Inbox badge is what made the Trash count read as 188 unread
messages demanding attention rather than as how much is in there.
The rule is right: nobody triages a bin, and the useful number there is how much
is in it. What was wrong is that saying so relied on colour alone. Both numbers
were the same filled pill in the same place at the same size, one red and one a
faint grey, and a bare "5" in a pill is a bare "5" — the shape said "badge", not
"how many". Colour is also the one channel a person may not have.
So the two now differ in SHAPE as well as tone: unread is a filled round pill
(it is a call to action and looks like one), a total is an outlined rounded
rectangle that reads as a tally. And both carry an accessible name that says
which kind of number it is, because to a screen reader they were both the word
"five".
#}
{% macro total_badge(count, key) %}
<span class="rail-hide text-xs font-medium px-1.5 py-0.5 rounded-full bg-hover text-ink-muted{{ count > 0 ? '' : ' hidden' }}"
{# text-ink-SOFT, not muted. Muted is the caption ink and measured 3.07:1
against the Solar sidebar at 12px — under half of what AA asks. The soft
ink is 7.5:1 or better in every theme, and this is a number, not a
caption: it is the content of the row's right-hand side. #}
<span class="rail-hide text-xs font-medium tabular-nums px-1.5 py-0.5 rounded-md border border-line text-ink-soft{{ count > 0 ? '' : ' hidden' }}"
data-ui--sidebar-target="badge"
data-badge-kind="total"
aria-label="{{ 'sidebar.badge.total'|trans({ '%count%': count }) }}"
data-count-key="{{ key }}">{{ count }}</span>
{% endmacro %}
@@ -64,7 +79,7 @@ keeps its accessible name either way.
text-ink-faint hover:text-ink hover:bg-hover
opacity-0 group-hover/label:opacity-100 focus-visible:opacity-100
transition-opacity cursor-pointer"
aria-label="{{ 'settings.labels.edit'|trans({ '%name%': label.name }) }}"
aria-label="{{ 'settings.labels.edit'|trans({ '%name%': label|label_name }) }}"
>
<i class="fa-solid fa-pen text-[9px]" aria-hidden="true"></i>
</button>
@@ -99,7 +114,7 @@ keeps its accessible name either way.
The label's own colour dot on the far left is identity,
not status, which is why this cannot be folded into it. #}
<span class="min-w-0 flex items-center gap-1.5">
<span class="truncate">{{ label.name }}</span>
<span class="truncate">{{ label|label_name }}</span>
{{ macros.label_new_dot(label) }}
</span>
{{ macros.unread_badge(sidebarCounts.forLabel(label), 'label:' ~ label.id) }}
@@ -120,7 +135,7 @@ keeps its accessible name either way.
class="flex-1 flex items-center justify-between gap-2 truncate min-w-0"
data-ui--sidebar-target="link"
data-action="{{ drawerClose }}">
<span class="truncate">{{ label.name }}</span>
<span class="truncate">{{ label|label_name }}</span>
{{ macros.unread_badge(sidebarCounts.forLabel(label), 'label:' ~ label.id) }}
</a>
{{ macros.label_edit(label) }}
@@ -146,7 +161,7 @@ App\Twig\NewMailMarkers::roleKey() and ::labelKey(), which build the other half.
{% macro label_new_dot(label) %}
{% import '_partials/_new_dot.html.twig' as shared %}
{{ shared.new_dot(newMail.forLabel(label), 'new:label:' ~ label.id, label.name) }}
{{ shared.new_dot(newMail.forLabel(label), 'new:label:' ~ label.id, label|label_name) }}
{% endmacro %}
{% macro starred_new_dot() %}
@@ -179,6 +194,13 @@ controller instead — the scroll position and which trees are open.
<aside id="{{ _drawerMode is defined and _drawerMode ? 'sidebar-drawer-inner' : 'sidebar' }}"
data-controller="ui--sidebar"
data-ui--sidebar-counts-url-value="{{ path('app_mail_sidebar_counts') }}"
{# The badge names are patched on every sync alongside the numbers, so
the strings have to reach the controller rather than being written in
it. One JSON value, the way every other controller here takes them. #}
data-ui--sidebar-i18n-value="{{ {
unread: 'sidebar.badge.unread'|trans({ '%count%': '%count%' }),
total: 'sidebar.badge.total'|trans({ '%count%': '%count%' }),
}|json_encode }}"
class="flex flex-col w-56 h-full flex-shrink-0 overflow-hidden pane
transition-[width] duration-200
{{ _drawerMode is defined and _drawerMode ? '' : 'shell-chrome' }}">
@@ -410,7 +432,16 @@ controller instead — the scroll position and which trees are open.
</div>
<div id="{{ _drawerMode is defined and _drawerMode ? 'account-list-drawer' : 'account-list' }}" class="rail-hide space-y-0.5">
{% include '_partials/_account_list.html.twig' with { _drawerClose: _drawerClose } %}
{# The same suffix the list containers either side of this use. Both
sidebars render this partial on every page, so without it each
account's folder frame and expand toggle existed twice under one
id — invalid HTML, and Turbo resolves a frame target BY id, so
the drawer and the desktop rail were one bad guess apart from
swapping folder lists. #}
{% include '_partials/_account_list.html.twig' with {
_drawerClose: _drawerClose,
_idSuffix: _drawerMode is defined and _drawerMode ? '-drawer' : '',
} %}
</div>
</nav>
+1 -1
View File
@@ -181,7 +181,7 @@ Threads only. A bare Message row is a message, and only a conversation arrives.
same everywhere it appears, only in a unified multi-account list. #}
{% if showAccountDot %}
{% set accountName = rowAccount.name ?: (rowAccount.email ?: rowAccount.username) %}
<span class="pointer-events-none absolute bottom-0 left-0 h-2.5 w-2.5 {{ rowAccount.sortOrder|account_color }} [clip-path:polygon(0_0,0_100%,100%_100%)]"
<span class="pointer-events-none absolute bottom-0 left-0 h-2.5 w-2.5 {{ rowAccount.colorIndex|account_color }} [clip-path:polygon(0_0,0_100%,100%_100%)]"
title="{{ 'thread_row.account'|trans({ '%account%': accountName }) }}"
aria-hidden="true"></span>
{% endif %}
@@ -1,15 +0,0 @@
<turbo-stream action="replace" target="account-list">
<template>
<div id="account-list" class="space-y-0.5">
{% include '_partials/_account_list.html.twig' %}
</div>
</template>
</turbo-stream>
<turbo-stream action="replace" target="settings-account-list">
<template>
<div id="settings-account-list">
{% include 'settings/_accounts.html.twig' with { manageableAccounts: manageableAccounts } %}
</div>
</template>
</turbo-stream>
@@ -30,7 +30,7 @@
class="nav-item flex items-center justify-between py-1.5 text-xs transition-colors text-ink-muted"
style="--label-depth: {{ 12 + label.depth * 12 }}px"
>
<span class="truncate">{{ label.name }}</span>
<span class="truncate">{{ label|label_name }}</span>
{% if not label.isSystem %}
{# Counted within this account, and keyed that way too: the
sidebar's own label rows carry `label:<id>` and mean every
+7 -2
View File
@@ -1,5 +1,10 @@
{# The frame response for an account the user has just expanded. The rows are
shared with the sidebar's own inline render — see _account_folder_rows. #}
<turbo-frame id="account-folders-{{ account.id }}">
shared with the sidebar's own inline render — see _account_folder_rows.
`frameId` rather than a literal: the desktop rail and the mobile drawer each
render a frame for this account and they cannot share an id, so the reply has
to wear whichever one asked. MailController::folderFrameId() decides, and
only ever returns one of the two. #}
<turbo-frame id="{{ frameId|default('account-folders-' ~ account.id) }}">
{% include 'mail/_account_folder_rows.html.twig' with { account: account, labels: labels } only %}
</turbo-frame>
+72 -21
View File
@@ -3,24 +3,48 @@
{{ 'settings.accounts.empty'|trans }}
</p>
{% else %}
{# Says what dragging does, before it is dragged. This list used to reassign
the primary account as a side effect of being tidied, and nothing on
screen mentioned it; the honest fix is that it no longer does, but the
list should still say which of the two it is. #}
<p class="px-4 pt-3 pb-1 text-xs text-ink-faint">{{ 'settings.accounts.order_hint'|trans }}</p>
{# sortablejs by way of our own controller, not @stimulus-components/sortable:
that wrapper builds its own request body with nowhere to put a CSRF
token, which is exactly why this endpoint went without one. The rules
list had already moved off it for the same reason. #}
<ul class="divide-y divide-line"
data-controller="sortable"
data-sortable-handle-value=".account-drag-handle"
data-sortable-resource-name-value="account"
data-sortable-response-kind-value="turbo-stream"
data-sortable-animation-value="150">
data-controller="settings--account-order"
data-settings--account-order-url-value="{{ path('app_account_reorder') }}"
data-settings--account-order-csrf-value="{{ csrf_token('account_reorder') }}"
data-settings--account-order-target="list">
{% for account in manageableAccounts %}
{% set isPassword = account.authType == 'password' %}
<li class="flex items-center gap-3 px-4 py-3.5"
data-sortable-update-url="{{ path('app_account_reorder', { id: account.id }) }}">
<li class="flex items-center gap-3 px-4 py-3.5" data-account-id="{{ account.id }}">
<button
type="button"
class="account-drag-handle shrink-0 w-5 h-8 flex items-center justify-center text-ink-faint hover:text-ink-muted cursor-grab active:cursor-grabbing touch-none"
aria-label="{{ 'settings.accounts.reorder'|trans }}" title="{{ 'settings.accounts.reorder'|trans }}"
>
<i class="fa-solid fa-grip-vertical text-xs" aria-hidden="true"></i>
</button>
{# Buttons as well as a handle: a drag is unreachable from a
keyboard and fiddly on a phone, and the rules list beside
this one already worked that way. #}
<div class="flex flex-col items-center shrink-0">
<button type="button" data-action="settings--account-order#move" data-direction="up"
class="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"
{{ loop.first ? 'disabled' : '' }}
aria-label="{{ 'settings.accounts.move_up'|trans({ '%account%': account.displayAddress }) }}">
<i class="fa-solid fa-chevron-up text-[9px]" aria-hidden="true"></i>
</button>
<span class="account-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.accounts.reorder'|trans }}" aria-hidden="true">
<i class="fa-solid fa-grip-vertical text-[10px]"></i>
</span>
<button type="button" data-action="settings--account-order#move" data-direction="down"
class="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"
{{ loop.last ? 'disabled' : '' }}
aria-label="{{ 'settings.accounts.move_down'|trans({ '%account%': account.displayAddress }) }}">
<i class="fa-solid fa-chevron-down text-[9px]" aria-hidden="true"></i>
</button>
</div>
<span class="w-2.5 h-2.5 rounded-full shrink-0 {{ account.isActive ? 'bg-green-400' : 'bg-ink-faint' }}"
title="{{ account.isActive ? 'settings.accounts.status.active'|trans : 'settings.accounts.status.disabled'|trans }}"></span>
@@ -31,14 +55,17 @@
{# Read the flag, not the position. This badge used to
be `loop.first`, which made it a statement about
whatever order the list happened to arrive in — and
the settings page sorts alphabetically while
`isPrimary` is derived from the user's own
arrangement (AccountCreator::resequence). The two
disagreed whenever the alphabet did, so the account
badged PRIMARY here and the account compose actually
sent from were different accounts. #}
`isPrimary` was then itself derived from position, so
the badge moved when the list was dragged and took
the user's From address with it.
The flag is now chosen rather than derived, and the
badge reports it. It is also a title, because "which
account sends my mail" is not something a coloured
pill on its own answers. #}
{% if account.isPrimary %}
<span class="shrink-0 inline-flex items-center rounded-full bg-blue-500/10 px-2 py-0.5 text-[11px] font-medium text-accent">
<span class="shrink-0 inline-flex items-center rounded-full bg-accent-soft px-2 py-0.5 text-[11px] font-medium text-accent"
title="{{ 'settings.accounts.primary_explained'|trans }}">
{{ 'settings.accounts.primary'|trans }}
</span>
{% endif %}
@@ -52,6 +79,30 @@
</span>
<div class="flex items-center gap-1 shrink-0">
{# The explicit way to choose the sending account, which is
the half that was missing: the only way to change it used
to be to drag a row to the top, which is not something a
person tidying a list is asking for and which the list
never said it would do.
Only offered on an account that could actually send —
a disabled account as the default From would be a From
nothing can go out of. #}
{% if not account.isPrimary and account.isActive %}
<form method="post" action="{{ path('app_account_primary', { id: account.id }) }}">
<input type="hidden" name="_token" value="{{ csrf_token('account-primary' ~ account.id) }}">
<button
type="submit"
class="h-8 px-2 flex items-center gap-1.5 rounded-lg text-[11px] font-medium text-ink-faint hover:text-accent hover:bg-hover transition-colors cursor-pointer"
title="{{ 'settings.accounts.make_primary_explained'|trans({ '%account%': account.displayAddress }) }}"
>
<i class="fa-regular fa-star text-[10px]" aria-hidden="true"></i>
<span class="hidden sm:inline">{{ 'settings.accounts.make_primary'|trans }}</span>
<span class="sr-only sm:hidden">{{ 'settings.accounts.make_primary_explained'|trans({ '%account%': account.displayAddress }) }}</span>
</button>
</form>
{% endif %}
{% if isPassword %}
<button
type="button"
+8 -1
View File
@@ -16,8 +16,15 @@
<div class="grid grid-cols-3 sm:grid-cols-6 gap-3">
{% for theme in enum('App\\Domain\\Enum\\Theme\\Theme').cases %}
{% set swatch = theme.swatch %}
{# data-theme-NAME. `data-theme` is the palette switch (every
theme block in app.css is selected by it), so a tile wearing
it declared that theme's whole palette on itself and the
label below inherited it — "Nord" written in Nord's ink on
Solar's cream, 1.22:1. The swatch above still previews the
theme, because those are inline colours on that element
alone and preview is its whole job. #}
<button type="button"
data-theme="{{ theme.value }}"
data-theme-name="{{ theme.value }}"
data-dark="{{ theme.isDark ? '1' : '0' }}"
data-theme-defaults="{{ theme.defaults|json_encode }}"
data-action="click->settings--appearance#pickTheme"
+13 -5
View File
@@ -59,8 +59,16 @@ Variables:
{% endif %}
<span class="flex-1 min-w-0 flex items-center gap-2">
{# |label_name, not .name. A system label's name column is
its English creation name, so this list read "Inbox /
Sent / Drafts" while the sidebar one panel away read
"Posteingang / Gesendet / Entwürfe" — the same six
folders, disagreeing on screen at the same time. The
filter goes to the sidebar's own translation key, so
there is one name and not two. A user label has no role
and comes back unchanged. #}
<span class="truncate text-sm {{ label.isVisible ? 'text-ink' : 'text-ink-faint' }}">
{{ label.name }}
{{ label|label_name }}
</span>
{% if label.isSystem %}
<span class="inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium bg-zinc-500/10 text-ink-muted">
@@ -79,7 +87,7 @@ Variables:
data-ui--modal-title-value="{{ 'label.form.edit_title'|trans }}"
data-ui--modal-src-value="{{ path('app_label_edit', { id: label.id }) }}"
class="w-8 h-8 flex items-center justify-center rounded-lg text-zinc-400 hover:text-ink hover:bg-hover transition-colors cursor-pointer"
aria-label="{{ 'settings.labels.edit'|trans({ '%name%': label.name }) }}"
aria-label="{{ 'settings.labels.edit'|trans({ '%name%': label|label_name }) }}"
title="{{ 'label.form.edit_title'|trans }}"
>
<i class="fa-solid fa-pen text-xs" aria-hidden="true"></i>
@@ -87,13 +95,13 @@ Variables:
<form method="post" action="{{ path('app_label_delete', { id: label.id }) }}"
data-turbo-confirm="{{ label.children|length > 0
? 'label.delete_confirm_children'|trans({ '%name%': label.name, '%count%': label.children|length })
: 'label.delete_confirm'|trans({ '%name%': label.name }) }}">
? 'label.delete_confirm_children'|trans({ '%name%': label|label_name, '%count%': label.children|length })
: 'label.delete_confirm'|trans({ '%name%': label|label_name }) }}">
<input type="hidden" name="_token" value="{{ csrf_token('label-delete' ~ label.id) }}">
<button
type="submit"
class="w-8 h-8 flex items-center justify-center rounded-lg text-zinc-400 hover:text-danger hover:bg-danger-soft transition-colors cursor-pointer"
aria-label="{{ 'settings.labels.delete'|trans({ '%name%': label.name }) }}"
aria-label="{{ 'settings.labels.delete'|trans({ '%name%': label|label_name }) }}"
title="{{ 'label.form.delete'|trans }}"
>
<i class="fa-solid fa-trash text-xs" aria-hidden="true"></i>
+106 -1
View File
@@ -138,7 +138,7 @@ final class BadgeSemanticsTest extends WebTestCase
$classes = (string) $badge->first()->attr('class');
self::assertStringNotContainsString('bg-accent', $classes, 'a total must not wear the unread accent');
self::assertStringContainsString('text-ink-muted', $classes, 'it should read as neutral');
self::assertStringContainsString('text-ink-soft', $classes, 'it should read as neutral');
}
public function testTheInboxBadgeStillWearsTheAccent(): void
@@ -153,6 +153,111 @@ final class BadgeSemanticsTest extends WebTestCase
self::assertStringContainsString('bg-accent', (string) $badge->first()->attr('class'));
}
/**
* Drafts is the other total role and was never asserted, which is how it
* came to be reported alongside Trash.
*/
public function testTheDraftsBadgeIsNotStyledAsUnreadEither(): void
{
$client = $this->signedIn();
$client->request('GET', '/mail/inbox');
$classes = (string) $client->getCrawler()
->filter('[data-count-key="role:drafts"]')
->first()
->attr('class');
self::assertStringNotContainsString('bg-accent', $classes);
self::assertStringContainsString('text-ink-soft', $classes);
}
/**
* The rule was right; saying so out loud was not.
*
* The follow-up report: "Entwürfe 5" and "Papierkorb 2" are totals, "Posteingang
* 5" is an unread count, and all three were the same filled pill in the same
* place at the same size. One was red and one was a faint grey, and a bare
* number in a pill is a bare number — the shape said "badge", not "how many".
* Colour alone is also the one channel a reader may not have.
*
* So the two kinds now differ in SHAPE as well as tone: a call to action is a
* filled round pill, a tally is an outlined rectangle. Asserted as "the class
* lists differ in the roundness", which is the part a person actually sees,
* rather than as an exact string.
*/
public function testATotalAndAnUnreadCountAreNotTheSameShape(): void
{
$client = $this->signedIn();
$client->request('GET', '/mail/inbox');
$crawler = $client->getCrawler();
$unread = (string) $crawler->filter('[data-count-key="role:inbox"]')->first()->attr('class');
$total = (string) $crawler->filter('[data-count-key="role:trash"]')->first()->attr('class');
self::assertStringContainsString('rounded-full', $unread, 'an unread count is a pill');
self::assertStringNotContainsString('rounded-full', $total, 'a total must not be the same pill');
self::assertStringContainsString('border', $total, 'and reads as an outlined tally instead');
}
/**
* And to a screen reader, where colour and shape both count for nothing,
* they were both simply the word "five".
*/
public function testEveryBadgeSaysWhichKindOfNumberItIs(): void
{
$client = $this->signedIn();
$client->request('GET', '/mail/inbox');
$badges = $client->getCrawler()->filter('[data-ui--sidebar-target="badge"]');
self::assertGreaterThan(0, $badges->count());
$badges->each(static function ($node): void {
$key = (string) $node->attr('data-count-key');
$kind = (string) $node->attr('data-badge-kind');
$name = (string) $node->attr('aria-label');
self::assertContains($kind, ['unread', 'total'], sprintf('"%s" has no badge kind', $key));
self::assertNotSame('', $name, sprintf('"%s" is an unnamed number', $key));
self::assertStringContainsString(
trim((string) $node->text()),
$name,
sprintf('"%s" is named with a different number than it shows', $key),
);
});
}
/**
* The kinds have to agree with the rule rather than being decorated by
* hand — a Trash badge marked "unread" would be back to the reported fault
* with an accessible name confirming it.
*/
public function testTheBadgeKindsMatchTheTotalRolesRule(): void
{
$client = $this->signedIn();
$client->request('GET', '/mail/inbox');
$crawler = $client->getCrawler();
foreach ([LabelRole::Trash, LabelRole::Drafts] as $role) {
self::assertSame(
'total',
$crawler->filter('[data-count-key="role:' . $role->value . '"]')->first()->attr('data-badge-kind'),
$role->value . ' counts the total, so it must say so',
);
}
self::assertSame(
'unread',
$crawler->filter('[data-count-key="role:inbox"]')->first()->attr('data-badge-kind'),
);
}
// ── the new-mail dots, which share this payload ───────────────────────
/**
@@ -0,0 +1,268 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Settings;
use App\Domain\Enum\Mail\LabelRole;
use App\Tests\Support\Mail\SeedsMarkerFixtures;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\DomCrawler\Crawler;
/**
* Two panels of one app, on screen at the same time, saying the same things.
*
* Two reports land here and they are the same shape: something the sidebar
* renders and something else renders were built by different code, so they
* drifted.
*
* - Settings → Labels listed "Inbox, Sent, Drafts, Spam, Trash, Archive"
* while the German sidebar beside it read "Posteingang, Gesendet,
* Entwürfe, Spam, Papierkorb, Archiv". A system label's `name` column is
* its English creation name; the sidebar never printed it (its rows are
* hardcoded `sidebar.nav.*` translations) and the settings list did.
* - The desktop rail and the mobile drawer both render `_sidebar.html.twig`
* into the same document, so every account's folder frame and expand
* toggle existed twice under one id. Duplicate ids are invalid HTML and
* Turbo resolves frame targets BY id.
*
* Against a fixture user of its own rather than the shared admin seed: both
* questions need accounts and system labels to exist, and the admin seed has
* neither reliably — which is how a test asserting "no duplicate account frame
* ids" can pass on a page that renders no accounts at all.
*/
final class SidebarAndSettingsAgreeTest extends WebTestCase
{
use SeedsMarkerFixtures;
private ?Connection $connection = null;
/** The six the report named, in the order the settings list shows them. */
private const array SYSTEM_LABELS = [
['Inbox', LabelRole::Inbox],
['Sent', LabelRole::Sent],
['Drafts', LabelRole::Drafts],
['Spam', LabelRole::Spam],
['Trash', LabelRole::Trash],
['Archive', LabelRole::Archive],
];
private function fixtureClient(int $accounts = 2): 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();
for ($n = 0; $n < $accounts; ++$n) {
$this->account = $this->seedAccount();
}
foreach (self::SYSTEM_LABELS as [$name, $role]) {
$label = $this->seedLabel($name, $role);
if (LabelRole::Inbox === $role) {
$this->inbox = $label;
}
}
$client->loginUser($this->user);
return $client;
}
protected function tearDown(): void
{
if (null !== $this->connection && true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
$this->connection = null;
parent::tearDown();
}
// ── the system folder names ───────────────────────────────────────────
/**
* The reported contradiction, as an assertion: with the UI in German, the
* settings list must not be printing the English creation names.
*/
public function testSettingsLabelsDoNotShowTheEnglishCreationNames(): void
{
$client = $this->german();
$client->request('GET', '/settings?section=labels');
self::assertResponseIsSuccessful();
$shown = $this->texts($client->getCrawler(), '#settings-label-list li span.truncate');
self::assertNotEmpty($shown, 'the labels panel rendered no rows');
// Spam is deliberately absent: its German name IS "Spam", so it is the
// one row that cannot tell a translated list from an untranslated one.
foreach (['Inbox', 'Sent', 'Drafts', 'Trash', 'Archive'] as $creationName) {
self::assertNotContains(
$creationName,
$shown,
sprintf('Settings → Labels still prints the creation name "%s"', $creationName),
);
}
}
/**
* And the stronger half: whatever the sidebar calls a folder, this list
* calls it too. Read off the sidebar's own rendering rather than compared
* against fixed German strings, so it keeps meaning something when the
* wording changes.
*/
public function testSettingsLabelsUseTheSameNamesAsTheSidebar(): void
{
$client = $this->german();
$client->request('GET', '/settings?section=labels');
self::assertResponseIsSuccessful();
$crawler = $client->getCrawler();
$settingsRows = $this->texts($crawler, '#settings-label-list li span.truncate');
self::assertNotEmpty($settingsRows, 'the labels panel rendered no rows');
// Read off what the sidebar ACTUALLY rendered, found by the route each
// row links to rather than by its text — the text is the thing under
// test. Inbox, Sent, Drafts and Trash are always in both lists, so they
// are the four the two surfaces can be held to naming identically.
$router = static::getContainer()->get('router');
foreach (['app_mail_inbox', 'app_mail_sent', 'app_mail_drafts', 'app_mail_trash'] as $route) {
$row = $crawler->filter(sprintf('#sidebar nav a[href="%s"] .rail-hide', $router->generate($route)));
self::assertGreaterThan(0, $row->count(), sprintf('the sidebar has no row for %s', $route));
$name = trim($row->first()->text());
self::assertContains(
$name,
$settingsRows,
sprintf('the sidebar calls it "%s" and Settings → Labels does not', $name),
);
}
}
// ── the duplicated ids ────────────────────────────────────────────────
/**
* Every id in the mailbox shell is unique.
*
* Reported against `account-folders-20` and `-22` and the expand toggles,
* each of which appeared twice: once in the mobile drawer and once in the
* desktop rail. Nothing was seen misbehaving, which is rather the point —
* Turbo finds a frame by id and nothing guarantees it finds the one the
* user is looking at.
*
* Asserted over the whole document rather than that one pair, because the
* cause is a partial rendered twice and the next id added to it would have
* exactly the same problem.
*/
public function testTheMailboxShellHasNoDuplicateIds(): void
{
$client = $this->fixtureClient();
$client->request('GET', '/mail/inbox');
self::assertResponseIsSuccessful();
$ids = $client->getCrawler()->filter('[id]')->each(
static fn (Crawler $node): string => (string) $node->attr('id'),
);
self::assertNotEmpty($ids);
$duplicates = array_keys(array_filter(
array_count_values($ids),
static fn (int $count): bool => $count > 1,
));
self::assertSame([], $duplicates, 'rendered more than once: ' . implode(', ', $duplicates));
}
/** Both sidebars really are rendering their own copy, or the above is vacuous. */
public function testBothSidebarsRenderAFolderFrameForEveryAccount(): void
{
$client = $this->fixtureClient(accounts: 2);
$client->request('GET', '/mail/inbox');
$frames = $client->getCrawler()->filter('turbo-frame[id^="account-folders-"]')->each(
static fn (Crawler $node): string => (string) $node->attr('id'),
);
self::assertCount(4, $frames, 'two accounts in two sidebars is four distinct frames');
self::assertSame($frames, array_values(array_unique($frames)));
self::assertCount(2, array_filter($frames, static fn (string $id): bool => str_ends_with($id, '-drawer')));
}
/**
* A unique id is no use if aria-controls now points at the other sidebar's
* frame, or at nothing.
*/
public function testEveryAccountToggleControlsAFrameThatExists(): void
{
$client = $this->fixtureClient();
$client->request('GET', '/mail/inbox');
$crawler = $client->getCrawler();
$ids = $crawler->filter('[id]')->each(static fn (Crawler $node): string => (string) $node->attr('id'));
$controls = $crawler->filter('[aria-controls^="account-folders-"]')->each(
static fn (Crawler $node): string => (string) $node->attr('aria-controls'),
);
self::assertNotEmpty($controls, 'no account toggles were rendered');
foreach ($controls as $target) {
self::assertContains($target, $ids, sprintf('the toggle points at "%s", which is not on the page', $target));
}
// Each toggle controls its own frame and no two share one.
self::assertSame($controls, array_values(array_unique($controls)));
}
/**
* The locale is the user's, applied by UserLocaleSubscriber on every
* request — calling setLocale() on the translator by hand does not survive
* the next one, which is how a version of this test came to compare English
* against English and pass while the bug was still there.
*/
private function german(): KernelBrowser
{
$client = $this->fixtureClient();
$this->user->locale = 'de';
$this->em->flush();
return $client;
}
/**
* @return list<string>
*/
private function texts(Crawler $crawler, string $selector): array
{
return $crawler->filter($selector)->each(static fn (Crawler $node): string => trim($node->text()));
}
}
@@ -7,6 +7,7 @@ namespace App\Tests\Controller;
use App\Repository\User\ApiTokenRepository;
use App\Repository\User\UserRepository;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
@@ -115,4 +116,93 @@ final class StateChangingPostsNeedCsrfTest extends WebTestCase
self::assertSame(403, $client->getResponse()->getStatusCode(), "$path accepted a forged token");
}
// ── the drag-to-reorder endpoints ─────────────────────────────────────
/**
* The two list-ordering endpoints, which take their token in a header.
*
* The account one is why this section exists. It was a per-account PATCH
* driven by @stimulus-components/sortable, a wrapper that builds its own
* multipart body and gives you nowhere to attach a token — so it shipped
* with a comment explaining that it had none, and stayed that way. The
* rules list had already dropped the wrapper for exactly this reason; the
* accounts list now posts the whole order as JSON the same way, and this
* test is what stops the next draggable list from repeating it.
*
* Asserted as 403 rather than "nothing was written": the order is the thing
* being written, so a silent no-op and a rejection look the same from the
* repository, and only one of them is the endpoint doing its job.
*
* @return iterable<string, array{string}>
*/
public static function reorderEndpoints(): iterable
{
yield 'reorder the account list' => ['/account/reorder'];
yield 'reorder the rules list' => ['/settings/filters/reorder'];
}
#[DataProvider('reorderEndpoints')]
public function testReorderEndpointsRejectARequestWithNoToken(string $path): void
{
$client = $this->signedIn();
$client->request(
'POST',
$path,
server: ['CONTENT_TYPE' => 'application/json'],
content: json_encode(['ids' => [2, 1]], JSON_THROW_ON_ERROR),
);
self::assertSame(403, $client->getResponse()->getStatusCode(), "$path accepted a request with no token");
}
#[DataProvider('reorderEndpoints')]
public function testReorderEndpointsRejectAForgedToken(string $path): void
{
$client = $this->signedIn();
$client->request(
'POST',
$path,
server: ['CONTENT_TYPE' => 'application/json', 'HTTP_X_CSRF_TOKEN' => 'nonsense'],
content: json_encode(['ids' => [2, 1]], JSON_THROW_ON_ERROR),
);
self::assertSame(403, $client->getResponse()->getStatusCode(), "$path accepted a forged token");
}
/**
* And the endpoint that chooses which account sends mail, which is new and
* is the more valuable of the two to forge: it silently changes the address
* everything the user writes afterwards goes out from.
*/
public function testMakingAnAccountPrimaryRejectsAForgedToken(): void
{
$client = $this->signedIn();
$client->request('POST', '/account/1/primary', ['_token' => 'nonsense']);
self::assertContains(
$client->getResponse()->getStatusCode(),
[403, 404],
'the primary switch accepted a forged token',
);
}
private function signedIn(): KernelBrowser
{
$client = static::createClient();
$user = static::getContainer()->get(UserRepository::class)
->findOneBy(['email' => self::ADMIN_EMAIL]);
if (null === $user) {
self::markTestSkipped('run `app:test:seed-user --admin` first');
}
$client->loginUser($user);
return $client;
}
}
@@ -176,7 +176,7 @@ final class ThemeVariableCompletenessTest extends TestCase
public function testEveryThemeHasACompletePalette(Theme $theme): void
{
$blocks = self::blocks();
$selector = sprintf('[data-theme="%s"]', $theme->value);
$selector = sprintf(':root[data-theme="%s"]', $theme->value);
if (Theme::System === $theme || Theme::Light === $theme) {
self::assertArrayNotHasKey($selector, $blocks, $theme->value . ' should paint from :root/.dark.');
@@ -255,7 +255,7 @@ final class ThemeVariableCompletenessTest extends TestCase
#[DataProvider('themes')]
public function testSeededAccentMatchesTheBlock(Theme $theme): void
{
$selector = sprintf('[data-theme="%s"]', $theme->value);
$selector = sprintf(':root[data-theme="%s"]', $theme->value);
$blocks = self::blocks();
$block = $blocks[$selector] ?? $blocks[':root'];
@@ -366,7 +366,7 @@ final class ThemeVariableCompletenessTest extends TestCase
{
return ':root' === $selector
|| '.dark' === $selector
|| 1 === preg_match('/^\[data-theme="[a-z-]+"\]$/', $selector);
|| 1 === preg_match('/^:root\[data-theme="[a-z-]+"\]$/', $selector);
}
/** @return array<string, string> */
@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\Mail;
use App\Entity\Mail\Account;
use App\Service\Mail\AccountCreator;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
/**
* Arranging a list is not the same act as choosing who you are.
*
* The report: `pluetzner@outlook.com` was first and carried the Primary badge,
* Compose opened with From set to it, and dragging `test@pluetzner.de` above it
* by the handle moved the badge — and the From address — to the account that
* was dragged. Nothing in the UI said that reordering would do that. The same
* drag also swapped the two accounts' identity colours, in the sidebar and on
* every message row at once.
*
* The cause was one line: resequence() wrote `isPrimary = 0 === $index`
* alongside sortOrder, and the dot filter read sortOrder directly.
*
* The model chosen is separation. A person dragging a row to tidy a list is
* arranging a list; they are not changing their return address, and they are
* certainly not repainting a mark whose whole purpose is to be recognisable.
* So sortOrder means display position and nothing else, isPrimary is set
* explicitly by a button that says what it does, and colorIndex is handed out
* once at creation and never moves.
*
* These are the invariants that make that true. They are unit tests on the
* service rather than controller tests, because the coupling lived in the
* service and would come back there.
*/
final class AccountOrderIsNotIdentityTest extends TestCase
{
private AccountCreator $creator;
protected function setUp(): void
{
// create() is not under test here and is the only method that needs the
// collaborators; resequence/ensurePrimary/makePrimary are pure.
$this->creator = (new ReflectionClass(AccountCreator::class))->newInstanceWithoutConstructor();
}
// ── order does not decide the sender ──────────────────────────────────
public function testReorderingDoesNotMoveThePrimaryFlag(): void
{
$outlook = $this->account('pluetzner@outlook.com', primary: true);
$test = $this->account('test@pluetzner.de');
// The reported drag: the second account pulled above the first.
$this->creator->resequence([$test, $outlook]);
self::assertSame(0, $test->sortOrder, 'the dragged account is now first');
self::assertSame(1, $outlook->sortOrder);
self::assertFalse($test->isPrimary, 'a drag must not hand over the From address');
self::assertTrue($outlook->isPrimary, 'the primary is still the account that was chosen');
}
public function testReorderingDoesNotRepaintTheAccountDots(): void
{
$outlook = $this->account('pluetzner@outlook.com', colorIndex: 0);
$test = $this->account('test@pluetzner.de', colorIndex: 1);
$this->creator->resequence([$test, $outlook]);
self::assertSame(0, $outlook->colorIndex, 'an account keeps the colour the user has learned');
self::assertSame(1, $test->colorIndex);
}
/** Dragging back was reported to restore it, which it now trivially does. */
public function testDraggingBackAndForthChangesNothingButPositions(): void
{
$outlook = $this->account('pluetzner@outlook.com', primary: true, colorIndex: 0);
$test = $this->account('test@pluetzner.de', colorIndex: 1);
$this->creator->resequence([$test, $outlook]);
$this->creator->resequence([$outlook, $test]);
self::assertTrue($outlook->isPrimary);
self::assertFalse($test->isPrimary);
self::assertSame([0, 1], [$outlook->colorIndex, $test->colorIndex]);
self::assertSame([0, 1], [$outlook->sortOrder, $test->sortOrder]);
}
// ── the sender is chosen explicitly ───────────────────────────────────
public function testMakePrimaryMovesTheFlagAndLeavesExactlyOne(): void
{
$first = $this->account('a@example.test', primary: true);
$second = $this->account('b@example.test');
$third = $this->account('c@example.test');
$this->creator->makePrimary($second, [$first, $second, $third]);
self::assertFalse($first->isPrimary);
self::assertTrue($second->isPrimary);
self::assertFalse($third->isPrimary);
}
public function testMakePrimaryLeavesTheOrderAlone(): void
{
$first = $this->account('a@example.test', sortOrder: 0, primary: true);
$second = $this->account('b@example.test', sortOrder: 1);
$this->creator->makePrimary($second, [$first, $second]);
self::assertSame([0, 1], [$first->sortOrder, $second->sortOrder], 'choosing a sender must not resort the list');
}
// ── exactly one primary, however the list changed ─────────────────────
/**
* The invariant used to be free — position 0 always existed and was always
* one row. Now that the flag is chosen, losing it has to be caught.
*/
public function testDeletingThePrimaryPromotesTheFirstRemainingAccount(): void
{
$survivor = $this->account('b@example.test');
$last = $this->account('c@example.test');
// The primary is simply not in the list any more.
$this->creator->resequence([$survivor, $last]);
$this->creator->ensurePrimary([$survivor, $last]);
self::assertTrue($survivor->isPrimary, 'a user is never left without a sending account');
self::assertFalse($last->isPrimary);
}
public function testTheFirstAccountAddedIsPrimaryWithoutBeingToldTo(): void
{
$only = $this->account('a@example.test');
$this->creator->ensurePrimary([$only]);
self::assertTrue($only->isPrimary);
}
public function testAnExistingPrimaryIsNeverStolenByEnsurePrimary(): void
{
$first = $this->account('a@example.test');
$second = $this->account('b@example.test', primary: true);
$this->creator->ensurePrimary([$first, $second]);
self::assertFalse($first->isPrimary, 'being first is not being primary any more');
self::assertTrue($second->isPrimary);
}
/**
* Two primaries is a state the old derivation could not produce and an
* import can. findOneBy() would then pick between them arbitrarily, so the
* From address would differ between two loads of the same window.
*/
public function testADriftedListIsRepairedDownToOnePrimary(): void
{
$first = $this->account('a@example.test', primary: true);
$second = $this->account('b@example.test', primary: true);
$this->creator->ensurePrimary([$first, $second]);
self::assertTrue($first->isPrimary);
self::assertFalse($second->isPrimary);
}
public function testAnEmptyListIsNotAnError(): void
{
$this->creator->ensurePrimary([]);
$this->expectNotToPerformAssertions();
}
private function account(
string $email,
int $sortOrder = 0,
bool $primary = false,
int $colorIndex = 0,
): Account {
$account = new Account();
$account->email = $email;
$account->username = $email;
$account->sortOrder = $sortOrder;
$account->isPrimary = $primary;
$account->colorIndex = $colorIndex;
return $account;
}
}
+76 -2
View File
@@ -114,10 +114,84 @@ final class ThreadParticipantsTest extends TestCase
self::assertSame([], $this->participants->forThread($this->thread()));
}
// ── one name per person, not one per address ──────────────────────────
/**
* The reported row: a Trash thread whose sender column read "ich, ich".
*
* De-duplication was by ADDRESS, and this account owns two of them — its
* email and its username. The reader wrote from each at some point, both
* resolved to the label "me", and the column printed it twice. Which is a
* row that says a conversation has two participants who are both you.
*/
public function testTheReaderIsNamedOnceEvenWhenTheyWroteFromTwoOwnAddresses(): void
{
$thread = $this->thread(
$this->message('joerg@example.test', 'Jörg Müller', '2026-07-28 09:00:00'),
$this->message('me@example.test', 'Me Myself', '2026-07-28 10:00:00'),
$this->message('me.other@example.test', 'Me Myself', '2026-07-28 11:00:00'),
);
self::assertSame(['Jörg Müller', 'ich'], $this->participants->forThread($thread, 'ich'));
}
/**
* And the same collapse when the reader is the ONLY participant, which is
* the case the fallback to recipients has to be reached through: two owned
* addresses used to make `names` ['me', 'me'], which is not `[$me]`, so the
* thread did not look self-written and the recipients never stood in.
*/
public function testAThreadTheReaderWroteFromTwoOwnAddressesStillFallsBackToRecipients(): void
{
$first = $this->message('me@example.test', 'Me Myself', '2026-07-28 09:00:00');
$second = $this->message('me.other@example.test', 'Me Myself', '2026-07-28 10:00:00');
$second->toAddresses = [['name' => 'Jörg Müller', 'address' => 'joerg@example.test']];
self::assertSame(
['Jörg Müller'],
$this->participants->forThread($this->thread($first, $second)),
);
}
/**
* A correspondent writing from two of their own addresses under one name is
* the same fault with somebody else's name in it.
*/
public function testOneCorrespondentWritingFromTwoAddressesIsNamedOnce(): void
{
$thread = $this->thread(
$this->message('joerg@example.test', 'Jörg Müller', '2026-07-28 09:00:00'),
$this->message('j.mueller@example.test', 'Jörg Müller', '2026-07-28 10:00:00'),
);
self::assertSame(['Jörg Müller'], $this->participants->forThread($thread));
}
/** Recipients collapse too — the Sent column is built from those. */
public function testRepeatedRecipientNamesCollapse(): void
{
$message = $this->message('me@example.test', 'Me Myself', '2026-07-28 09:00:00');
$message->toAddresses = [
['name' => 'Jörg Müller', 'address' => 'joerg@example.test'],
['name' => 'Jörg Müller', 'address' => 'j.mueller@example.test'],
['name' => 'Anna', 'address' => 'anna@example.test'],
];
self::assertSame(
['Jörg Müller', 'Anna'],
$this->participants->forThread($this->thread($message)),
);
}
private function thread(Message ...$messages): MessageThread
{
$account = new Account();
$account->email = 'me@example.test';
// email AND username, because that is what Account::$ownedAddresses
// falls back to before aliases are seeded — and two owned addresses is
// precisely the shape that produced "me, me".
$account = new Account();
$account->email = 'me@example.test';
$account->username = 'me.other@example.test';
$thread = new MessageThread();
$thread->account = $account;
+316
View File
@@ -0,0 +1,316 @@
import { expect } from "@playwright/test";
import { test } from "./support/test";
/**
* The account list and the sidebar, in a real browser.
*
* Four reports from one manual pass, and each needs a browser to hold:
*
* - Reordering accounts silently reassigned the primary — the address
* Compose sends from — and repainted the account dots, because sortOrder
* decided all three. Ordering is display-only now; the sender is a button.
* - That reorder request carried no CSRF token at all, because
* @stimulus-components/sortable builds its own body.
* - The sidebar account dot had no accessible name of any kind, so an orange
* circle beside one address and a green one beside another could not be
* interpreted.
* - Every theme preset label was painted in the palette of the theme it
* NAMES rather than the one on screen — "Nord" at 1.22:1 in Solar — because
* the tile carried `data-theme`, which is the palette switch itself.
*
* The contrast test is the one that cannot move to phpunit: it needs computed
* styles, which means a browser with the stylesheet actually applied.
*/
const PANEL = '[data-controller="settings--appearance"]';
/** WCAG relative luminance, from an "rgb(r, g, b)" string. */
function ratio(fg: string, bg: string): number {
const parse = (c: string) => (c.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number);
const lum = (rgb: number[]) =>
0.2126 * chan(rgb[0]) + 0.7152 * chan(rgb[1]) + 0.0722 * chan(rgb[2]);
const chan = (v: number) => {
const s = v / 255;
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
const a = lum(parse(fg));
const b = lum(parse(bg));
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}
/** The account dots the sidebar is currently painting, per account row. */
async function sidebarDots(page): Promise<string[]> {
await page.goto("/mail/inbox");
return page.locator('#sidebar [role="img"][aria-label]').evaluateAll((dots) =>
dots.map((d) => `${(d as HTMLElement).getAttribute("aria-label")}=${getComputedStyle(d).backgroundColor}`),
);
}
/** Adds a password account through the settings modal, as account.spec.ts does. */
async function addAccount(page, label: string, stamp: number): Promise<void> {
await page.goto("/settings?section=accounts");
await page
.locator("section")
.filter({ has: page.locator("#settings-account-list") })
.getByRole("button", { name: "Add account" })
.click();
const imapHost = page.locator('input[name="account[imapHost]"]');
await expect(imapHost).toBeVisible();
await page.locator('input[name="account[email]"]').fill(label);
await page.locator('input[name="account[username]"]').fill(`order-${stamp}@example.test`);
await page.locator('input[name="account[password]"]').fill("hunter2");
await imapHost.fill(`order-${stamp}.example.test`);
await page.locator('input[name="account[imapPort]"]').fill("993");
await page.locator('select[name="account[imapEncryption]"]').selectOption("ssl");
await page.locator("#modal button[type=submit]").click();
await expect(page.getByText("Account added successfully")).toBeVisible();
}
/** Drop it again — an account is durable, user-wide state the next spec sees. */
async function removeAccount(page, label: string): Promise<void> {
await page.goto("/settings?section=accounts");
const row = page
.locator("#settings-account-list li")
.filter({ hasText: new RegExp(label, "i") });
// A loop, not a single removal: an aborted earlier run can have left more
// than one behind, and this is also the cleanup path.
for (let remaining = await row.count(); remaining > 0; remaining -= 1) {
page.once("dialog", (dialog) => dialog.accept());
await row.getByRole("button", { name: "Remove account" }).first().click();
await expect(row).toHaveCount(remaining - 1);
}
}
async function setTheme(page, theme: string) {
await page.evaluate((t) => {
document.documentElement.setAttribute("data-theme", t);
document.documentElement.classList.toggle(
"dark",
["dark", "nord", "dusk"].includes(t),
);
}, theme);
await page.waitForTimeout(120);
}
test.describe("accounts + sidebar", () => {
/**
* Defect 8. Each label used to be painted in the palette of the theme it
* names, because the tile carried `data-theme` and every theme block in
* app.css is selected by exactly that attribute.
*/
test("every theme preset label is readable in every theme", async ({ page }) => {
await page.goto("/settings?section=appearance");
await expect(page.locator(PANEL)).toBeVisible();
const rows: string[] = [];
let worst = 100;
for (const current of ["solar", "dark", "light", "paper", "nord", "dusk"]) {
await setTheme(page, current);
const measured = await page
.locator(`${PANEL} [data-theme-name]`)
.evaluateAll((tiles) =>
tiles.map((tile) => {
// :scope, or querySelector finds the swatch's own inner
// dot — which is a last-child span too, and is painted
// in inline preview colours that mean nothing here.
const label = tile.querySelector(":scope > span:last-child") as HTMLElement;
// Walk up for the first non-transparent background, the
// way a person's eye does.
let node: HTMLElement | null = label;
let bg = "rgba(0, 0, 0, 0)";
while (node && (bg === "rgba(0, 0, 0, 0)" || bg === "transparent")) {
bg = getComputedStyle(node).backgroundColor;
node = node.parentElement;
}
return {
name: (tile as HTMLElement).dataset.themeName ?? "?",
text: label?.textContent?.trim() ?? "",
fg: getComputedStyle(label).color,
bg,
};
}),
);
for (const m of measured) {
const r = ratio(m.fg, m.bg);
worst = Math.min(worst, r);
rows.push(` in ${current.padEnd(6)} | label ${m.text.padEnd(8)} | ${r.toFixed(2)}:1`);
}
}
console.log("\nTHEME PRESET LABEL CONTRAST\n" + rows.join("\n"));
console.log(`\n worst: ${worst.toFixed(2)}:1 (AA needs 4.50:1)\n`);
expect(worst).toBeGreaterThanOrEqual(4.5);
});
/**
* Defects 1 + 2 + 3, which are one gesture: reordering is cosmetic, the
* request carries a token, and the dots do not move.
*
* Creates its own second account and removes it again in a `finally`, for
* the reasons account.spec.ts documents at length — an account is durable,
* user-wide state and several other specs count them.
*/
test("reordering accounts changes neither the primary nor the dots", async ({ page }) => {
// Creates an account, reorders it, reads the sidebar twice and removes
// it again — a dozen navigations, comfortably past the 30s default.
test.setTimeout(120_000);
const stamp = Date.now();
const label = `E2E ORDER ${stamp}`;
try {
// A previous aborted run can leave one behind, and the assertions
// below are about a two-account list.
await removeAccount(page, "E2E ORDER");
await addAccount(page, label, stamp);
const rows = page.locator("#settings-account-list li[data-account-id]");
await expect(rows).toHaveCount(2);
const primaryRow = page.locator("li[data-account-id]", {
has: page.getByText("Primary", { exact: true }),
});
const primaryBefore = await primaryRow.getAttribute("data-account-id");
const orderBefore = await rows.evaluateAll((els) =>
els.map((e) => (e as HTMLElement).dataset.accountId ?? ""),
);
const dotsBefore = await sidebarDots(page);
// The keyboard path — which is also the one that did not exist
// before, ordering being drag-only.
await page.goto("/settings?section=accounts");
const requested = page.waitForResponse(
(r) => r.url().includes("/account/reorder") && r.request().method() === "POST",
);
await rows.nth(1).locator('[data-direction="up"]').click();
const response = await requested;
expect(response.status(), "the reorder must be accepted").toBe(200);
expect(
response.request().headers()["x-csrf-token"],
"and it must have carried a CSRF token",
).toBeTruthy();
await page.goto("/settings?section=accounts");
const orderAfter = await rows.evaluateAll((els) =>
els.map((e) => (e as HTMLElement).dataset.accountId ?? ""),
);
expect(orderAfter, "the arrangement is what was actually saved").toEqual(
[orderBefore[1], orderBefore[0]],
);
expect(
await primaryRow.getAttribute("data-account-id"),
"a reorder must not hand over the From address",
).toBe(primaryBefore);
// Sorted, because the ROWS are expected to have moved — the claim
// is that each account kept the colour it had, not that the list
// is in the same sequence. Before the fix the pairing itself
// changed: the account that moved to the top took the top colour.
expect(
(await sidebarDots(page)).sort(),
"nor repaint the account dots",
).toEqual([...dotsBefore].sort());
// Back to settings: sidebarDots() reads the mailbox shell, and the
// locators below are scoped to #settings-account-list.
await page.goto("/settings?section=accounts");
// And the explicit control does what dragging used to do silently.
const other = rows.filter({ hasNot: page.getByText("Primary", { exact: true }) }).first();
const otherId = await other.getAttribute("data-account-id");
await other.getByRole("button", { name: /primary/i }).click();
await expect(page.getByText(/Primary account changed/i)).toBeVisible();
await page.goto("/settings?section=accounts");
expect(
await primaryRow.getAttribute("data-account-id"),
"and choosing a sender explicitly does move it",
).toBe(otherId);
} finally {
await removeAccount(page, label);
}
});
/** Defect 3: the sidebar dot has a name a person can read. */
test("the sidebar account dot is labelled", async ({ page }) => {
await page.goto("/mail/inbox");
const dots = page.locator('#sidebar [role="img"][aria-label]');
if ((await dots.count()) === 0) {
test.skip(true, "no accounts in the sidebar");
}
for (let i = 0; i < (await dots.count()); i += 1) {
expect(await dots.nth(i).getAttribute("aria-label")).toBeTruthy();
}
});
/** Defect 5: one id, one element. */
test("the mailbox shell has no duplicate ids", async ({ page }) => {
await page.goto("/mail/inbox");
const duplicates = await page.evaluate(() => {
const seen = new Map<string, number>();
document.querySelectorAll("[id]").forEach((n) => {
seen.set(n.id, (seen.get(n.id) ?? 0) + 1);
});
return [...seen.entries()].filter(([, n]) => n > 1).map(([id]) => id);
});
expect(duplicates).toEqual([]);
});
/**
* The mobile drawer renders the same sidebar partial as the desktop rail,
* so both had to be on screen at once for the duplicate ids to exist — and
* a phone is where the drawer is the only sidebar there is.
*/
test("the drawer sidebar works at 414x851", async ({ page }) => {
await page.setViewportSize({ width: 414, height: 851 });
await page.goto("/mail/inbox");
const duplicates = await page.evaluate(() => {
const seen = new Map<string, number>();
document.querySelectorAll("[id]").forEach((n) => {
seen.set(n.id, (seen.get(n.id) ?? 0) + 1);
});
return [...seen.entries()].filter(([, n]) => n > 1).map(([id]) => id);
});
expect(duplicates).toEqual([]);
// The drawer's own copy of each account frame is present and distinct.
const drawerFrames = await page
.locator('turbo-frame[id^="account-folders-"][id$="-drawer"]')
.count();
expect(drawerFrames).toBeGreaterThan(0);
});
});
@@ -92,7 +92,7 @@ async function pick(page: Page, theme: string): Promise<void> {
(response) => response.url().includes("/appearance") && response.request().method() === "POST",
);
await page.locator(`${PANEL} [data-theme="${theme}"]`).click();
await page.locator(`${PANEL} [data-theme-name="${theme}"]`).click();
await saved;
}
@@ -122,8 +122,8 @@ async function setLayout(page: Page, layout: string): Promise<void> {
}
async function themeNames(page: Page): Promise<string[]> {
return page.locator(`${PANEL} [data-theme]`).evaluateAll(
(buttons) => buttons.map((button) => (button as HTMLElement).dataset.theme ?? ""),
return page.locator(`${PANEL} [data-theme-name]`).evaluateAll(
(buttons) => buttons.map((button) => (button as HTMLElement).dataset.themeName ?? ""),
);
}
+1 -1
View File
@@ -41,7 +41,7 @@ async function open(page: Page): Promise<void> {
/** Themes are radio-ish buttons carrying their own defaults as data. */
function themeButtons(page: Page) {
return page.locator(`${PANEL} [data-theme]`);
return page.locator(`${PANEL} [data-theme-name]`);
}
test.describe("appearance — theme", () => {
+16 -1
View File
@@ -22,6 +22,7 @@ account:
google: Weiter mit Google
info: Melde dich bei deinem Anbieter an — es wird kein Passwort gespeichert.
microsoft: Weiter mit Microsoft
primary_set: Primäres Konto geändert
removed: Konto entfernt
tabs:
imap: IMAP / SMTP
@@ -1690,8 +1691,17 @@ settings:
empty: Noch keine Konten. Füge eines hinzu, um E-Mails zu synchronisieren.
enable: Konto aktivieren
heading: E-Mail-Konten
make_primary: Als primär festlegen
make_primary_explained: 'Neue Nachrichten standardmäßig von %account% senden'
move_down: '%account% in der Liste nach unten schieben'
move_up: '%account% in der Liste nach oben schieben'
order_hint: >-
Ziehen, um diese Liste zu ordnen. Die Reihenfolge bestimmt nur die
Anzeige — das Konto, von dem neue Nachrichten gesendet werden, ist
das mit „Primär“ markierte.
password_help: Leer lassen, um das aktuelle Passwort zu behalten.
primary: Primär
primary_explained: Neue Nachrichten werden standardmäßig von diesem Konto gesendet.
push:
degraded_hint:
gmail: >-
@@ -2456,8 +2466,13 @@ sidebar:
heading: Konten
loading: Wird geladen…
none: Noch keine Konten
colour: 'Farbmarkierung für %account%'
colour_disabled: 'Farbmarkierung für %account%, das deaktiviert ist'
open: '%account% öffnen'
toggle: 'Ordner in %account% ein-/ausklappen'
badge:
total: '%count% insgesamt'
unread: '%count% ungelesen'
compose: Schreiben
labels:
add: Label erstellen
@@ -2499,7 +2514,7 @@ tabs:
primary: Allgemein
promotions: Werbung
social: Soziale Netzwerke
updates: Updates
updates: Benachrichtigungen
thread:
no_subject: (kein Betreff)
+15
View File
@@ -22,6 +22,7 @@ account:
google: Continue with Google
info: Sign in with your provider — no password stored.
microsoft: Continue with Microsoft
primary_set: Primary account changed
removed: Account removed
tabs:
imap: IMAP / SMTP
@@ -1677,8 +1678,17 @@ settings:
empty: No accounts yet. Add one to start syncing mail.
enable: Enable account
heading: Mail accounts
make_primary: Make primary
make_primary_explained: 'Send new messages from %account% by default'
move_down: 'Move %account% down the list'
move_up: 'Move %account% up the list'
order_hint: >-
Drag to arrange this list. The order is how accounts are shown and
nothing else — the account new messages are sent from is the one
marked Primary.
password_help: Leave blank to keep the current password.
primary: Primary
primary_explained: New messages are sent from this account by default.
push:
degraded_hint:
gmail: >-
@@ -2427,8 +2437,13 @@ sidebar:
heading: Accounts
loading: Loading…
none: No accounts yet
colour: 'Colour marking %account%'
colour_disabled: 'Colour marking %account%, which is switched off'
open: 'Open %account%'
toggle: 'Show or hide folders in %account%'
badge:
total: '%count% in total'
unread: '%count% unread'
compose: Compose
labels:
add: Create label
+14
View File
@@ -22,6 +22,7 @@ account:
google: Sail on with Google
info: Sign on with yer harbourmaster — no secret word be kept aboard.
microsoft: Sail on with Microsoft
primary_set: A new flagship be named
removed: Crew member put ashore
tabs:
imap: IMAP / SMTP
@@ -1371,8 +1372,16 @@ settings:
empty: No crew yet. Sign one on to start haulin' mail aboard.
enable: Restore the papers
heading: Th' crew
make_primary: Name her flagship
make_primary_explained: 'Send new bottles from %account% by default'
move_down: 'Move %account% down the roster'
move_up: 'Move %account% up the roster'
order_hint: >-
Drag to set the roster. 'Tis the order they be listed in and naught
else — the ship yer bottles sail from is the one marked Flagship.
password_help: Leave it blank to keep the secret word ye have.
primary: Flagship
primary_explained: New bottles sail from this ship unless ye say otherwise.
push:
degraded_hint:
gmail: >-
@@ -2086,8 +2095,13 @@ sidebar:
heading: Th' crew
loading: Haulin' aboard…
none: No crew yet
colour: 'Colours flown by %account%'
colour_disabled: 'Colours flown by %account%, laid up in port'
open: 'Board %account%'
toggle: 'Open or close the lockers in %account%'
badge:
total: '%count% all told'
unread: '%count% unread'
compose: Scribble
labels:
add: Hoist a new flag