feat(mail): select every conversation in a view, in one request

Four reported items, and the feature that turned out to be the fix for two of
them.

**Selecting past the page.** "Select all" meant the fifty rows on screen, which
is the only thing a checkbox over a paginated list can mean on its own — and not
what anybody wants in a bin holding a hundred and ninety-five. When the page is
fully selected and the view holds more, the toolbar now offers the rest, and the
same gesture covers "every starred mail".

The view arrives as a named scope, not as the URL the user is on. Posting the
URL would make every mail list route an input to a bulk WRITE, so a route added
later for reading becomes a way of deleting without anyone deciding that. The
scope names are the ones the list templates already use for themselves
(`{% block list_scope %}`), so there is nothing new to learn and the two cannot
drift. Unread-only travels with it: a "select all" that ignored the filter would
act on mail the person cannot see.

**One request, not one per conversation.** The toolbar posted per thread, in
parallel — fine for fifty rows, impossible for a folder. That also could not
answer for the LIST: each response redrew its own row and knew nothing else, so
an emptied list still said "1–5 of 5" (Schreibtest 10). The bulk response
carries the rows AND the pane re-reads the list, because the pager and the empty
state are properties of the list that no row-shaped stream can carry.

Two single-thread assumptions had to go with it, and both were correct until
now. `finish()` resynced only `$messages[0]->thread`, so a bulk archive answered
200, reported 71, and changed nothing on screen — the threads kept the Inbox
label their messages no longer had. `markRead()` wrote the whole selection's
unread count onto the first thread, so four conversations were marked read and
three rows went on rendering as unread. Both now work per thread, and the bulk
controller groups by ACCOUNT because ThreadStatusUpdater resolves the
destination folder from the first message's account.

**The 404 was English and in the wrong theme** (Befunde 2). Not an oversight in
the template: a 404 is thrown by the router at priority 32 of kernel.request,
before the firewall at 8 and before UserLocaleSubscriber at 6 — nothing has
authenticated when the exception is handled, so there is no user, no locale and
no theme. The error template must not look one up, and its own docblock explains
why at length. So both hints ride in a cookie written on ordinary responses.

**Sie/du** (Befunde 8). Seven strings addressed the reader formally, and the
report found them in Aliases because that is where the reader happened to look.
Guarded by a test that uses the one rule that can be checked: German capitalises
both the polite second person and the first word of a sentence, so a capitalised
form MID-sentence can only be formal address. A naive search flags a dozen good
sentences, gets an exception list, and the exception list then swallows the next
real one.

**A revert, recorded rather than hidden.** The label menu's escape to the
browser's top layer is gone. The bug is real — the panes' `backdrop-filter`
makes each a stacking context and the containing block for `position: fixed`, so
nothing inside one can rise above the chrome — but the fix broke clicking:
`elementFromPoint` at an option's own coordinates answers null and the click
never lands. It did the same to the snooze menu. Three specs caught it. A menu
that is visible and does nothing is worse than one that is clipped, and I could
not find the cause without seeing the real layout, so the spec is marked fixme
with the whole story in it.

Also: a shared `rowAction()` for the specs. A row's actions are
`visibility: hidden` until hovered, which makes them easy to lose under four
parallel workers and — because a hidden element is not in the accessibility tree
— makes `getByRole` report a missing action for one that is merely unhovered.
That was three of my own specs failing on a different run each time.

4027 unit tests, PHPStan clean, 550 browser tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5 committed 2026-08-22 20:09:32 +02:00
1 parent 155b421553
commit 2549b7e019
27 files changed
+1357 -310

No files matched your search

@@ -1,6 +1,5 @@
import { Controller } from "@hotwired/stimulus";
import { jsonCsrfHeaders } from "../../csrf.js";
import { pinToTopLayer, releaseFromTopLayer } from "../../popout.js";
/**
* "Label as" dropdown. Toggles a label on the target thread/message via the
@@ -38,18 +37,10 @@ export default class extends Controller {
} else {
this.panelTarget.classList.remove("hidden");
// Into the top layer, or it never gets out of the mail pane: the
// panes carry backdrop-filter, which makes them a stacking context
// AND the containing block for position:fixed, and every ancestor
// from there down is overflow:hidden. This menu used to vanish
// behind the navbar and no z-index could have fixed it. See
// assets/popout.js.
// Bulk mode: the menu is shared by whatever is selected, so what it
// shows has to be read from the selection each time it opens.
this._syncFromSelection();
pinToTopLayer(event.currentTarget, this.panelTarget);
document.addEventListener("click", this._boundClose, { capture: true });
}
}
@@ -162,11 +153,6 @@ export default class extends Controller {
}
_close() {
// Out of the top layer first: a hidden popover that was never dismissed
// keeps its scroll and resize listeners, and the next open would add a
// second pair.
releaseFromTopLayer(this.panelTarget);
this.panelTarget.classList.add("hidden");
document.removeEventListener("click", this._boundClose, { capture: true });
}
@@ -9,7 +9,8 @@
// reach it.
import { Controller } from "@hotwired/stimulus";
import { csrfToken } from "../../csrf.js";
import * as Turbo from "@hotwired/turbo";
import { jsonCsrfHeaders } from "../../csrf.js";
import { announceWrite } from "../../mail_writes.js";
// Classes applied to the checkbox button in each state
@@ -29,9 +30,39 @@ export default class extends Controller {
"selectMenuBtn",
"actions", // wrapper div around bulk-action buttons
"selectionCount",
"viewBanner", // the "select all N in this view" strip
"viewBannerText",
"viewBannerAction",
];
static values = { total: Number };
static values = {
total: Number,
// What this list is showing, in the vocabulary the list template
// already uses for itself ({% block list_scope %}) — see
// App\Service\Mail\ListViewResolver. `scopeValue` carries the
// category for the inbox and the label id for a label view.
// `viewScope` / `viewValue`, not `scope` / `scopeValue`: Stimulus
// spells a value as `data-…-<name>-value`, so a value called
// `scopeValue` becomes `data-…-scope-value-value` while `scope` becomes
// `data-…-scope-value`. The two are one character apart in the markup
// and mean entirely different things, which is how the view descriptor
// arrived empty and the bulk action archived nothing while answering
// 200.
viewScope: String,
viewValue: String,
unreadOnly: Boolean,
i18n: Object,
};
/**
* True once "select all N" has been pressed: the selection is the VIEW,
* not the rows on screen.
*
* Cleared by anything that changes what is selected, including a bulk
* action finishing — a person who deleted everything and then clicks
* Archive must not archive everything that arrived since.
*/
#allInView = false;
// ── Lifecycle ─────────────────────────────────────────────────────────
@@ -106,27 +137,43 @@ export default class extends Controller {
// ── Bulk actions ──────────────────────────────────────────────────────
async archiveSelected() {
const ids = this._selectedIds();
if (ids.length === 0) { return; }
await this._bulkPost(ids, "archive");
await this._bulkPost("archive");
}
async deleteSelected() {
const ids = this._selectedIds();
if (ids.length === 0) { return; }
await this._bulkPost(ids, "trash");
await this._bulkPost("trash");
}
async markReadSelected() {
const ids = this._selectedIds();
if (ids.length === 0) { return; }
await this._bulkPost(ids, "read", { read: true });
await this._bulkPost("read", { read: true });
}
async markUnreadSelected() {
const ids = this._selectedIds();
if (ids.length === 0) { return; }
await this._bulkPost(ids, "read", { read: false });
await this._bulkPost("read", { read: false });
}
/**
* Extend the selection past the page, to everything in this view.
*
* Offered only when every row on screen is already selected and there are
* more than fit — the state where "select all" has visibly done less than
* the words promise. It is what a bin holding a hundred and ninety-five
* messages needs, and the same gesture as selecting every starred mail.
*/
selectAllInView(event) {
event?.preventDefault();
this.#allInView = true;
this._syncFromRows();
}
/** Back to the rows on screen. */
clearViewSelection(event) {
event?.preventDefault();
this.#allInView = false;
this._setAllRows(false);
this._syncFromRows();
}
/**
@@ -188,66 +235,74 @@ export default class extends Controller {
* @param {string} action - route suffix: archive | trash | read | snooze | star
* @param {object} body - optional JSON body (e.g. { read: true })
*/
async _bulkPost(ids, action, body = {}) {
const csrf = csrfToken();
/**
* One request for the whole selection.
*
* It used to be one request per conversation, fired in parallel. That is
* survivable for the fifty rows a page holds and impossible for what this
* now offers: "select all" over a bin of two hundred would have opened two
* hundred connections, and a real mailbox is worse.
*
* It also could not answer for the list. Each response redrew its own row
* and knew nothing else, so a list that lost four of five rows still said
* "1–5 of 5" and an emptied one never showed its empty state — the pager
* and the placeholder belong to the LIST, and no row-shaped response
* carries them. The bulk response refreshes the frame instead.
*/
async _bulkPost(action, body = {}) {
const ids = this.#allInView ? [] : this._selectedIds();
// Hold the list refresh for the whole run. Each of these posts makes the
// server publish a sync, and a refresh answering one of them mid-run
// re-renders the list from a state where the remaining threads have not
// been acted on yet — putting rows back that the streams below are about
// to remove, as fresh elements the streams can no longer address. The
// rows then stay on screen having been archived. See
// mail_pane_controller#hold.
if (false === this.#allInView && ids.length === 0) {
return;
}
// Holds the mail pane's refresh of the list frame for the duration —
// see mail--mail-pane#hold. Without it the frame reloads mid-run and
// the rows being acted on are replaced underneath the request.
this.dispatch("writing");
try {
await this._bulkPostInner(ids, action, body, csrf);
} finally {
// Releases the mail pane's hold on the list frame — a statement
// about THIS run of posts, and still the toolbar's own.
this.dispatch("written");
const response = await fetch(`${STATUS_BASE}/bulk/${action}`, {
method: "POST",
headers: jsonCsrfHeaders(),
body: JSON.stringify({
...body,
ids,
all: this.#allInView,
scope: this.viewScopeValue,
value: this.viewValueValue,
unreadOnly: this.unreadOnlyValue,
}),
});
// And the separate, general one the badges listen for. The sidebar
// used to listen to the line above; it no longer does, so a bulk
// action has to make both statements. See assets/mail_writes.js
// for why they stayed two events rather than becoming one.
announceWrite();
}
}
if (false === response.ok) {
console.error(`[list-toolbar] bulk ${action} failed`, response.status);
async _bulkPostInner(ids, action, body, csrf) {
const results = await Promise.all(
ids.map(async (id) => {
const url = `${STATUS_BASE}/thread/${id}/${action}`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrf,
},
body: JSON.stringify(body),
});
if (!response.ok) {
console.error(`[list-toolbar] ${action} failed for thread ${id}`, response.status);
return null;
}
return response.text();
}),
);
// Render streams in selection order so DOM mutations are predictable.
for (const html of results) {
if (html !== null && html.trim() !== "") {
Turbo.renderStreamMessage(html);
return;
}
}
// Uncheck all rows after the action succeeds (rows may have been
// removed from the DOM by the stream responses).
this._setAllRows(false);
this._syncFromRows();
// The rows first, so the click has an answer immediately, then the
// list itself for the pager and the empty state — see
// thread/status/_bulk.stream.html.twig for why it takes both.
Turbo.renderStreamMessage(await response.text());
// The list itself — the pager, the empty state, the total — is
// re-read by the mail pane when the "written" event below releases
// its hold. NOT by frame.reload(): this frame is rendered inline
// and has no `src`, and reload() on a src-less turbo-frame does
// nothing at all, silently. That is worth writing down, because it
// looks exactly like a refresh that ran and found no changes.
} finally {
this.dispatch("written");
announceWrite();
// The selection is spent either way. Leaving #allInView set is how
// a second click would act on everything that had arrived since —
// which is the shape of the desync that was reported, made worse.
this.#allInView = false;
this._setAllRows(false);
this._syncFromRows();
}
}
/**
@@ -272,8 +327,56 @@ export default class extends Controller {
// ── Count label ──────────────────────────────────────────────────
if (this.hasSelectionCountTarget) {
this.selectionCountTarget.textContent =
checkedCount > 0 ? String(checkedCount) : "";
this.selectionCountTarget.textContent = this.#allInView
? String(this.totalValue)
: (checkedCount > 0 ? String(checkedCount) : "");
}
this.#syncViewBanner(allChecked);
}
/**
* The "and the other 145" offer.
*
* Shown only where "select all" has visibly done less than the words
* promise: every row on screen is ticked AND the view holds more than fit.
* On a list that fits on one page there is nothing to extend to, and
* offering it anyway would be a control that does nothing.
*/
#syncViewBanner(allChecked) {
if (false === this.hasViewBannerTarget) {
return;
}
const extendable = allChecked && this.totalValue > this._rowCheckboxes().length;
this.viewBannerTarget.classList.toggle("hidden", false === (extendable || this.#allInView));
if (false === this.hasViewBannerTextTarget) {
return;
}
// Two sentences, because they say opposite things: one offers to widen
// the selection, the other reports that it IS widened and offers the
// way back. A single string with a number swapped in would have to be
// one or the other.
const i18n = this.i18nValue ?? {};
this.viewBannerTextTarget.textContent = this.#allInView
? (i18n.allSelected ?? "").replace("%count%", String(this.totalValue))
: (i18n.selectAll ?? "").replace("%count%", String(this.totalValue));
if (this.hasViewBannerActionTarget) {
this.viewBannerActionTarget.textContent = this.#allInView
? (i18n.clear ?? "")
: (i18n.selectAllAction ?? "");
this.viewBannerActionTarget.setAttribute(
"data-action",
this.#allInView
? "click->mail--list-toolbar#clearViewSelection"
: "click->mail--list-toolbar#selectAllInView",
);
}
}
+2 -34
View File
@@ -1,5 +1,4 @@
import { Controller } from "@hotwired/stimulus";
import { pinToTopLayer, releaseFromTopLayer } from "../../popout.js";
/**
* A small click-to-open menu.
@@ -28,7 +27,6 @@ import { pinToTopLayer, releaseFromTopLayer } from "../../popout.js";
*/
export default class extends Controller {
static targets = ["menu"];
static values = { escapes: Boolean };
connect() {
this._boundOutside = this._handleOutside.bind(this);
@@ -56,36 +54,12 @@ export default class extends Controller {
toggle(event) {
event?.preventDefault();
this.menuTarget.hidden ? this.open(event?.currentTarget) : this.close();
this.menuTarget.hidden ? this.open() : this.close();
}
/**
* @param {HTMLElement} [trigger] the control the menu should hang under.
* Defaults to this controller's element, which is the wrapper the
* menu was anchored to when it positioned itself with CSS.
*/
open(trigger) {
open() {
this.menuTarget.hidden = false;
// Into the top layer, for the menus that need to leave their pane.
//
// Opt-in, and that is the correction rather than the original design:
// applying it to every menu this controller opens broke the ones that
// are deliberately anchored to the box they live in. The compose
// window's emoji picker is centred ON the window and asserted to stay
// inside it — escaping to the viewport is exactly wrong there, and the
// specs said so.
//
// So a menu asks. The ones that ask are the ones opened inside a pane
// and needing to overlap the chrome around it: the panes carry
// backdrop-filter, which makes each a stacking context AND the
// containing block for position:fixed, so nothing anchored inside one
// can rise above the header whatever its z-index says. See
// assets/popout.js.
if (true === this.escapesValue) {
pinToTopLayer(trigger ?? this.element, this.menuTarget);
}
// For menus whose contents go stale while closed — the snooze menu
// recomputes its wake times here, so one left open across midnight
// cannot offer a "tomorrow" that is already today.
@@ -100,12 +74,6 @@ export default class extends Controller {
return;
}
// Unconditional, unlike the open: a menu that stopped opting in while
// one of its panels was still shown would otherwise keep the scroll
// and resize listeners for ever. releaseFromTopLayer does nothing to a
// panel that was never pinned.
releaseFromTopLayer(this.menuTarget);
this.menuTarget.hidden = true;
this._detach();
}
-131
View File
@@ -1,131 +0,0 @@
/**
* Getting a menu out from under the pane it was declared in.
*
* ## The trap
*
* plMail's panes carry `backdrop-filter`, and that has two consequences most
* CSS does not prepare you for. It makes the pane a **stacking context**, so a
* `z-30` menu inside it can never rise above a `z-20` header OUTSIDE it —
* z-index only compares within a context, and raising the number does nothing.
* And it makes the pane a **containing block for `position: fixed`**, so the
* usual escape hatch is not one: a fixed menu anchors to the pane and is
* clipped by it, exactly like an absolute one.
*
* On top of that every ancestor from the pane down is `overflow: hidden`, so
* the menu is clipped as well as painted underneath.
*
* That is why the label menu disappeared behind the navbar, and why the compose
* recipient dropdown could not overlay the dialog and shoved it down the page
* instead. Same cause, two symptoms, and neither is fixable with a z-index.
*
* tooltip_controller.js hit this first and solved it by rendering its bubble on
* <body>. That works and it costs something these menus cannot pay: moving an
* element out of its subtree takes it away from the Stimulus controller whose
* targets and actions are scoped to it.
*
* ## The top layer
*
* The popover API puts an element in the browser's top layer — above every
* stacking context, clipped by nothing — while leaving it exactly where it is
* in the DOM. Targets keep resolving, actions keep firing, the controller does
* not know anything moved. It is the same property `<dialog>.showModal()` gives
* the confirmation dialog, without the modality.
*
* Position has to be supplied, because a top-layer element is positioned
* against the viewport rather than against the trigger it belongs to. That is
* the whole of what this module does.
*/
/** Gap between trigger and panel, matching the `mt-1` these menus used. */
const OFFSET = 4;
/** Keep this much clear of the viewport edge. */
const MARGIN = 8;
/**
* Show `panel` in the top layer, pinned under `trigger`.
*
* Falls back to leaving the panel where it is when the browser has no popover
* support. That is not a hypothetical branch worth dropping: without it the
* menu would be `display: none` forever on an older browser, which is worse
* than the clipping this exists to fix.
*
* @param {HTMLElement} trigger
* @param {HTMLElement} panel
*/
export function pinToTopLayer(trigger, panel) {
if (typeof panel.showPopover !== "function") {
return;
}
if (false === panel.hasAttribute("popover")) {
// "manual" rather than "auto": light-dismiss would fight the outside
// click handling these controllers already do, and auto popovers close
// each other — so opening the label menu would shut a menu that is
// meant to stay open beneath it.
panel.setAttribute("popover", "manual");
}
panel.showPopover();
position(trigger, panel);
// The trigger moves when the list behind it scrolls, and the viewport
// changes under rotation. Both leave a top-layer panel floating where the
// trigger used to be, because nothing about it is anchored any more.
const reposition = () => position(trigger, panel);
panel.__plReposition = reposition;
window.addEventListener("scroll", reposition, { capture: true, passive: true });
window.addEventListener("resize", reposition, { passive: true });
}
/**
* Take it back out of the top layer.
*
* @param {HTMLElement} panel
*/
export function releaseFromTopLayer(panel) {
if (panel.__plReposition) {
window.removeEventListener("scroll", panel.__plReposition, { capture: true });
window.removeEventListener("resize", panel.__plReposition);
delete panel.__plReposition;
}
if (typeof panel.hidePopover === "function" && true === panel.matches(":popover-open")) {
panel.hidePopover();
}
}
/**
* Under the trigger, right edges aligned, nudged back on screen if that would
* put it off the edge.
*
* Right-aligned because that is how every one of these menus was already
* anchored (`absolute right-0`), and flipping above the trigger when there is
* no room below is what stops a long label list from running off the bottom of
* a short window.
*/
function position(trigger, panel) {
const anchor = trigger.getBoundingClientRect();
const box = panel.getBoundingClientRect();
let top = anchor.bottom + OFFSET;
if (top + box.height > window.innerHeight - MARGIN) {
const above = anchor.top - box.height - OFFSET;
// Only flip when there is genuinely more room up there. On a very short
// window both are bad and below is the one that reads as a menu.
top = above >= MARGIN ? above : Math.max(MARGIN, window.innerHeight - box.height - MARGIN);
}
let left = anchor.right - box.width;
left = Math.min(left, window.innerWidth - box.width - MARGIN);
left = Math.max(MARGIN, left);
panel.style.position = "fixed";
panel.style.margin = "0";
panel.style.top = `${Math.round(top)}px`;
panel.style.left = `${Math.round(left)}px`;
}
-22
View File
@@ -3992,25 +3992,3 @@ label:has(> input.sr-only) {
text-underline-offset: 2px;
}
/* Menus rendered in the top layer.
*
* The popover API is what gets a menu out of a pane it can otherwise never
* leave — see assets/popout.js — and it arrives with a UA stylesheet of its
* own: a border, padding, a background, and `inset: 0` with `margin: auto`,
* all of which fight the panel's own styling and the coordinates popout.js
* computes for it.
*
* Reset rather than restyled: these elements already carry the app's classes,
* and the only thing needed here is to stop the browser adding a second,
* unstyled box around them. `inset: auto` is the load-bearing one — without it
* the UA centres the panel in the viewport and the computed top/left are
* ignored.
*/
[popover]:popover-open {
inset: auto;
margin: 0;
padding: 0;
border: 0;
background: transparent;
overflow: visible;
}
@@ -0,0 +1,133 @@
<?php
declare(strict_types=1);
namespace App\Controller\Mail;
use App\Controller\ChecksCsrf;
use App\Controller\RendersTurboStreams;
use App\Entity\User\User;
use App\Repository\Mail\MessageThreadRepository;
use App\Security\Voter\OwnershipVoter;
use App\Service\Mail\ListViewResolver;
use App\Service\Mail\ThreadStatusUpdater;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Bulk mail actions — one request for a whole selection.
*
* Its own controller rather than another route on ThreadStatusController,
* because that one is prefixed `/status/{type}/{id}` and every route on it is
* about ONE conversation named in the path. A bulk action is about a set named
* in the body, and hanging it off that prefix would have produced
* `/status/thread/17/bulk/archive` — a URL that names a conversation it does
* not act on.
*/
#[Route('/status/bulk', name: 'app_status_bulk_')]
#[IsGranted('ROLE_USER')]
final class BulkStatusController extends AbstractController
{
use ChecksCsrf;
use RendersTurboStreams;
public function __construct(
private readonly MessageThreadRepository $threadRepository,
private readonly ThreadStatusUpdater $status,
private readonly ListViewResolver $views,
) {
}
/**
* One request for a whole selection, and for a whole view.
*
* The toolbar used to post once per conversation, in parallel. That is fine
* for the fifty rows a page can hold and impossible for what was asked for
* — "select everything in this folder" over a bin holding two hundred would
* have opened two hundred connections, and a real mailbox is worse.
*
* It also could not answer the other half of what the list needs. Each of
* those responses redrew its own row, so the pager kept saying "1–5 of 5"
* over four rows and an emptied list never showed its empty state: nothing
* in a per-row stream knows what the list as a whole now looks like. This
* answers with the list frame, once, which is the only thing that does.
*
* @see ListViewResolver for why the view arrives as a named scope rather
* than as the URL the user is on.
*/
#[Route('/{action}', name: 'run', methods: ['POST'], requirements: ['action' => 'archive|trash|read|restore'])]
public function bulk(Request $request, string $action): Response
{
$this->assertCsrf($request, 'ajax');
/** @var User $user */
$user = $this->getUser();
$body = json_decode($request->getContent(), true);
$body = is_array($body) ? $body : [];
$threads = true === ($body['all'] ?? false)
? $this->views->threadsIn(
$user,
(string) ($body['scope'] ?? ''),
(string) ($body['value'] ?? ''),
true === ($body['unreadOnly'] ?? false),
)
: $this->threadRepository->findBy(['id' => array_map(intval(...), (array) ($body['ids'] ?? []))]);
// Grouped by account, and that is not an optimisation.
// ThreadStatusUpdater resolves the destination label and its IMAP
// folder from the FIRST message's account — which is right for every
// caller that names one conversation, and wrong for a selection
// spanning two mailboxes: everything would be filed into the first
// account's Archive. One call per account keeps each resolution
// correct, and there are rarely more than three.
$byAccount = [];
foreach ($threads as $thread) {
// Every thread, not just the first: the ids arrive from a browser
// and a selection can be edited before it is posted.
$this->denyAccessUnlessGranted(OwnershipVoter::OWN, $thread);
foreach ($thread->messages as $message) {
$byAccount[(int) $message->account?->id][] = $message;
}
}
if ([] === $byAccount) {
return $this->renderTurboStream('thread/status/_bulk.stream.html.twig', [
'count' => 0,
'threads' => [],
'leaves' => false,
]);
}
foreach ($byAccount as $messages) {
match ($action) {
'archive' => $this->status->archive($messages),
'trash' => $this->status->trash($messages),
'restore' => $this->status->restore($messages),
'read' => $this->status->markRead($messages, true === ($body['read'] ?? true)),
// Unreachable: the route requirement above is the allow-list, so a
// fifth action cannot arrive here without being added there too.
// Stated rather than assumed, because the day someone widens the
// requirement and forgets this arm, a silent no-op is the worst
// possible answer for an action that says it deleted things.
default => throw $this->createNotFoundException(sprintf('Unknown bulk action "%s".', $action)),
};
}
return $this->renderTurboStream('thread/status/_bulk.stream.html.twig', [
'count' => count($threads),
'threads' => $threads,
// Whether the conversation is still in the list it was acted on
// from. Archive, trash and restore all move it somewhere else;
// marking read leaves it exactly where it was and only changes how
// it draws.
'leaves' => 'read' !== $action,
]);
}
}
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Infrastructure\Event\Subscriber;
use App\Domain\Enum\Theme\Theme;
use App\Entity\Embeddable\Appearance;
use App\Entity\User\User;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Mirrors the user's theme and language into a cookie, for the pages that
* cannot ask.
*
* An error page is rendered with no user at all, and not because it forgot to
* look. A 404 is thrown by the router at priority 32 of kernel.request — before
* the firewall at 8 and before UserLocaleSubscriber at 6 — so neither has run
* by the time the exception is handled, and the token storage is empty for the
* rest of the cycle. The result is what was reported: a German user in a
* "Beere" theme gets an English 404 in beige "Papier", which reads as a
* different application.
*
* The error template cannot solve this for itself, and its docblock explains
* why in detail: it must not need a session, a user, a database or anything
* else that might be the thing that broke. A template that throws while
* handling an exception leaves the visitor on Symfony's bare fallback, which is
* the one case where the branded page mattered.
*
* A cookie is the one channel that survives all of that. It is written on
* ordinary responses, where the user IS known, and read by anything rendering
* without one. Nothing secret goes in it — a theme name and a locale, both of
* which the page it comes from displays anyway.
*/
#[AsEventListener(event: KernelEvents::RESPONSE)]
final readonly class AppearanceCookieSubscriber
{
/** Read by AppearanceExtension and by the error template. */
public const string COOKIE = 'plmail_ui';
/**
* A year. This is a display hint with no security meaning, and the point of
* it is to still be there on the day something breaks — which is not
* usually the day after it was set.
*/
private const int LIFETIME = 31_536_000;
public function __construct(private Security $security)
{
}
public function __invoke(ResponseEvent $event): void
{
if (false === $event->isMainRequest()) {
return;
}
$user = $this->security->getUser();
if (false === $user instanceof User) {
return;
}
$wanted = $this->value($user->appearance->theme->value, $event->getRequest()->getLocale());
// Only when it changed. Sending an identical Set-Cookie on every
// response is noise on the wire and, more to the point, makes every
// response uncacheable for no gain.
if ($wanted === $event->getRequest()->cookies->get(self::COOKIE)) {
return;
}
$event->getResponse()->headers->setCookie(
Cookie::create(self::COOKIE, $wanted)
->withExpires(time() + self::LIFETIME)
->withPath('/')
// Readable by JavaScript is not needed and not offered; the
// theme bootstrap in the layout uses localStorage for its own
// reasons and is unaffected by this.
->withHttpOnly(true)
->withSameSite(Cookie::SAMESITE_LAX),
);
}
/** `theme|locale`, which is all either reader needs. */
private function value(string $theme, string $locale): string
{
return $theme . '|' . $locale;
}
/**
* The theme and locale a request carries, or nulls.
*
* Static because both readers are places with no service container worth
* the name — a Twig extension that must not fail, and a template.
*
* @return array{0: ?string, 1: ?string}
*/
public static function read(?string $cookie): array
{
if (null === $cookie || '' === $cookie) {
return [null, null];
}
$parts = explode('|', $cookie, 2);
$theme = '' !== $parts[0] ? $parts[0] : null;
$locale = isset($parts[1]) && '' !== $parts[1] ? $parts[1] : null;
// Both are compared against known values by their readers, so a
// hand-edited cookie can only ever fail to match.
return [$theme, $locale];
}
/** @return Appearance the stored theme applied to the defaults */
public static function appearanceFrom(?string $cookie): Appearance
{
$appearance = new Appearance();
[$theme] = self::read($cookie);
if (null === $theme) {
return $appearance;
}
$known = Theme::tryFrom($theme);
if (null !== $known) {
$appearance->theme = $known;
}
return $appearance;
}
}
@@ -7,6 +7,8 @@ namespace App\Infrastructure\Event\Subscriber;
use App\Entity\User\User;
use App\Domain\Enum\AppLocale;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use App\Infrastructure\Event\Subscriber\AppearanceCookieSubscriber;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
@@ -30,7 +32,12 @@ final class UserLocaleSubscriber implements EventSubscriberInterface
public static function getSubscribedEvents(): array
{
return [KernelEvents::REQUEST => ['onKernelRequest', 6]];
return [
KernelEvents::REQUEST => ['onKernelRequest', 6],
// Before Symfony's own ErrorListener (-128), so the error page is
// rendered in the right language rather than corrected afterwards.
KernelEvents::EXCEPTION => ['onKernelException', 0],
];
}
public function onKernelRequest(RequestEvent $event): void
@@ -54,4 +61,51 @@ final class UserLocaleSubscriber implements EventSubscriberInterface
$event->getRequest()->setLocale($locale->value);
$this->translator->setLocale($locale->value);
}
/**
* The same job for the pages nobody authenticated for.
*
* A 404 is thrown by the router at priority 32 of kernel.request — before
* the firewall at 8 and before onKernelRequest above at 6. Neither has run
* when the exception is handled, so the token storage is empty and the
* request still carries the default locale: a German user mistyping a URL
* got an English page, in the default theme, which reads as a different
* application.
*
* The user cannot be looked up here either, for the same reason the error
* template refuses to: whatever threw might be the session, the database or
* the firewall itself, and an error handler that throws leaves the visitor
* on Symfony's bare fallback. So the locale comes from the cookie written
* on ordinary responses — see AppearanceCookieSubscriber, which carries the
* theme in the same value for the same reason.
*
* A no-op when the token IS available, which is every exception that is not
* a routing failure: those have been through onKernelRequest already and
* the locale is right.
*/
public function onKernelException(ExceptionEvent $event): void
{
if (false === $event->isMainRequest()) {
return;
}
$request = $event->getRequest();
if (null !== $this->tokenStorage->getToken()?->getUser()) {
return;
}
[, $stored] = AppearanceCookieSubscriber::read(
$request->cookies->get(AppearanceCookieSubscriber::COOKIE),
);
$locale = AppLocale::tryFromRequest($stored);
if (null === $locale) {
return;
}
$request->setLocale($locale->value);
$this->translator->setLocale($locale->value);
}
}
+154
View File
@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace App\Service\Mail;
use App\Domain\Enum\Mail\LabelRole;
use App\Domain\Enum\Mail\MessageCategory;
use App\Entity\Mail\MessageThread;
use App\Entity\User\User;
use App\Repository\Label\LabelRepository;
use App\Repository\Mail\MessageThreadRepository;
/**
* Every conversation in a list, not just the page somebody can see.
*
* "Select all" used to mean "select the fifty rows rendered", which is the only
* thing a checkbox over a paginated list can mean on its own — and it is not
* what anybody wants it to mean in a bin holding a hundred and ninety-five
* messages. This is the other half: given the view a person is looking at, the
* ids of everything in it.
*
* ## A descriptor, not a URL
*
* The obvious shape is to post the current URL and let the server re-run
* whatever the list ran. That was rejected: it makes every mail list route an
* input to a bulk write, so a route added later for reading becomes a way of
* deleting without anyone deciding that. The client names a scope and a value
* from a closed set instead, and anything unrecognised resolves to nothing.
*
* ## Unread-only is part of the view
*
* The list has a filter, and a "select all" that ignored it would act on mail
* the person cannot see — the exact failure that makes bulk actions frightening.
* It is carried through to the same repository methods the list itself uses, so
* the set can only ever be what was on screen, continued past the page break.
*/
final readonly class ListViewResolver
{
/**
* The page size used when draining a view.
*
* Large, because the whole point is to not stop at a page — but bounded,
* because this walks into a query that can return a mailbox. Draining in
* pages keeps the hydration flat rather than loading a hundred thousand
* rows into one array.
*/
private const int CHUNK = 500;
/**
* A ceiling, and a deliberate one.
*
* A user with a quarter of a million messages who selects everything and
* presses Delete should get a refusal rather than a request that runs until
* something times out and leaves the mailbox half-changed. The number is
* far above any real selection and far below anything that hurts.
*/
public const int LIMIT = 10_000;
public function __construct(
private MessageThreadRepository $threads,
private LabelRepository $labels,
) {
}
/**
* @param string $scope one of `inbox`, `archive`, `trash`, `spam`, `sent`,
* `drafts`, `snoozed`, `starred`, `label`
* @param string $value the category for `inbox`, the label id for `label`,
* ignored otherwise
*
* @return list<MessageThread> at most self::LIMIT of them
*/
public function threadsIn(User $user, string $scope, string $value, bool $unreadOnly): array
{
$page = 1;
$found = [];
do {
$batch = $this->page($user, $scope, $value, $unreadOnly, $page);
foreach ($batch as $thread) {
$found[] = $thread;
if (count($found) >= self::LIMIT) {
return $found;
}
}
++$page;
} while (count($batch) === self::CHUNK);
return $found;
}
/**
* @return list<MessageThread>
*/
private function page(User $user, string $scope, string $value, bool $unreadOnly, int $page): array
{
if ('inbox' === $scope) {
// The inbox is per category tab, and the tab IS the view: selecting
// everything while looking at Notifications must not reach into
// Primary, which is not on screen.
$category = MessageCategory::tryFrom($value) ?? MessageCategory::Primary;
return $this->threads->findForUnifiedInbox($user, $category, $page, self::CHUNK, unreadOnly: $unreadOnly);
}
if ('label' === $scope) {
$label = $this->labels->find((int) $value);
// Somebody else's label resolves to nothing rather than to an
// error: this is reached from a bulk write, and the honest answer
// to "act on a label you do not own" is that there is nothing to
// act on.
if (null === $label || $label->usr?->id !== $user->id) {
return [];
}
return $this->threads->findForLabel($label, page: $page, perPage: self::CHUNK, unreadOnly: $unreadOnly);
}
// Starred is not a label role — it is a column on the thread — so it
// has its own repository method rather than a row in the table below.
// Being asked for it is not exotic: "select every starred mail" is the
// example the feature was asked for with.
if ('starred' === $scope) {
return $this->threads->findForStarred($user, $page, self::CHUNK, unreadOnly: $unreadOnly);
}
$role = self::ROLES[$scope] ?? null;
if (null === $role) {
return [];
}
return $this->threads->findForRole($user, $role, $page, self::CHUNK, unreadOnly: $unreadOnly);
}
/**
* The scopes that are a system role, by the name the list template already
* uses for itself (`{% block list_scope %}`), so the client has nothing new
* to learn and the two cannot drift.
*/
private const array ROLES = [
'archive' => LabelRole::Archive,
'trash' => LabelRole::Trash,
'spam' => LabelRole::Spam,
'sent' => LabelRole::Sent,
'drafts' => LabelRole::Drafts,
'snoozed' => LabelRole::Snoozed,
];
}
+52 -2
View File
@@ -289,7 +289,34 @@ final readonly class ThreadStatusUpdater
}
}
$messages[0]->thread->unreadCount = $unread;
// Per thread, counted from that thread's own messages.
//
// `$messages[0]->thread->unreadCount = $unread` was right while every
// caller named one conversation: the total of the set WAS the total of
// the thread. A bulk action passes several threads at once, and that
// line then wrote the whole selection's count onto the first thread and
// left the others untouched — so four conversations were marked read in
// the database and three rows went on rendering as unread, because
// `data-unread` reads the thread's counter rather than its messages.
$counts = [];
foreach ($messages as $message) {
$thread = $message->thread;
if (null === $thread) {
continue;
}
$counts[spl_object_id($thread)] ??= ['thread' => $thread, 'unread' => 0];
if (null === $message->seenAt) {
++$counts[spl_object_id($thread)]['unread'];
}
}
foreach ($counts as ['thread' => $thread, 'unread' => $threadUnread]) {
$thread->unreadCount = $threadUnread;
}
$this->propagator->markRead($messages, $read);
$this->recordJmapUpdates($messages);
@@ -549,7 +576,30 @@ final readonly class ThreadStatusUpdater
*/
private function finish(array $messages): void
{
$this->threadLabelSynchronizer->sync($messages[0]->thread);
// Every distinct thread, not just the first one's.
//
// Each of these methods used to be reached from a route naming ONE
// conversation, so `$messages[0]->thread` was the whole set and saying
// so was honest. A bulk action passes messages from many threads at
// once, and a thread whose labels are not resynced keeps the Inbox
// label its messages no longer have — so the list goes on showing a
// conversation that has been archived. It answers 200, reports the
// right count, and changes nothing on screen, which is the most
// expensive kind of correct.
$threads = [];
foreach ($messages as $message) {
$thread = $message->thread;
if (null !== $thread) {
$threads[spl_object_id($thread)] = $thread;
}
}
foreach ($threads as $thread) {
$this->threadLabelSynchronizer->sync($thread);
}
$this->recordJmapUpdates($messages);
$this->em->flush();
}
+20 -3
View File
@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Twig;
use App\Entity\Embeddable\Appearance;
use Symfony\Component\HttpFoundation\RequestStack;
use App\Infrastructure\Event\Subscriber\AppearanceCookieSubscriber;
use App\Entity\User\User;
use App\Service\Appearance\AppearanceRenderer;
use App\Service\Appearance\BackgroundResolver;
@@ -25,6 +27,9 @@ final class AppearanceExtension extends AbstractExtension
* for both to come out of the same method.
*/
private readonly BackgroundResolver $backgrounds,
// For the no-user case above: the cookie is the only place the theme
// survives a request the firewall never ran for.
private readonly RequestStack $requests,
) {
}
@@ -45,11 +50,23 @@ final class AppearanceExtension extends AbstractExtension
{
$user = $this->security->getUser();
if (false === $user instanceof User) {
return new Appearance();
if ($user instanceof User) {
return $user->appearance;
}
return $user->appearance;
// No user is not always "not signed in". An error page is rendered with
// an empty token storage even for somebody who is: a 404 is thrown by
// the router at priority 32 of kernel.request, before the firewall at
// 8, so nothing has authenticated by the time the exception is handled.
// Falling straight through to the defaults is what put a German user in
// a beige "Papier" 404 while every other page was their own theme.
//
// The cookie is written on ordinary responses, where the user IS known
// — see AppearanceCookieSubscriber, which also explains why the error
// template cannot simply look the user up for itself.
return AppearanceCookieSubscriber::appearanceFrom(
$this->requests->getCurrentRequest()?->cookies->get(AppearanceCookieSubscriber::COOKIE),
);
}
public function appearanceClass(): string
+17 -3
View File
@@ -10,6 +10,12 @@
conditional above and the fragment layout has no such block to inherit. #}
{% block body_controllers %}core--mercure mail--mail-pane{% endblock %}
{# The one part of a view that varies within its scope: the category tab for the
inbox, the label id for a label view. Empty for every list where the scope is
the whole answer. Defined here so `block('list_scope_value')` resolves for
every list rather than only the two that care. #}
{% block list_scope_value %}{% endblock %}
{% set selectedMessage = selectedMessage|default(null) %}
{#
@@ -221,10 +227,18 @@ which of the two is happening.
{# ── Gmail-style toolbar ──────────────────────────────────────────── #}
{% block inbox_toolbar %}
{# list_scope is the same block the sync scope uses, so a view
names itself once. list_scope_value carries the one thing that
varies within a scope: the category tab for the inbox, the label
id for a label view. Both go to the server's closed set — see
App\Service\Mail\ListViewResolver. #}
{% include '_partials/_list_toolbar.html.twig' with {
page: page ?? 1,
total: total ?? 0,
per_page: per_page ?? 50,
page: page ?? 1,
total: total ?? 0,
per_page: per_page ?? 50,
list_scope: block('list_scope'),
list_scope_value: block('list_scope_value'),
unread_only: unread_only ?? false,
} %}
{% endblock %}
+31 -1
View File
@@ -80,14 +80,44 @@ The button itself carries `group` and `relative`.
{% import _self as m %}
<div
class="flex items-center px-3 py-1
{# `relative`: the select-all-in-view banner below is positioned against
this bar, so it hangs under the toolbar rather than over the first row. #}
class="relative flex items-center px-3 py-1
border-b border-line
bg-raised
shrink-0 min-h-[var(--pane-header-h)]"
data-controller="mail--list-toolbar"
data-mail--list-toolbar-total-value="{{ total }}"
{# What this list is showing, in the vocabulary the list template already
uses for itself. The server resolves it against a closed set — see
App\Service\Mail\ListViewResolver, which explains why the view is named
rather than posted as the current URL. #}
data-mail--list-toolbar-view-scope-value="{{ list_scope|default('') }}"
data-mail--list-toolbar-view-value-value="{{ list_scope_value|default('') }}"
data-mail--list-toolbar-unread-only-value="{{ unread_only|default(false) ? 'true' : 'false' }}"
data-mail--list-toolbar-i18n-value="{{ {
selectAll: 'toolbar.view_select.offer'|trans,
selectAllAction: 'toolbar.view_select.offer_action'|trans,
allSelected: 'toolbar.view_select.selected'|trans,
clear: 'toolbar.view_select.clear'|trans,
}|json_encode }}"
>
{# The offer to select past the page. Hidden until every row on screen is
ticked AND the view holds more than fits — before that it would be a
control that does nothing. Filled in by the controller, which is the only
thing that knows both numbers. #}
<div data-mail--list-toolbar-target="viewBanner"
class="hidden absolute inset-x-0 top-full z-10 flex items-center justify-center gap-2
px-3 py-1.5 text-xs bg-accent-soft text-ink border-b border-line">
<span data-mail--list-toolbar-target="viewBannerText"></span>
<button type="button"
data-mail--list-toolbar-target="viewBannerAction"
data-action="click->mail--list-toolbar#selectAllInView"
class="font-semibold text-accent underline underline-offset-2 hover:opacity-70 cursor-pointer">
</button>
</div>
{# ── Checkbox + chevron ───────────────────────────────────────────────── #}
<div class="flex items-center shrink-0 mr-2">
+3
View File
@@ -2,6 +2,9 @@
{% extends '_layout/_mailbox.html.twig' %}
{% block list_scope %}inbox{% endblock %}
{# The tab IS the view: selecting everything while looking at Notifications
must not reach into Primary, which is not on screen. #}
{% block list_scope_value %}{{ tab.value ?? '' }}{% endblock %}
{% block title %}
{% set _unread = sidebarCounts.forRoleBadge(enum('App\\Domain\\Enum\\Mail\\LabelRole').Inbox) %}
+6
View File
@@ -3,6 +3,12 @@ mail/label.html.twig — thread list for a custom label.
#}
{% extends '_layout/_mailbox.html.twig' %}
{# A label view names itself by its id, because the label is the view — see
App\Service\Mail\ListViewResolver, which checks the label belongs to the
person asking before it resolves anything. #}
{% block list_scope %}label{% endblock %}
{% block list_scope_value %}{{ label.id }}{% endblock %}
{% block title %}
{% set _unread = sidebarCounts.forLabel(label) %}
{{ _unread > 0 ? '('~_unread~')' }} {{ label.fullName }} — {{ 'app.name'|trans }}
+2
View File
@@ -1,5 +1,7 @@
{% extends '_layout/_mailbox.html.twig' %}
{% block list_scope %}starred{% endblock %}
{% block title %}
{% set _unread = sidebarCounts.forStarred() %}
{{ _unread > 0 ? '('~_unread~')' }} {{ 'sidebar.nav.starred'|trans }} — {{ 'app.name'|trans }}
@@ -0,0 +1,57 @@
{#
The answer to a bulk action: redraw the list, once.
Not a stream per row, which is what the toolbar's per-conversation posts
produced. Each of those knew what had happened to its own row and nothing else,
so a list that lost four of five rows still said "1–5 of 5", and one that lost
every row showed no empty state at all — the pager and the placeholder are
properties of the LIST, and no row-shaped response can carry them.
So this body carries no streams at all: the CLIENT reloads the list frame when
the request comes back, and the controller answers that reload exactly as it
answers any other visit to the list. One definition of what the list looks like,
and no second implementation here of the paging, filtering and sorting this
response has no parameters for.
A `<turbo-stream action="refresh">` was tried first and is not reliable enough
for this: it asks Turbo to refresh the PAGE, which is debounced and skipped
under conditions this code cannot see. Rows that leave the list happened to look
right — they were gone either way — while rows that STAY, like a conversation
being marked read, kept their old state on screen until something else
re-rendered them.
The count is a comment rather than anything visible. Nothing on screen needs it,
and a bulk action that silently does nothing is otherwise indistinguishable from
one that worked — which is exactly how a whole-view archive that resynced only
the first thread's labels answered 200, reported 71, and changed nothing.
## Why the rows are here as well as the frame reload
The rows are redrawn from here AND the client re-reads the list frame, which
looks like doing the same work twice and is not. The rows have to change the
moment the response lands, because that is the feedback for the click; the frame
has to be re-read because the pager and the empty state are properties of the
list that no row-shaped stream can carry. Leaving out the first made a bulk
mark-read take a visible beat and left the rows stale in between; leaving out
the second is the original finding.
Variables:
count how many conversations were acted on; 0 is a valid answer
threads the conversations, for redrawing their rows
leaves true when the action takes them out of THIS list (archive, trash,
restore) rather than changing them in place (read)
#}
<!-- plmail-bulk: {{ count }} -->
{% for thread in threads %}
{% if leaves %}
<turbo-stream action="remove" target="thread_{{ thread.id }}">
</turbo-stream>
{% else %}
<turbo-stream action="replace" target="thread_{{ thread.id }}">
<template>
{% include '_partials/_thread_row.html.twig' with { thread: thread } only %}
</template>
</turbo-stream>
{% endif %}
{% endfor %}
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Tests\Documentation;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Yaml\Yaml;
/**
* Everything plMail says in German duzt.
*
* That is a decision about what the product sounds like, and it held everywhere
* except a handful of strings — "Was jede **Ihrer** Adressen … tut", "Nur für
* **Sie** sichtbar", "sobald **Sie** seine Nachricht gelesen haben". Reported
* from a crawl of the running instance, which noticed it in the Aliases panel
* and assumed that panel was the exception. It was not; it was where the reader
* happened to look.
*
* ## How this can be tested at all
*
* German capitalises the polite second person — Sie, Ihr, Ihre, Ihnen — and
* also the first word of every sentence. Third-person "sie/ihre" (it, they,
* their) is lowercase mid-sentence. So a capitalised form in the MIDDLE of a
* sentence can only be formal address, while the same word at the start of one
* is ambiguous and is left alone.
*
* That is why this checks position rather than presence. A naive search for
* "Sie" flags a dozen perfectly good sentences — "Sie wird verschlüsselt
* gespeichert" about a file, "Sie verschwinden aus allen Konten" about labels —
* and a test that cries wolf gets an exception list, and then the exception
* list gets the next real one.
*
* The cost is that a sentence STARTING with formal address is not caught. Two
* such existed and were fixed by hand; the rule catches the majority and never
* lies about the rest, which is the better trade than an allow-list that rots.
*/
final class GermanIsInformalTest extends TestCase
{
/**
* Sie, Ihr and its declensions. `Ihres`/`Ihrem` are included even though
* nothing uses them today — the point is to catch the next one.
*/
private const string POLITE = '(Sie|Ihnen|Ihre[rnms]?|Ihr)';
public function testNoGermanStringAddressesTheReaderFormally(): void
{
$offenders = [];
foreach ($this->strings() as $key => $value) {
foreach ($this->midSentenceHits($value) as $hit) {
$offenders[] = sprintf('%s — "%s" in: %s', $key, $hit, $value);
}
}
self::assertSame(
[],
$offenders,
"German is informal everywhere in plMail — these address the reader as Sie:\n"
. implode("\n", $offenders),
);
}
/**
* The rule itself is worth a test, because a rule this one relies on being
* exactly right is one that can be broken by a plausible-looking tweak.
*/
public function testTheRuleSeparatesPolitenessFromCapitalisation(): void
{
// Formal address, mid-sentence: caught.
self::assertNotSame([], $this->midSentenceHits('Nur für Sie sichtbar.'));
self::assertNotSame([], $this->midSentenceHits('Was jede Ihrer Adressen tut.'));
// Third person, mid-sentence and lowercase: not formal, not caught.
self::assertSame([], $this->midSentenceHits('Die Datei wird gespeichert, sie bleibt hier.'));
// Third person at the start of a sentence, capitalised because German
// capitalises sentences: ambiguous, deliberately not caught.
self::assertSame([], $this->midSentenceHits('Die Datei ist da. Sie wird verschlüsselt.'));
self::assertSame([], $this->midSentenceHits('Sie verschwinden aus allen Konten.'));
}
/** @return list<string> */
private function midSentenceHits(string $value): array
{
$hits = [];
// Split on sentence ends, keeping it simple: what matters is that the
// first word after one is exempt.
foreach (preg_split('/(?<=[.!?:])\s+/u', $value) ?: [] as $sentence) {
// Drop the first word — that is the position politeness cannot be
// distinguished from capitalisation.
$rest = (string) preg_replace('/^\S+\s*/u', '', $sentence);
if (1 === preg_match('/\b' . self::POLITE . '\b/u', $rest, $match)) {
$hits[] = $match[1];
}
}
return $hits;
}
/** @return array<string, string> every leaf of the German catalogue */
private function strings(): array
{
$flat = [];
$walk = static function (array $node, string $path) use (&$walk, &$flat): void {
foreach ($node as $key => $value) {
$here = '' === $path ? (string) $key : $path . '.' . $key;
if (is_array($value)) {
$walk($value, $here);
continue;
}
$flat[$here] = (string) $value;
}
};
$walk(
(array) Yaml::parseFile(dirname(__DIR__, 2) . '/translations/messages.de.yaml'),
'',
);
return $flat;
}
}
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Tests\Infrastructure\Event;
use App\Domain\Enum\Theme\Theme;
use App\Infrastructure\Event\Subscriber\AppearanceCookieSubscriber;
use App\Infrastructure\Event\Subscriber\UserLocaleSubscriber;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage;
use Symfony\Component\Translation\Translator;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* An error page is in the reader's language and the reader's theme.
*
* Reported from a crawl: a German user in a "Beere" theme mistypes a URL and
* gets an English 404 in beige "Papier" — which does not read as the same
* application, and arrives at the moment somebody is already confused.
*
* It was not an oversight in the template. A 404 is thrown by the ROUTER, at
* priority 32 of kernel.request — before the firewall at 8 and before
* UserLocaleSubscriber at 6. Nothing has authenticated by the time the
* exception is handled: the token storage is empty, the request still carries
* the default locale, and the appearance falls through to its defaults. Every
* other page in the application is fine, which is exactly why this survived.
*
* The fix cannot be "look the user up", and the error template's own docblock
* says why at length: it must not need a session, a user or a database, because
* any of those may be the thing that broke. So the two display hints travel in
* a cookie written on ordinary responses, where the user IS known.
*
* Tested at this level rather than through the browser because the test
* environment runs with APP_DEBUG on, where an exception renders Symfony's
* diagnostic page and the application's template is never reached — a test
* against that would assert nothing a user could ever see.
*/
final class ErrorPageAppearanceTest extends TestCase
{
public function testTheLocaleComesFromTheCookieWhenNobodyAuthenticated(): void
{
$translator = new Translator('en');
$subscriber = new UserLocaleSubscriber(new TokenStorage(), $translator);
$request = Request::create('/gibtesnicht');
$request->cookies->set(AppearanceCookieSubscriber::COOKIE, 'nord|de');
$subscriber->onKernelException($this->exception($request));
self::assertSame('de', $request->getLocale());
self::assertSame('de', $translator->getLocale(), 'the page renders through the translator, not the request');
}
/**
* The request wins when it has been through the firewall.
*
* Every exception that is NOT a routing failure — a 500 from inside a
* controller — has already had the user's locale applied, and re-deriving
* it from a stale cookie would be the one way this could make things worse.
*/
public function testAnAuthenticatedRequestIsLeftAlone(): void
{
$translator = new Translator('en');
$storage = new TokenStorage();
$storage->setToken(new \Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken(
new \Symfony\Component\Security\Core\User\InMemoryUser('someone', null),
'main',
));
$subscriber = new UserLocaleSubscriber($storage, $translator);
$request = Request::create('/boom');
$request->setLocale('en');
$request->cookies->set(AppearanceCookieSubscriber::COOKIE, 'nord|de');
$subscriber->onKernelException($this->exception($request));
self::assertSame('en', $request->getLocale());
}
public function testAnUnknownLocaleIsIgnoredRatherThanApplied(): void
{
$translator = new Translator('en');
$subscriber = new UserLocaleSubscriber(new TokenStorage(), $translator);
$request = Request::create('/gibtesnicht');
$request->cookies->set(AppearanceCookieSubscriber::COOKIE, 'nord|xx_XX');
$subscriber->onKernelException($this->exception($request));
self::assertSame('en', $request->getLocale());
}
public function testTheThemeComesFromTheSameCookie(): void
{
self::assertSame(Theme::Nord, AppearanceCookieSubscriber::appearanceFrom('nord|de')->theme);
}
/**
* A cookie is a thing a reader can edit, so both halves are matched against
* known values and anything else falls back rather than being rendered.
*/
public function testAHandEditedCookieFallsBackToTheDefaults(): void
{
$defaults = AppearanceCookieSubscriber::appearanceFrom(null)->theme;
self::assertSame($defaults, AppearanceCookieSubscriber::appearanceFrom('../../etc|xx')->theme);
self::assertSame($defaults, AppearanceCookieSubscriber::appearanceFrom('')->theme);
self::assertSame($defaults, AppearanceCookieSubscriber::appearanceFrom('nonsense')->theme);
}
private function exception(Request $request): ExceptionEvent
{
return new ExceptionEvent(
$this->createStub(KernelInterface::class),
$request,
HttpKernelInterface::MAIN_REQUEST,
new NotFoundHttpException('No route found'),
);
}
}
+139
View File
@@ -0,0 +1,139 @@
import { test, expect } from "./support/test";
import { seed } from "./support/config";
/**
* "Select all" means all of them, and one action is one request.
*
* The checkbox selected the rows on screen, which is the only thing a checkbox
* over a paginated list can mean on its own — and not what anybody wants it to
* mean in a bin holding a hundred and ninety-five messages. It now offers to
* extend to the whole view, and the same gesture works for "every starred mail".
*
* Two other reported faults are fixed by the same change:
*
* • The toolbar posted once per conversation, in parallel. Fine for fifty
* rows, impossible for a whole folder.
* • Every one of those responses redrew its own row and knew nothing else, so
* an emptied list still said "1–5 of 5" and never showed its empty state.
* The pager and the placeholder belong to the LIST; no row-shaped response
* carries them.
*/
/**
* More than one page of conversations, which is the only state where "select
* all" and "select all in this view" differ — and therefore the only state this
* feature exists for. 200 messages lands around 67 threads against a page size
* of 50, and seeds in under ten seconds; the command's own default is 200,000,
* which is for query measurement and times out a browser context.
*/
test.beforeEach(() => {
seed("seed-bulk --messages=200");
});
// Its own account, so it has to be taken away again or every later spec in this
// worker inherits a mailbox with sixty-seven extra conversations in it.
test.afterAll(() => {
seed("seed-bulk --clear");
});
const TOOLBAR = '[data-controller="mail--list-toolbar"]';
const BANNER = '[data-mail--list-toolbar-target="viewBanner"]';
async function selectEveryRowOnScreen(page: import("@playwright/test").Page) {
await page.locator(`${TOOLBAR} [role="checkbox"]`).first().click();
}
test.describe("selecting past the page", () => {
test("offers the whole view once the page is selected, and acts on it in one request", async ({ page }) => {
await page.goto("/mail/inbox");
await expect(page.locator("#message-list li").first()).toBeVisible();
const banner = page.locator(BANNER);
await expect(banner, "the offer is showing before anything is selected").toBeHidden();
await selectEveryRowOnScreen(page);
await expect(banner, "selecting the page did not offer the rest of the view").toBeVisible();
// The offer names the real total, not the page size.
const total = Number(await page.locator(TOOLBAR).getAttribute("data-mail--list-toolbar-total-value"));
const rows = await page.locator("#message-list li").count();
expect(total, "this fixture does not span more than one page").toBeGreaterThan(rows);
await expect(banner).toContainText(String(total));
let bulkRequests = 0;
page.on("request", (request) => {
if (request.url().includes("/status/bulk/")) {
bulkRequests++;
}
});
await banner.getByRole("button").click();
await page.locator(TOOLBAR).getByRole("button", { name: /archive/i }).click();
// The whole view went, not the page: what is left is whatever else this
// worker's mailbox holds, and it has to be less than a full page.
await expect
.poll(() => page.locator("#message-list li").count(), { timeout: 15_000 })
.toBeLessThan(rows);
// One request for all of it — the thing that makes this safe to offer.
// Per conversation it would have been seventy.
expect(bulkRequests, "the toolbar posted per conversation again").toBe(1);
});
/**
* The pager and the empty state follow the rows.
*
* Reported separately: after archiving, the list lost its rows and kept
* saying "1–5 of 5"; after deleting the last draft the list was blank with
* no empty state at all. Both are the same cause — a per-row response
* cannot speak for the list.
*/
test("the pager agrees with the rows after a bulk action", async ({ page }) => {
await page.goto("/mail/inbox");
await expect(page.locator("#message-list li").first()).toBeVisible();
await selectEveryRowOnScreen(page);
await page.locator(BANNER).getByRole("button").click();
await page.locator(TOOLBAR).getByRole("button", { name: /archive/i }).click();
const pager = page.locator('[data-list-region="pagination"]');
// The reported symptom: rows went and the pager did not. It is a
// refreshable region of the frame, so what this really asserts is that
// a bulk action triggers the list refresh at all — per-conversation
// responses never did, because none of them knew anything about the
// list as a whole.
await expect
.poll(async () => {
const rows = await page.locator("#message-list li").count();
const text = await pager.innerText();
return text.includes(`of ${rows}`);
}, { timeout: 15_000 })
.toBe(true);
});
/**
* The toolbar's own total is deliberately NOT part of that.
*
* The pane's background refresh replaces marked regions of the frame and
* leaves the toolbar alone, because rebuilding it would come back from the
* server with nothing selected and silently throw away a selection the user
* had made — which mail.spec.ts pins in "a second bulk action survives the
* first one's refresh landing on it". Asserted here so the next person to
* notice the stale number reads this instead of "fixing" it.
*/
test("a refresh does not rebuild the toolbar and lose the selection", async ({ page }) => {
await page.goto("/mail/inbox");
await expect(page.locator("#message-list li").first()).toBeVisible();
await selectEveryRowOnScreen(page);
const selected = await page.locator('[data-mail--list-toolbar-target="selectionCount"]').innerText();
expect(selected, "nothing was selected to begin with").not.toBe("");
await page.locator(BANNER).getByRole("button").click();
await expect(page.locator(BANNER)).toContainText(/selected/i);
});
});
+22 -1
View File
@@ -50,7 +50,28 @@ async function isOnTop(page: import("@playwright/test").Page, selector: string):
}
test.describe("menus escape their pane", () => {
test("the label menu in a conversation is not hidden behind the navbar", async ({ page }) => {
/**
* KNOWN FAILING — the fix was reverted, and this records why rather than
* quietly disappearing with it.
*
* The reported bug is real: the label dropdown in a conversation is painted
* under the navbar, and it is not a z-index problem — the panes carry
* `backdrop-filter`, which makes each one a stacking context AND the
* containing block for `position: fixed`, so nothing anchored inside one
* can rise above the chrome whatever its number says.
*
* The attempted fix was the browser's top layer, via the popover API. It
* places the panel correctly and it broke clicking: with the panel shown as
* a popover, `document.elementFromPoint` at the option's own coordinates
* answers null and the click never lands. It did the same to the snooze
* menu. Three specs caught it, which is the system working.
*
* Reverted rather than pursued, because a menu that is visible and does
* nothing is worse than one that is clipped, and because I could not find
* the cause without seeing the real layout. It wants a reproduction in a
* browser rather than a fourth guess.
*/
test.fixme("the label menu in a conversation is not hidden behind the navbar", async ({ page }) => {
await page.goto("/mail/inbox");
await mailRow(page, INBOX_SUBJECTS.read).click();
+8 -20
View File
@@ -1,5 +1,6 @@
import { test, expect } from "./support/test";
import { INBOX_SUBJECTS, mailRow, seed } from "./support/config";
import { rowActionAwaited } from "./support/rows";
/**
* Archiving and deleting are filing, not one-way doors.
@@ -29,28 +30,12 @@ async function actionsFor(page: import("@playwright/test").Page, subject: string
return row;
}
/**
* Clicks a row action and waits for the write to land.
*
* Navigating straight after the click reads the next list from before the
* write, which fails as "the conversation is not there" — indistinguishable
* from the feature being broken, and only on some runs.
*/
async function act(page: import("@playwright/test").Page, button: import("@playwright/test").Locator, endpoint: string) {
const landed = page.waitForResponse(
(response) => response.url().includes(endpoint) && response.request().method() === "POST",
);
await button.click();
await landed;
}
test.describe("getting mail back", () => {
test("an archived conversation offers the way back, and not Archive again", async ({ page }) => {
await page.goto("/mail/inbox");
const row = await actionsFor(page, INBOX_SUBJECTS.archive);
await act(page, row.getByRole("button", { name: /archive/i }), "/archive");
await rowActionAwaited(page, row, /archive/i, "/archive");
await page.goto("/mail/archive");
@@ -69,7 +54,7 @@ test.describe("getting mail back", () => {
).toHaveCount(0);
// ...and the way back is.
await act(page, archived.getByRole("button", { name: /inbox/i }), "/restore");
await rowActionAwaited(page, archived, /inbox/i, "/restore");
await page.goto("/mail/inbox");
await expect(
@@ -86,7 +71,10 @@ test.describe("getting mail back", () => {
// `exact` on the label rather than a regex: "Delete forever" also
// matches /delete/i, and a locator that can resolve to either of two
// buttons — one reversible, one not — is not one to leave in a suite.
await act(page, row.getByRole("button", { name: "Delete", exact: true }), "/trash");
// The exact name: "Delete forever" also matches /delete/i, and a
// locator that can resolve to either of two buttons — one reversible,
// one not — is not one to leave in a suite.
await rowActionAwaited(page, row, "Delete", "/trash");
await page.goto("/mail/trash");
@@ -95,7 +83,7 @@ test.describe("getting mail back", () => {
// Both are offered here, and this is the only place delete forever is.
await expect(trashed.getByRole("button", { name: /forever/i })).toHaveCount(1);
await act(page, trashed.getByRole("button", { name: /inbox/i }), "/restore");
await rowActionAwaited(page, trashed, /inbox/i, "/restore");
await page.goto("/mail/inbox");
await expect(mailRow(page, INBOX_SUBJECTS.read)).toBeVisible({ timeout: 10_000 });
+2 -2
View File
@@ -1,5 +1,6 @@
import { test, expect } from "./support/test";
import { INBOX_SUBJECTS, mailRow, seed } from "./support/config";
import { rowAction } from "./support/rows";
/**
* The snooze menu says which day it means, in the order it means it.
@@ -53,9 +54,8 @@ async function offered(page: import("@playwright/test").Page): Promise<Array<{ k
async function openSnoozeMenu(page: import("@playwright/test").Page, subject: string) {
const row = mailRow(page, subject);
await expect(row).toBeVisible();
await row.hover();
await row.getByRole("button", { name: /snooze/i }).click({ force: true });
await rowAction(row, /snooze/i);
await expect(page.locator('[data-controller="mail--snooze-menu"]:not([hidden])').first())
.toBeVisible();
+12
View File
@@ -1,5 +1,6 @@
import { test, expect } from "./support/test";
import { INBOX_SUBJECTS, mailRow, seed } from "./support/config";
import { rowAction } from "./support/rows";
/**
* The Snoozed list says when each conversation comes back, and lets you cancel.
@@ -38,8 +39,19 @@ test("a snoozed conversation shows its wake time and can be woken", async ({ pag
const option = menu.locator("[data-snooze-key]:not([hidden])").first();
const wakeAt = await option.getAttribute("data-mail--message-row-until-param") ?? "";
// Waited for AND asserted. Navigating before the write lands reads the
// next list from before it, which fails as "the conversation is not there"
// — indistinguishable from the feature being broken, and only on some runs.
// A refused write answers just as promptly as a successful one, so the
// status is checked rather than assumed.
const snoozed = page.waitForResponse(
(response) => response.url().includes("/snooze") && response.request().method() === "POST",
);
await option.click({ force: true });
expect((await snoozed).status(), "the snooze was refused").toBe(200);
// Deliberately NOT asserting the row leaves the inbox here. It does not,
// and that is its own reported finding — the row is removed locally by some
// actions and not others, and the pager and empty state do not follow
+58
View File
@@ -0,0 +1,58 @@
import { expect, type Locator, type Page } from "@playwright/test";
/**
* Click one of a row's hover actions.
*
* The actions are `invisible group-hover:visible`, so they occupy space at all
* times and are only painted while the pointer is on the row. That makes them
* unusually easy to lose: anything that re-lays-out the list between the hover
* and the click — a live count arriving, a background refresh landing, a
* sibling menu leaving the top layer — takes the hover with it, and Playwright
* reports the button as "not stable" and then "not visible", or clicks nothing
* at all and leaves the test waiting for a request that was never sent.
*
* Under four parallel workers that happened often enough to look like the app
* being broken, on a different spec each run.
*
* `force` because visibility is exactly the property that is legitimately
* flickering here, and the button's box is correct throughout. The assertion
* before it is what keeps this honest: an action that is genuinely absent —
* Archive on already-archived mail, say — still fails, and fails saying so.
*/
export async function rowAction(row: Locator, name: string | RegExp): Promise<void> {
// Hover FIRST. The actions are `visibility: hidden` until the row is
// hovered, and a hidden element is not in the accessibility tree — so
// getByRole finds nothing at all and the assertion below would report a
// missing action for one that is merely unhovered.
await row.scrollIntoViewIfNeeded();
await row.hover();
const button = row.getByRole("button", { name });
await expect(button, `the row has no "${name}" action`).toHaveCount(1);
await button.click({ force: true });
}
/**
* Click a row action and wait for the write it makes to land.
*
* Navigating straight after the click reads the next list from before the
* write, which fails as "the conversation is not there" — indistinguishable
* from the feature being broken. The status is asserted rather than assumed,
* because a refused write answers just as promptly as a successful one.
*/
export async function rowActionAwaited(
page: Page,
row: Locator,
name: string | RegExp,
endpoint: string,
): Promise<void> {
const landed = page.waitForResponse(
(response) => response.url().includes(endpoint) && response.request().method() === "POST",
);
await rowAction(row, name);
expect((await landed).status(), `${endpoint} was refused`).toBe(200);
}
+13 -8
View File
@@ -1508,7 +1508,7 @@ message:
body_frame: Nachrichteninhalt
details:
categorised:
correspondent: 'Sie haben diesem Absender geschrieben'
correspondent: 'Du hast diesem Absender geschrieben'
default: 'nichts wies auf Massenmail hin'
forum: 'ein Mailinglisten-Header: '
gmail: 'laut Gmail: '
@@ -1554,7 +1554,7 @@ message:
subject: 'Gelesen: %subject%'
mismatch: 'Die Bestätigung soll an %notifyTo% gehen – das ist nicht die Adresse, von der diese Nachricht gesendet wurde (%sender%).'
read_at: 'Gelesen am %datetime%'
requested: '%sender% möchte benachrichtigt werden, wenn Sie diese Nachricht lesen.'
requested: '%sender% möchte benachrichtigt werden, wenn du diese Nachricht liest.'
send: 'Bestätigung senden'
save_to: 'Speichern in'
security:
@@ -1563,7 +1563,7 @@ message:
domain_in_name: "Der Name gibt %claimed% an, gesendet wurde die Nachricht aber von %actual%."
title: "Der Absendername passt nicht zur Absenderadresse"
spam:
body: "Diese Nachricht wurde als Spam eingestuft. Ihre Links und Bilder sind riskant — behandeln Sie jede Aufforderung darin wie die eines Fremden."
body: "Diese Nachricht wurde als Spam eingestuft. Ihre Links und Bilder sind riskant — behandle jede Aufforderung darin wie die eines Fremden."
title: Diese Nachricht ist im Spam
mercure:
@@ -2098,7 +2098,7 @@ settings:
invalid: 'Diese Einstellung konnte nicht gespeichert werden.'
read_receipt:
for_alias: 'Lesebestätigungen für %alias%'
hint: 'Wenn ein Absender benachrichtigt werden möchte, sobald Sie seine Nachricht gelesen haben.'
hint: 'Wenn ein Absender benachrichtigt werden möchte, sobald du seine Nachricht gelesen hast.'
inherit: 'Konto-Standard (%mode%)'
label: 'Lesebestätigungen'
mode:
@@ -2106,7 +2106,7 @@ settings:
ask: 'Jedes Mal fragen'
never: 'Nie senden'
saved: 'Gespeichert.'
subheading: 'Was jede Ihrer Adressen beim Senden und Empfangen standardmäßig tut.'
subheading: 'Was jede deiner Adressen beim Senden und Empfangen standardmäßig tut.'
filters:
account: Account
action:
@@ -2444,7 +2444,7 @@ settings:
base_url: 'Serveradresse'
base_url_fixed: 'Verbindung zu %url%, von der Administration festgelegt.'
name: Name
name_help: 'Nur für Sie sichtbar, um zwei Verbindungen auseinanderzuhalten.'
name_help: 'Nur für dich sichtbar, um zwei Verbindungen auseinanderzuhalten.'
secret:
dropbox: 'Zugriffstoken'
googleDrive: 'Zugriffstoken'
@@ -2462,7 +2462,7 @@ settings:
permissions:
immich: 'asset.read — Fotos und Videos sehen|asset.download — die Originaldatei anhängen|asset.view — Vorschauen im Dialog anzeigen|asset.upload — Anhänge in Immich speichern|album.read — Alben auflisten und durchsehen|albumAsset.create — gespeicherte Anhänge einem Album zuordnen'
permissions_heading: 'Berechtigungen für diesen Schlüssel'
permissions_note: 'Ein Schlüssel mit allen Berechtigungen funktioniert ebenfalls und ist einfacher, wenn Sie nicht einzeln auswählen möchten.'
permissions_note: 'Ein Schlüssel mit allen Berechtigungen funktioniert ebenfalls und ist einfacher, wenn du nicht einzeln auswählen möchtest.'
secret_unchanged: 'Leer lassen, um die gespeicherten Zugangsdaten zu behalten'
username: Benutzername
heading: Integrationen
@@ -2760,7 +2760,7 @@ settings:
reverted: 'Wieder die Signatur des Kontos.'
save: 'Signatur speichern'
saved: 'Gespeichert.'
subheading: 'Was am Ende der Nachrichten steht, die Sie schreiben.'
subheading: 'Was am Ende der Nachrichten steht, die du schreibst.'
timezone:
heading: Zeitzone
subheading: Die Uhr, nach der Zeiten und Daten angezeigt werden. Es wird nichts umgeschrieben — derselbe Zeitpunkt wird nur auf deiner Uhr gelesen.
@@ -2864,6 +2864,11 @@ toast:
no_archive_mailbox: Es gibt kein Archiv-Postfach.
toolbar:
view_select:
offer: 'Es lassen sich alle %count% Unterhaltungen in dieser Ansicht auswählen.'
offer_action: 'Alle %count% auswählen'
selected: 'Alle %count% Unterhaltungen in dieser Ansicht sind ausgewählt.'
clear: 'Nur diese Seite auswählen'
actions:
archive: Archivieren
delete: Löschen
+7
View File
@@ -2842,6 +2842,13 @@ toast:
no_archive_mailbox: There is no archive mailbox.
toolbar:
view_select:
# The "and the other 145" offer, shown when every row on screen is
# selected and the view holds more than fits on a page.
offer: 'All %count% conversations in this view can be selected.'
offer_action: 'Select all %count%'
selected: 'All %count% conversations in this view are selected.'
clear: 'Select only this page'
actions:
archive: Archive
delete: Delete