mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
fix(search,routing,a11y): prefix+substring search, page clamps, 404s, contrast
H-04 search found only exact whole words. The tsvector tokenizer emits "help.wirhub.de" as one host lexeme, so "wirhub" is a substring of a lexeme and no full-text query can reach it, while "Testmai" needs a prefix operator websearch_to_tsquery has no way to express. Free text now compiles to three OR-ed passes — websearch as before, a to_tsquery with :* per term, and an ILIKE needle for single terms of 3+ characters — with pg_trgm GIN indexes so the third is index-backed. Deliberate websearch syntax (quotes, OR, negation) suppresses the extra passes rather than widening what was narrowed on purpose. H-06 page numbers past the end rendered "201-193 of 193" over an empty list. The six role views were six copies of the same eight lines, so they are now one. H-07 /mail/thread/abc and ?tab=quatsch were 500s: a non-numeric id reaching the entity resolver, and MessageCategory::from on a URL parameter. Error pages are branded and localised, and deliberately standalone — the app shell asks for a session, which an anonymous 404 does not have. A-01/A-02 <html> had no lang at all, and the viewport forbade zoom. A-07 accent ink was effectively always white (2.34:1 on the reported pink); it is now chosen by WCAG relative luminance on gamma-expanded channels, scoring both candidates instead of guessing at a threshold. Every theme's ink ramp now clears 4.5:1 on its own surface and still runs strongest-to-faintest. I-02 search.title existed in no catalogue. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7fc7f88598
commit
5ff870c3b7
29 files changed
+1348
-139
No files matched your search
+31
-26
@@ -55,8 +55,8 @@
|
||||
/* Ink */
|
||||
--rgb-ink: 39 39 42;
|
||||
--rgb-ink-soft: 55 65 81; /* gray-700 */
|
||||
--rgb-ink-muted: 113 113 122;
|
||||
--rgb-ink-faint: 161 161 170;
|
||||
--rgb-ink-muted: 95 95 102;
|
||||
--rgb-ink-faint: 116 116 122;
|
||||
|
||||
/* Accent. The stored setting overrules this from an inline style on
|
||||
<html>; the value here is what the theme asks for, and Theme::defaults()
|
||||
@@ -108,8 +108,8 @@
|
||||
--rgb-sheet: 255 255 255;
|
||||
--rgb-sheet-ink: 39 39 42;
|
||||
--rgb-sheet-ink-soft: 55 65 81;
|
||||
--rgb-sheet-ink-muted: 113 113 122;
|
||||
--rgb-sheet-ink-faint: 161 161 170;
|
||||
--rgb-sheet-ink-muted: 95 95 102;
|
||||
--rgb-sheet-ink-faint: 116 116 122;
|
||||
--rgb-sheet-link: 29 78 216;
|
||||
--rgb-sheet-danger: 185 28 28;
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
--rgb-ink: 244 244 245;
|
||||
--rgb-ink-soft: 209 213 219; /* gray-300 */
|
||||
--rgb-ink-muted: 161 161 170;
|
||||
--rgb-ink-faint: 113 113 122;
|
||||
--rgb-ink-faint: 129 129 137;
|
||||
|
||||
--rgb-accent: 37 99 235;
|
||||
--rgb-accent-ink: 255 255 255;
|
||||
@@ -185,8 +185,8 @@
|
||||
--rgb-sheet: 248 250 252; /* slate-50 */
|
||||
--rgb-sheet-ink: 15 23 42; /* slate-900 */
|
||||
--rgb-sheet-ink-soft: 51 65 85; /* slate-700 */
|
||||
--rgb-sheet-ink-muted: 100 116 139; /* slate-500 */
|
||||
--rgb-sheet-ink-faint: 148 163 184; /* slate-400 */
|
||||
--rgb-sheet-ink-muted: 81 94 113; /* slate-500 */
|
||||
--rgb-sheet-ink-faint: 104 114 129; /* slate-400 */
|
||||
--rgb-sheet-link: 29 78 216;
|
||||
--rgb-sheet-danger: 185 28 28;
|
||||
}
|
||||
@@ -215,8 +215,8 @@
|
||||
|
||||
--rgb-ink: 35 34 32;
|
||||
--rgb-ink-soft: 82 78 72;
|
||||
--rgb-ink-muted: 107 102 94;
|
||||
--rgb-ink-faint: 150 144 134;
|
||||
--rgb-ink-muted: 95 91 84;
|
||||
--rgb-ink-faint: 116 111 103;
|
||||
|
||||
--rgb-accent: 125 107 79;
|
||||
--rgb-accent-ink: 255 255 255;
|
||||
@@ -255,8 +255,8 @@
|
||||
--rgb-sheet: 247 245 239;
|
||||
--rgb-sheet-ink: 35 34 32;
|
||||
--rgb-sheet-ink-soft: 82 78 72;
|
||||
--rgb-sheet-ink-muted: 107 102 94;
|
||||
--rgb-sheet-ink-faint: 150 144 134;
|
||||
--rgb-sheet-ink-muted: 95 91 84;
|
||||
--rgb-sheet-ink-faint: 116 111 103;
|
||||
--rgb-sheet-link: 29 78 216;
|
||||
--rgb-sheet-danger: 185 28 28;
|
||||
}
|
||||
@@ -292,8 +292,8 @@
|
||||
|
||||
--rgb-ink: 214 209 202;
|
||||
--rgb-ink-soft: 186 181 174;
|
||||
--rgb-ink-muted: 142 137 130;
|
||||
--rgb-ink-faint: 108 104 98;
|
||||
--rgb-ink-muted: 159 155 149;
|
||||
--rgb-ink-faint: 134 131 126;
|
||||
|
||||
--rgb-accent: 58 111 92;
|
||||
--rgb-accent-ink: 240 245 242;
|
||||
@@ -338,8 +338,8 @@
|
||||
--rgb-sheet: 248 250 252; /* slate-50 */
|
||||
--rgb-sheet-ink: 15 23 42; /* slate-900 */
|
||||
--rgb-sheet-ink-soft: 51 65 85; /* slate-700 */
|
||||
--rgb-sheet-ink-muted: 100 116 139; /* slate-500 */
|
||||
--rgb-sheet-ink-faint: 148 163 184; /* slate-400 */
|
||||
--rgb-sheet-ink-muted: 81 94 113; /* slate-500 */
|
||||
--rgb-sheet-ink-faint: 104 114 129; /* slate-400 */
|
||||
--rgb-sheet-link: 29 78 216;
|
||||
--rgb-sheet-danger: 185 28 28;
|
||||
}
|
||||
@@ -395,9 +395,9 @@
|
||||
lightest colour rather than a warm cream borrowed from elsewhere. */
|
||||
--rgb-sheet: 236 239 244;
|
||||
--rgb-sheet-ink: 46 52 64;
|
||||
--rgb-sheet-ink-soft: 88 93 104;
|
||||
--rgb-sheet-ink-muted: 111 116 125;
|
||||
--rgb-sheet-ink-faint: 149 153 161;
|
||||
--rgb-sheet-ink-soft: 66 70 78;
|
||||
--rgb-sheet-ink-muted: 83 87 94;
|
||||
--rgb-sheet-ink-faint: 104 107 113;
|
||||
--rgb-sheet-link: 94 129 172; /* #5e81ac */
|
||||
--rgb-sheet-danger: 191 97 106; /* #bf616a */
|
||||
}
|
||||
@@ -451,8 +451,8 @@
|
||||
--rgb-sheet: 245 243 255;
|
||||
--rgb-sheet-ink: 30 27 46;
|
||||
--rgb-sheet-ink-soft: 77 75 92;
|
||||
--rgb-sheet-ink-muted: 103 100 117;
|
||||
--rgb-sheet-ink-faint: 146 144 159;
|
||||
--rgb-sheet-ink-muted: 91 88 103;
|
||||
--rgb-sheet-ink-faint: 111 109 121;
|
||||
--rgb-sheet-link: 109 40 217;
|
||||
--rgb-sheet-danger: 185 28 28;
|
||||
}
|
||||
@@ -472,10 +472,15 @@
|
||||
--rgb-sunken: 238 232 213; /* base2 #eee8d5 */
|
||||
--rgb-shadow: 0 0 0;
|
||||
|
||||
--rgb-ink: 88 110 117; /* base01 #586e75 */
|
||||
--rgb-ink-soft: 94 116 124;
|
||||
--rgb-ink-muted: 101 123 131; /* base00 #657b83 */
|
||||
--rgb-ink-faint: 147 161 161; /* base1 #93a1a1 */
|
||||
/* Solarized light's own content tones (base01/base00/base1) sit inside a
|
||||
narrow band — roughly 2.5:1 to 5:1 on base3. That band cannot hold four
|
||||
tiers where even the FAINTEST must clear 4.5:1, so the ramp is extended
|
||||
downward past base02 rather than compressed into illegibility. The hue
|
||||
is solarized's; the range is not. */
|
||||
--rgb-ink: 46 57 61;
|
||||
--rgb-ink-soft: 61 75 81;
|
||||
--rgb-ink-muted: 77 93 100; /* base00 #657b83, darkened */
|
||||
--rgb-ink-faint: 104 114 114;
|
||||
|
||||
--rgb-accent: 181 137 0; /* yellow #b58900 */
|
||||
--rgb-accent-ink: 255 255 255;
|
||||
@@ -507,8 +512,8 @@
|
||||
--rgb-sheet: 253 246 227; /* base3 */
|
||||
--rgb-sheet-ink: 7 54 66; /* base02 #073642 */
|
||||
--rgb-sheet-ink-soft: 61 96 101;
|
||||
--rgb-sheet-ink-muted: 91 119 121;
|
||||
--rgb-sheet-ink-faint: 140 158 153;
|
||||
--rgb-sheet-ink-muted: 74 96 98;
|
||||
--rgb-sheet-ink-faint: 102 115 112;
|
||||
--rgb-sheet-link: 38 139 210; /* blue #268bd2 */
|
||||
--rgb-sheet-danger: 220 50 47; /* red #dc322f */
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Trigram indexes for the substring pass in search.
|
||||
*
|
||||
* Why search needs a substring pass at all is in FreeTextCompiler: the
|
||||
* tokenizer emits "help.wirhub.de" as one `host` lexeme, so "wirhub" is not a
|
||||
* prefix of anything in the tsvector and full-text search cannot reach it by
|
||||
* any query. ILIKE '%wirhub%' can, and without an index that is a sequential
|
||||
* scan over every body in the account.
|
||||
*
|
||||
* pg_trgm ships with the postgres image (available 1.6, not previously
|
||||
* installed). GIN over gin_trgm_ops lets the planner serve `%needle%` from the
|
||||
* index for needles of three characters or more, which is exactly the floor
|
||||
* FreeTextCompiler enforces before it emits a needle at all.
|
||||
*
|
||||
* The cost, stated plainly because it is not free:
|
||||
*
|
||||
* Write amplification. Every INSERT of a message now maintains four more GIN
|
||||
* indexes, body_text's being much the largest — a trigram index over prose is
|
||||
* roughly the size of the prose. Sync is a bulk insert path, so this is the
|
||||
* number that matters; GIN's pending-list (fastupdate, on by default) absorbs
|
||||
* it in batches rather than per row.
|
||||
*
|
||||
* Disk. Expect the trigram index on body_text to approach the size of the
|
||||
* body_text column itself. This is the real price of the feature.
|
||||
*
|
||||
* The alternative — leaving the substring pass unindexed — was rejected because
|
||||
* a search box that is correct and slow gets used and then reported as slow,
|
||||
* whereas one that is fast and wrong gets reported as broken, which is what
|
||||
* happened.
|
||||
*
|
||||
* CONCURRENTLY is deliberately NOT used: it cannot run inside the transaction
|
||||
* migrations execute in. On an install large enough for that to matter, the
|
||||
* index build takes a write lock on `message` for its duration.
|
||||
*/
|
||||
final class Version20260811160000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Install pg_trgm and add GIN trigram indexes for substring search on message';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE EXTENSION IF NOT EXISTS pg_trgm');
|
||||
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_message_subject_trgm ON message USING GIN (subject gin_trgm_ops)');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_message_body_text_trgm ON message USING GIN (body_text gin_trgm_ops)');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_message_from_name_trgm ON message USING GIN (from_name gin_trgm_ops)');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_message_from_address_trgm ON message USING GIN (from_address gin_trgm_ops)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP INDEX IF EXISTS idx_message_from_address_trgm');
|
||||
$this->addSql('DROP INDEX IF EXISTS idx_message_from_name_trgm');
|
||||
$this->addSql('DROP INDEX IF EXISTS idx_message_body_text_trgm');
|
||||
$this->addSql('DROP INDEX IF EXISTS idx_message_subject_trgm');
|
||||
|
||||
// The extension stays. btree_gist is installed by the calendar
|
||||
// migration and nothing here can know whether something else has since
|
||||
// come to depend on pg_trgm; dropping a shared extension on the way
|
||||
// down is how an unrelated index disappears.
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ final class AttachmentController extends AbstractController
|
||||
* answers 404 and lets the chip keep its icon, rather than sending the
|
||||
* full-size original under a preview's name.
|
||||
*/
|
||||
#[Route('/mail/attachment/{id}/thumbnail', name: 'app_mail_attachment_thumbnail', methods: ['GET'])]
|
||||
#[Route('/mail/attachment/{id}/thumbnail', name: 'app_mail_attachment_thumbnail', methods: ['GET'], requirements: ['id' => '\d+'])]
|
||||
public function thumbnail(MessagePart $part): Response
|
||||
{
|
||||
$this->assertOwned($part);
|
||||
@@ -54,7 +54,7 @@ final class AttachmentController extends AbstractController
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[Route('/mail/attachment/{id}', name: 'app_mail_attachment', methods: ['GET'])]
|
||||
#[Route('/mail/attachment/{id}', name: 'app_mail_attachment', methods: ['GET'], requirements: ['id' => '\d+'])]
|
||||
public function serve(MessagePart $part, Request $request): Response
|
||||
{
|
||||
$this->assertOwned($part);
|
||||
|
||||
@@ -18,6 +18,7 @@ use App\Twig\SidebarCounts;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
@@ -35,21 +36,73 @@ final class MailController extends AbstractController
|
||||
{
|
||||
}
|
||||
|
||||
/** Rows per page, for every list view and the pagination that clamps them. */
|
||||
private const int PER_PAGE = 50;
|
||||
|
||||
/**
|
||||
* The page to render — or a redirect to the last page that exists.
|
||||
*
|
||||
* `?page=999` on a 193-row folder used to render the header "201–193 of
|
||||
* 193" above nothing at all: the lower bound was clamped and the upper one
|
||||
* was not, so the arithmetic ran off the end of the list and said so. The
|
||||
* page number is user input via the URL, and the honest answer to a page
|
||||
* that does not exist is the last one that does.
|
||||
*
|
||||
* A redirect rather than a silent clamp, so the address bar stops claiming
|
||||
* a page the user is not on — otherwise reloading, bookmarking or sharing
|
||||
* the URL all carry the phantom page number along.
|
||||
*
|
||||
* Built from the current path and query rather than from a route name: this
|
||||
* serves ten list actions whose route parameters differ, and the one thing
|
||||
* they share is that only `page` needs changing.
|
||||
*/
|
||||
private function pageOrRedirect(Request $request, int $total): int|RedirectResponse
|
||||
{
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$lastPage = max(1, (int) ceil($total / self::PER_PAGE));
|
||||
|
||||
if ($page <= $lastPage) {
|
||||
return $page;
|
||||
}
|
||||
|
||||
$query = $request->query->all();
|
||||
|
||||
// Page 1 is the bare URL. Carrying `?page=1` would make the canonical
|
||||
// first page of every list a different URL depending on how it was
|
||||
// reached.
|
||||
if (1 === $lastPage) {
|
||||
unset($query['page']);
|
||||
} else {
|
||||
$query['page'] = $lastPage;
|
||||
}
|
||||
|
||||
return $this->redirect(
|
||||
$request->getPathInfo() . ([] === $query ? '' : '?' . http_build_query($query)),
|
||||
);
|
||||
}
|
||||
|
||||
#[Route('/inbox', name: 'inbox')]
|
||||
public function inbox(
|
||||
Request $request,
|
||||
): Response {
|
||||
$user = $this->getUser();
|
||||
$tab = MessageCategory::Primary;
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
|
||||
$tabParam = $request->query->get('tab');
|
||||
if ($tabParam !== null) {
|
||||
$tab = MessageCategory::from($tabParam);
|
||||
// tryFrom, not from: `?tab=` is a URL anyone can edit, and a category
|
||||
// that does not exist is a mistyped or stale link, not a server fault.
|
||||
// ValueError out of an enum here surfaced as a 500 on ?tab=quatsch.
|
||||
// Falling back to Primary shows the inbox, which is what the person
|
||||
// following that link was after.
|
||||
$tab = MessageCategory::tryFrom((string) $request->query->get('tab', ''))
|
||||
?? MessageCategory::Primary;
|
||||
|
||||
$total = $this->threadRepository->countForUnifiedInbox($user, $tab);
|
||||
$page = $this->pageOrRedirect($request, $total);
|
||||
|
||||
if ($page instanceof RedirectResponse) {
|
||||
return $page;
|
||||
}
|
||||
|
||||
$threads = $this->threadRepository->findForUnifiedInbox($user, $tab, $page);
|
||||
$total = $this->threadRepository->countForUnifiedInbox($user, $tab);
|
||||
$tabCounts = $this->threadRepository->countUnreadByCategoryForUnifiedInbox($user);
|
||||
$tabTotals = $this->threadRepository->countByCategoryForUnifiedInbox($user);
|
||||
|
||||
@@ -74,7 +127,7 @@ final class MailController extends AbstractController
|
||||
'tabCounts' => $tabCounts,
|
||||
'page' => $page,
|
||||
'total' => $total,
|
||||
'per_page' => 50,
|
||||
'per_page' => self::PER_PAGE,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -99,7 +152,7 @@ final class MailController extends AbstractController
|
||||
return $this->renderLabel($label, $request);
|
||||
}
|
||||
|
||||
#[Route('/label/{id}', name: 'label')]
|
||||
#[Route('/label/{id}', name: 'label', requirements: ['id' => '\d+'])]
|
||||
public function labelView(Label $label, Request $request): Response
|
||||
{
|
||||
if ($label->usr !== $this->getUser()) {
|
||||
@@ -112,9 +165,14 @@ final class MailController extends AbstractController
|
||||
private function renderLabel(Label $label, Request $request): Response
|
||||
{
|
||||
$account = $this->requestedAccount($request);
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$threads = $this->threadRepository->findForLabel($label, $account, $page);
|
||||
$total = $this->threadRepository->countForLabel($label, $account);
|
||||
$page = $this->pageOrRedirect($request, $total);
|
||||
|
||||
if ($page instanceof RedirectResponse) {
|
||||
return $page;
|
||||
}
|
||||
|
||||
$threads = $this->threadRepository->findForLabel($label, $account, $page);
|
||||
|
||||
$this->threadRepository->preloadLabels($threads);
|
||||
|
||||
@@ -124,7 +182,7 @@ final class MailController extends AbstractController
|
||||
'threads' => $threads,
|
||||
'page' => $page,
|
||||
'total' => $total,
|
||||
'per_page' => 50,
|
||||
'per_page' => self::PER_PAGE,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -160,16 +218,21 @@ final class MailController extends AbstractController
|
||||
* Everything in one account — what clicking the account itself in the
|
||||
* sidebar now does. Its labels sit underneath for narrowing further.
|
||||
*/
|
||||
#[Route('/account/{account}', name: 'account')]
|
||||
#[Route('/account/{account}', name: 'account', requirements: ['account' => '\d+'])]
|
||||
public function accountView(Account $account, Request $request): Response
|
||||
{
|
||||
if ($account->usr !== $this->getUser()) {
|
||||
throw $this->createAccessDeniedException();
|
||||
}
|
||||
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$threads = $this->threadRepository->findForAccount($account, $page);
|
||||
$total = $this->threadRepository->countForAccount($account);
|
||||
$page = $this->pageOrRedirect($request, $total);
|
||||
|
||||
if ($page instanceof RedirectResponse) {
|
||||
return $page;
|
||||
}
|
||||
|
||||
$threads = $this->threadRepository->findForAccount($account, $page);
|
||||
|
||||
$this->threadRepository->preloadLabels($threads);
|
||||
|
||||
@@ -178,7 +241,7 @@ final class MailController extends AbstractController
|
||||
'threads' => $threads,
|
||||
'page' => $page,
|
||||
'total' => $total,
|
||||
'per_page' => 50,
|
||||
'per_page' => self::PER_PAGE,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -186,9 +249,14 @@ final class MailController extends AbstractController
|
||||
public function starred(Request $request): Response
|
||||
{
|
||||
$user = $this->getUser();
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$threads = $this->threadRepository->findForStarred($user, $page);
|
||||
$total = $this->threadRepository->countForStarred($user);
|
||||
$page = $this->pageOrRedirect($request, $total);
|
||||
|
||||
if ($page instanceof RedirectResponse) {
|
||||
return $page;
|
||||
}
|
||||
|
||||
$threads = $this->threadRepository->findForStarred($user, $page);
|
||||
|
||||
$this->threadRepository->preloadLabels($threads);
|
||||
|
||||
@@ -196,62 +264,26 @@ final class MailController extends AbstractController
|
||||
'threads' => $threads,
|
||||
'page' => $page,
|
||||
'total' => $total,
|
||||
'per_page' => 50,
|
||||
'per_page' => self::PER_PAGE,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/sent', name: 'sent')]
|
||||
public function sent(Request $request): Response
|
||||
{
|
||||
$user = $this->getUser();
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$threads = $this->threadRepository->findForRole($user, LabelRole::Sent, $page);
|
||||
$total = $this->threadRepository->countForRole($user, LabelRole::Sent);
|
||||
|
||||
$this->threadRepository->preloadLabels($threads);
|
||||
|
||||
return $this->render('mail/sent.html.twig', [
|
||||
'threads' => $threads,
|
||||
'page' => $page,
|
||||
'total' => $total,
|
||||
'per_page' => 50,
|
||||
]);
|
||||
return $this->renderRole($request, LabelRole::Sent, 'mail/sent.html.twig');
|
||||
}
|
||||
|
||||
#[Route('/drafts', name: 'drafts')]
|
||||
public function drafts(Request $request): Response
|
||||
{
|
||||
$user = $this->getUser();
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$threads = $this->threadRepository->findForRole($user, LabelRole::Drafts, $page);
|
||||
$total = $this->threadRepository->countForRole($user, LabelRole::Drafts);
|
||||
|
||||
$this->threadRepository->preloadLabels($threads);
|
||||
|
||||
return $this->render('mail/drafts.html.twig', [
|
||||
'threads' => $threads,
|
||||
'page' => $page,
|
||||
'total' => $total,
|
||||
'per_page' => 50,
|
||||
]);
|
||||
return $this->renderRole($request, LabelRole::Drafts, 'mail/drafts.html.twig');
|
||||
}
|
||||
|
||||
#[Route('/trash', name: 'trash')]
|
||||
public function trash(Request $request): Response
|
||||
{
|
||||
$user = $this->getUser();
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$threads = $this->threadRepository->findForRole($user, LabelRole::Trash, $page);
|
||||
$total = $this->threadRepository->countForRole($user, LabelRole::Trash);
|
||||
|
||||
$this->threadRepository->preloadLabels($threads);
|
||||
|
||||
return $this->render('mail/trash.html.twig', [
|
||||
'threads' => $threads,
|
||||
'page' => $page,
|
||||
'total' => $total,
|
||||
'per_page' => 50,
|
||||
]);
|
||||
return $this->renderRole($request, LabelRole::Trash, 'mail/trash.html.twig');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,38 +299,14 @@ final class MailController extends AbstractController
|
||||
#[Route('/spam', name: 'spam')]
|
||||
public function spam(Request $request): Response
|
||||
{
|
||||
$user = $this->getUser();
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$threads = $this->threadRepository->findForRole($user, LabelRole::Spam, $page);
|
||||
$total = $this->threadRepository->countForRole($user, LabelRole::Spam);
|
||||
|
||||
$this->threadRepository->preloadLabels($threads);
|
||||
|
||||
return $this->render('mail/spam.html.twig', [
|
||||
'threads' => $threads,
|
||||
'page' => $page,
|
||||
'total' => $total,
|
||||
'per_page' => 50,
|
||||
]);
|
||||
return $this->renderRole($request, LabelRole::Spam, 'mail/spam.html.twig');
|
||||
}
|
||||
|
||||
/** Archive role view. */
|
||||
#[Route('/archive', name: 'archive')]
|
||||
public function archive(Request $request): Response
|
||||
{
|
||||
$user = $this->getUser();
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$threads = $this->threadRepository->findForRole($user, LabelRole::Archive, $page);
|
||||
$total = $this->threadRepository->countForRole($user, LabelRole::Archive);
|
||||
|
||||
$this->threadRepository->preloadLabels($threads);
|
||||
|
||||
return $this->render('mail/archive.html.twig', [
|
||||
'threads' => $threads,
|
||||
'page' => $page,
|
||||
'total' => $total,
|
||||
'per_page' => 50,
|
||||
]);
|
||||
return $this->renderRole($request, LabelRole::Archive, 'mail/archive.html.twig');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -312,23 +320,41 @@ final class MailController extends AbstractController
|
||||
*/
|
||||
#[Route('/snoozed', name: 'snoozed')]
|
||||
public function snoozed(Request $request): Response
|
||||
{
|
||||
return $this->renderRole($request, LabelRole::Snoozed, 'mail/snoozed.html.twig');
|
||||
}
|
||||
|
||||
/**
|
||||
* The six role views — Sent, Drafts, Trash, Spam, Archive, Snoozed — differ
|
||||
* only in which role they count and which template they hand it to.
|
||||
*
|
||||
* They were six copies of the same eight lines until the page clamp had to
|
||||
* be added to all of them, which is the usual way a fix reaches five of six
|
||||
* places. One body means the next such change lands everywhere or nowhere.
|
||||
*/
|
||||
private function renderRole(Request $request, LabelRole $role, string $template): Response
|
||||
{
|
||||
$user = $this->getUser();
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$threads = $this->threadRepository->findForRole($user, LabelRole::Snoozed, $page);
|
||||
$total = $this->threadRepository->countForRole($user, LabelRole::Snoozed);
|
||||
$total = $this->threadRepository->countForRole($user, $role);
|
||||
$page = $this->pageOrRedirect($request, $total);
|
||||
|
||||
if ($page instanceof RedirectResponse) {
|
||||
return $page;
|
||||
}
|
||||
|
||||
$threads = $this->threadRepository->findForRole($user, $role, $page);
|
||||
|
||||
$this->threadRepository->preloadLabels($threads);
|
||||
|
||||
return $this->render('mail/snoozed.html.twig', [
|
||||
return $this->render($template, [
|
||||
'threads' => $threads,
|
||||
'page' => $page,
|
||||
'total' => $total,
|
||||
'per_page' => 50,
|
||||
'per_page' => self::PER_PAGE,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/account/{account}/folders', name: 'account_folders')]
|
||||
#[Route('/account/{account}/folders', name: 'account_folders', requirements: ['account' => '\d+'])]
|
||||
public function accountFolders(Account $account): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_USER');
|
||||
@@ -434,7 +460,7 @@ final class MailController extends AbstractController
|
||||
return $this->json($payload);
|
||||
}
|
||||
|
||||
#[Route('/message/{id}', name: 'message')]
|
||||
#[Route('/message/{id}', name: 'message', requirements: ['id' => '\d+'])]
|
||||
public function message(Message $message, Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_USER');
|
||||
@@ -463,7 +489,13 @@ final class MailController extends AbstractController
|
||||
}
|
||||
|
||||
|
||||
#[Route('/thread/{id}', name: 'thread')]
|
||||
/**
|
||||
* `{id}` is constrained to digits so a non-numeric id fails to MATCH the
|
||||
* route and becomes a 404. Without it the id reaches the entity resolver,
|
||||
* which asks Postgres for a MessageThread whose bigint id is 'abc' and gets
|
||||
* back a driver-level type error — a 500 for what is only a bad link.
|
||||
*/
|
||||
#[Route('/thread/{id}', name: 'thread', requirements: ['id' => '\d+'])]
|
||||
public function thread(MessageThread $thread, Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_USER');
|
||||
|
||||
@@ -18,7 +18,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
* No raw RFC822 blob is stored, so the source view is reconstructed from the
|
||||
* persisted header map plus the decoded body.
|
||||
*/
|
||||
#[Route('/mail/message/{id}', name: 'app_mail_message_')]
|
||||
#[Route('/mail/message/{id}', name: 'app_mail_message_', requirements: ['id' => '\d+'])]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
final class MessageSourceController extends AbstractController
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Domain\Enum\Mail\SearchSortOrder;
|
||||
use App\Entity\Mail\Account;
|
||||
use App\Entity\Label\Label;
|
||||
use App\Entity\Mail\MessageThread;
|
||||
use App\Service\Search\FreeTextCompiler;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\DBAL\ArrayParameterType;
|
||||
use Doctrine\DBAL\ParameterType;
|
||||
@@ -18,8 +19,10 @@ use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
class MessageThreadRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
public function __construct(
|
||||
ManagerRegistry $registry,
|
||||
private readonly FreeTextCompiler $freeText = new FreeTextCompiler(),
|
||||
) {
|
||||
parent::__construct($registry, MessageThread::class);
|
||||
}
|
||||
|
||||
@@ -680,13 +683,56 @@ class MessageThreadRepository extends ServiceEntityRepository
|
||||
$params['userId'] = $user->id;
|
||||
$types['userId'] = ParameterType::INTEGER;
|
||||
|
||||
// ── Free-text via tsvector ────────────────────────────────────────
|
||||
// ── Free-text: three passes, OR-ed ────────────────────────────────
|
||||
// websearch_to_tsquery alone answers only the question "does this mail
|
||||
// contain exactly these words", which is not the question anyone types
|
||||
// into a search box. The other two passes are additive — nothing that
|
||||
// matched before stops matching — and each costs something:
|
||||
//
|
||||
// websearch index-only, GIN on search_vector. Free.
|
||||
// prefix the same GIN index; `:*` makes it a range over the
|
||||
// lexeme dictionary rather than a point lookup, so it
|
||||
// reads more index entries but no more heap.
|
||||
// substring ILIKE '%needle%'. Sequential without help, which is why
|
||||
// Version20260811... installs pg_trgm and GIN trigram
|
||||
// indexes over the four columns it touches. Restricted to
|
||||
// a single term of 3+ characters — see FreeTextCompiler,
|
||||
// which is also where the safety of the tsquery text is
|
||||
// established.
|
||||
$rankExpr = '0';
|
||||
|
||||
if ($query->freeText !== '') {
|
||||
$where[] = "m.search_vector @@ websearch_to_tsquery('english', :freeText)";
|
||||
$params['freeText'] = $query->freeText;
|
||||
$rankExpr = "ts_rank(m.search_vector, websearch_to_tsquery('english', :freeText))";
|
||||
$free = $this->freeText->compile($query->freeText);
|
||||
|
||||
$matches = ["m.search_vector @@ websearch_to_tsquery('english', :freeText)"];
|
||||
$ranks = ["ts_rank(m.search_vector, websearch_to_tsquery('english', :freeText))"];
|
||||
$params['freeText'] = $free->websearch;
|
||||
|
||||
if (null !== $free->prefix) {
|
||||
$matches[] = "m.search_vector @@ to_tsquery('english', :freePrefix)";
|
||||
$ranks[] = "ts_rank(m.search_vector, to_tsquery('english', :freePrefix))";
|
||||
$params['freePrefix'] = $free->prefix;
|
||||
}
|
||||
|
||||
if (null !== $free->substring) {
|
||||
// from_name and from_address as well as the body: a search for
|
||||
// part of a sender's domain is the same gesture as a search for
|
||||
// part of a word in the text, and fails the same way without it.
|
||||
$matches[] = '('
|
||||
. 'm.subject ILIKE :freeLike'
|
||||
. ' OR m.body_text ILIKE :freeLike'
|
||||
. ' OR m.from_name ILIKE :freeLike'
|
||||
. ' OR m.from_address ILIKE :freeLike'
|
||||
. ')';
|
||||
$params['freeLike'] = $this->freeText->likePattern($free->substring);
|
||||
}
|
||||
|
||||
$where[] = '(' . implode(' OR ', $matches) . ')';
|
||||
|
||||
// A row reached only by the substring pass has no ts_rank to give;
|
||||
// GREATEST over what did match keeps relevance ordering meaningful
|
||||
// instead of letting a NULL sink every such row to the bottom.
|
||||
$rankExpr = 1 === count($ranks) ? $ranks[0] : 'GREATEST(' . implode(', ', $ranks) . ')';
|
||||
}
|
||||
|
||||
// ── Operator filters ──────────────────────────────────────────────
|
||||
|
||||
@@ -9,6 +9,9 @@ use App\Entity\Embeddable\Appearance;
|
||||
|
||||
final readonly class AppearanceRenderer
|
||||
{
|
||||
/** WCAG 2.x AA for body text. What accent-ink is held to. */
|
||||
private const float MIN_CONTRAST = 4.5;
|
||||
|
||||
public function __construct(private BackgroundResolver $backgroundResolver)
|
||||
{
|
||||
}
|
||||
@@ -103,13 +106,74 @@ final readonly class AppearanceRenderer
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The ink to write ON the accent, chosen by measuring rather than assuming.
|
||||
*
|
||||
* The accent is picked by the user from a colour wheel, so no fixed ink can
|
||||
* be right for all of it — and white was effectively the fixed answer: the
|
||||
* old test weighted the raw 0–255 channels and only crossed its 0.6
|
||||
* threshold for very light colours, so a mid-tone pink got white text at
|
||||
* 2.34:1, well under the 4.5:1 WCAG demands of body text.
|
||||
*
|
||||
* Two corrections. First, luminance is computed the way WCAG defines it —
|
||||
* on gamma-EXPANDED channels. sRGB is not linear in intensity, and skipping
|
||||
* the expansion overstates how dark a saturated mid-tone is, which is
|
||||
* exactly the region where the wrong ink gets chosen. Second, there is no
|
||||
* threshold to tune: both candidate inks are scored against the accent and
|
||||
* the better one wins, so the rule states its own intent and cannot drift.
|
||||
*
|
||||
* The dark candidate is normally #18181B, which is what dark text is
|
||||
* everywhere else in the app; a different black for accent chips alone
|
||||
* would read as a mistake. But two inks are not quite enough to promise
|
||||
* 4.5:1: the accents where white and #18181B score equally sit at a
|
||||
* relative luminance near 0.198, and both score 4.24:1 there. That band is
|
||||
* narrow and it is real, so when the house ink cannot clear 4.5:1 the dark
|
||||
* candidate becomes pure black, which lifts the worst case over the line
|
||||
* (4.58:1) for every accent a user can pick. In practice this only engages
|
||||
* for mid-tone accents; everything else still gets the house ink.
|
||||
*/
|
||||
private static function contrastChannels(string $hex): string
|
||||
{
|
||||
[$r, $g, $b] = self::rgb($hex);
|
||||
$accent = self::relativeLuminance(self::rgb($hex));
|
||||
|
||||
$luminance = (0.2126 * $r + 0.7152 * $g + 0.0722 * $b) / 255;
|
||||
$houseInk = [24, 24, 27];
|
||||
$lightInk = [255, 255, 255];
|
||||
$blackInk = [0, 0, 0];
|
||||
|
||||
return $luminance > 0.6 ? '24 24 27' : '255 255 255';
|
||||
$onHouse = self::contrastRatio($accent, self::relativeLuminance($houseInk));
|
||||
$onLight = self::contrastRatio($accent, self::relativeLuminance($lightInk));
|
||||
|
||||
if ($onLight > $onHouse && $onLight > self::contrastRatio($accent, self::relativeLuminance($blackInk))) {
|
||||
return sprintf('%d %d %d', ...$lightInk);
|
||||
}
|
||||
|
||||
$ink = $onHouse >= self::MIN_CONTRAST ? $houseInk : $blackInk;
|
||||
|
||||
return sprintf('%d %d %d', ...$ink);
|
||||
}
|
||||
|
||||
/**
|
||||
* WCAG 2.x relative luminance.
|
||||
*
|
||||
* @param array{0:int,1:int,2:int} $rgb
|
||||
*/
|
||||
private static function relativeLuminance(array $rgb): float
|
||||
{
|
||||
$expand = static function (int $channel): float {
|
||||
$c = $channel / 255;
|
||||
|
||||
return $c <= 0.04045 ? $c / 12.92 : (($c + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
|
||||
return 0.2126 * $expand($rgb[0])
|
||||
+ 0.7152 * $expand($rgb[1])
|
||||
+ 0.0722 * $expand($rgb[2]);
|
||||
}
|
||||
|
||||
/** WCAG contrast ratio, 1.0 (identical) to 21.0 (black on white). */
|
||||
private static function contrastRatio(float $a, float $b): float
|
||||
{
|
||||
return (max($a, $b) + 0.05) / (min($a, $b) + 0.05);
|
||||
}
|
||||
|
||||
/** @return array{0:int,1:int,2:int} */
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Search;
|
||||
|
||||
/**
|
||||
* Turns the free-text remainder of a search into the forms Postgres can match.
|
||||
*
|
||||
* Why this is not just websearch_to_tsquery
|
||||
* -----------------------------------------
|
||||
* A tsvector holds LEXEMES, and a lexeme only matches whole. Two consequences
|
||||
* bit users hard enough to be reported as one bug:
|
||||
*
|
||||
* 1. "Testmai" finds nothing, because the mail contains "Testmail" and a
|
||||
* tsquery without `:*` asks for equality, not for a beginning.
|
||||
* 2. "wirhub" finds nothing in a body that plainly reads "help.wirhub.de",
|
||||
* because the tokenizer classifies that as ONE `host` token. No amount of
|
||||
* prefix matching fixes this one — "wirhub" is not a prefix of
|
||||
* "help.wirhub.de", it is in the middle of it — so it needs a substring
|
||||
* match, which full-text search structurally cannot offer.
|
||||
*
|
||||
* So: prefix for (1), a needle for (2), and the original string for everything
|
||||
* websearch already got right.
|
||||
*
|
||||
* Safety
|
||||
* ------
|
||||
* to_tsquery parses its argument as an expression, so user text cannot be
|
||||
* pasted into it — `a:*&!b` typed in the box would otherwise be executable
|
||||
* syntax, and an unbalanced quote a 500. Every term is stripped to characters
|
||||
* that cannot mean anything to the tsquery parser and then wrapped in single
|
||||
* quotes, which is the one context where a lexeme is taken literally. The
|
||||
* result is passed as a bound parameter regardless.
|
||||
*/
|
||||
final class FreeTextCompiler
|
||||
{
|
||||
/**
|
||||
* Below this, a substring needle is both useless and expensive: a trigram
|
||||
* index cannot serve a pattern shorter than three characters, so "de"
|
||||
* would degrade to a sequential scan over every body in the account, and
|
||||
* match half of them anyway.
|
||||
*/
|
||||
private const int MIN_SUBSTRING_LENGTH = 3;
|
||||
|
||||
public function compile(string $freeText): FreeTextQuery
|
||||
{
|
||||
$freeText = trim($freeText);
|
||||
|
||||
if ('' === $freeText) {
|
||||
return new FreeTextQuery('', null, null);
|
||||
}
|
||||
|
||||
// Deliberate websearch syntax is honoured exactly as written. Widening
|
||||
// it would be the opposite of what quoting and negation are FOR.
|
||||
if (true === $this->usesWebsearchSyntax($freeText)) {
|
||||
return new FreeTextQuery($freeText, null, null);
|
||||
}
|
||||
|
||||
$terms = preg_split('/\s+/', $freeText) ?: [];
|
||||
|
||||
$lexemes = [];
|
||||
|
||||
foreach ($terms as $term) {
|
||||
$safe = $this->sanitiseLexeme($term);
|
||||
|
||||
if ('' !== $safe) {
|
||||
$lexemes[] = sprintf("'%s':*", $safe);
|
||||
}
|
||||
}
|
||||
|
||||
$prefix = [] === $lexemes ? null : implode(' & ', $lexemes);
|
||||
|
||||
// One term only. For "invoice acme" the substring pass would have to
|
||||
// OR two needles across four columns and would still not answer the
|
||||
// question the user asked (both words, same message) any better than
|
||||
// the tsquery already does.
|
||||
$substring = null;
|
||||
|
||||
if (1 === count($terms) && mb_strlen($freeText) >= self::MIN_SUBSTRING_LENGTH) {
|
||||
$substring = $freeText;
|
||||
}
|
||||
|
||||
return new FreeTextQuery($freeText, $prefix, $substring);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape the wildcards ILIKE would otherwise honour, so a search for "50%"
|
||||
* looks for "50%" and not for "50 followed by anything".
|
||||
*/
|
||||
public function likePattern(string $needle): string
|
||||
{
|
||||
return '%' . str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $needle) . '%';
|
||||
}
|
||||
|
||||
private function usesWebsearchSyntax(string $freeText): bool
|
||||
{
|
||||
if (true === str_contains($freeText, '"')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// `or` is websearch's alternation keyword, in any case.
|
||||
if (1 === preg_match('/(^|\s)or(\s|$)/i', $freeText)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// A term that starts with `-` is a negation.
|
||||
return 1 === preg_match('/(^|\s)-\S/', $freeText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only what can appear inside a quoted lexeme without ending it.
|
||||
*
|
||||
* An allowlist rather than a denylist: `&`, `|`, `!`, `(`, `)`, `:`, `*`
|
||||
* and the quote itself are all tsquery syntax, and so is whatever the next
|
||||
* Postgres release adds. Letters, digits and the punctuation that holds
|
||||
* addresses and hostnames together are what a lexeme is made of; the quote
|
||||
* cannot survive this, which is what makes the surrounding quoting safe.
|
||||
*/
|
||||
private function sanitiseLexeme(string $term): string
|
||||
{
|
||||
return preg_replace('/[^\p{L}\p{N}._@+\-\/]+/u', '', $term) ?? '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Search;
|
||||
|
||||
/**
|
||||
* The three shapes one piece of free text has to take to be findable.
|
||||
*
|
||||
* `websearch` is the string as typed, for websearch_to_tsquery — it is the only
|
||||
* one of the three that understands quotes, OR and negation, so it stays the
|
||||
* primary match and the other two are only ever ADDED to it.
|
||||
*
|
||||
* `prefix` is a to_tsquery expression with `:*` on every term, so a half-typed
|
||||
* word finds the whole one. Null when the text uses websearch syntax: ORing a
|
||||
* prefix match onto `-foo` would hand back exactly the mail the user excluded.
|
||||
*
|
||||
* `substring` is a bare needle for ILIKE, for the words full-text cannot see at
|
||||
* all — the ones inside a token the tokenizer refused to split. Null unless the
|
||||
* text is a single term of at least three characters; see SearchStrategy in the
|
||||
* repository for why that line is drawn there and what it costs.
|
||||
*/
|
||||
final readonly class FreeTextQuery
|
||||
{
|
||||
public function __construct(
|
||||
public string $websearch,
|
||||
public ?string $prefix,
|
||||
public ?string $substring,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html class="{{ appearance_class() }}"
|
||||
{# `lang` is what tells a screen reader which voice to read the page in, and
|
||||
what a browser offers to translate from. It was absent entirely, so assistive
|
||||
technology fell back to the OS language and read a German UI in an English
|
||||
voice.
|
||||
|
||||
The request locale, not the user's stored preference: they agree for a
|
||||
signed-in user, and for the login page the request locale is the only one
|
||||
there is. Underscores become hyphens because BCP 47 writes en-PI where
|
||||
Symfony writes en_PI, and an invalid tag is ignored the same as a missing
|
||||
one. #}
|
||||
<html lang="{{ app.request.locale|replace({'_': '-'}) }}"
|
||||
class="{{ appearance_class() }}"
|
||||
data-theme="{{ appearance_theme() }}"
|
||||
style="{{ appearance_vars() }}">
|
||||
<head>
|
||||
@@ -7,9 +18,16 @@
|
||||
{# Without this the browser assumes a ~980px desktop canvas and scales the
|
||||
whole app down. `viewport-fit=cover` lets the gradient run under the
|
||||
notch; the safe-area padding in app.css keeps content clear of it.
|
||||
Zoom is pinned so the app behaves like an installed PWA rather than a
|
||||
page — see the 16px form-control rule in app.css, which is what actually
|
||||
stops iOS zooming on focus (it ignores user-scalable).
|
||||
|
||||
Zoom is NOT pinned. `maximum-scale=1, user-scalable=no` used to be here
|
||||
to make the app feel installed rather than page-like; it also took away
|
||||
pinch-zoom, which WCAG 1.4.4 requires and which is the only way some
|
||||
people read anything at all. The reason it was added — iOS zooming when
|
||||
a small form control takes focus — is already handled by the 16px
|
||||
form-control rule in app.css, which is what actually prevents that; iOS
|
||||
ignores user-scalable anyway, so this only ever disabled zoom on Android
|
||||
where nothing was wrong.
|
||||
|
||||
`interactive-widget=resizes-content`: Android's default (resizes-visual)
|
||||
shrinks only the visual viewport for the keyboard and leaves the layout
|
||||
viewport — where fixed elements live — at full height, so the page
|
||||
@@ -18,7 +36,7 @@
|
||||
the keyboard and there is nothing to pan. iOS ignores this keyword; the
|
||||
compose controller's visualViewport tracking stays as its fallback. #}
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content">
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
|
||||
<title>{% block title %}Welcome!{% endblock %}</title>
|
||||
{% include '_partials/_favicon.html.twig' %}
|
||||
{# PWA: iOS only offers Web Push to apps added to the Home Screen, and it
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
{#
|
||||
The fallback error page, and the body every specific one extends.
|
||||
|
||||
Symfony picks errorNNN.html.twig if it exists and falls back to this, so a
|
||||
status without its own template still lands somewhere branded rather than on
|
||||
the stock white "Oops! An Error Occurred".
|
||||
|
||||
Standalone rather than `extends '_layout/app.html.twig'`, which was the
|
||||
first attempt and was wrong: the app shell emits `csrf_token('ajax')`, which
|
||||
reaches for the session, and there is no session on an anonymous request.
|
||||
An error template that throws while rendering leaves the visitor on Symfony's
|
||||
bare fallback — so a 404 for a logged-out visitor would have been exactly the
|
||||
case where the branded page did not appear. An error page must not need
|
||||
anything that might itself be what broke: no session, no user, no database,
|
||||
no Mercure, no service worker.
|
||||
|
||||
What it keeps is the appearance: the theme functions read a cookie-backed
|
||||
default and work without a user, so the page is still the user's colours
|
||||
rather than a white sheet.
|
||||
#}
|
||||
{# Even `app.request` is guarded. An error template can be rendered with no
|
||||
request in scope at all, and a null-property access here would throw inside
|
||||
the handler for the thing that already threw. #}
|
||||
{% set error_locale = app.request is not null ? app.request.locale : 'en' %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ error_locale|replace({'_': '-'}) }}"
|
||||
class="{{ appearance_class() }}"
|
||||
data-theme="{{ appearance_theme() }}"
|
||||
style="{{ appearance_vars() }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>{{ error_title|default('errors.page.server.title'|trans) }} — {{ 'app.name'|trans }}</title>
|
||||
{% include '_partials/_favicon.html.twig' %}
|
||||
{{ importmap('app') }}
|
||||
</head>
|
||||
<body class="app-bg text-ink">
|
||||
<main class="min-h-screen flex items-center justify-center px-4">
|
||||
<div class="w-full max-w-md text-center">
|
||||
|
||||
<div class="flex items-center justify-center gap-2 mb-8">
|
||||
<i class="fa-solid fa-envelope text-accent text-2xl" aria-hidden="true"></i>
|
||||
<span class="text-xl font-semibold text-ink">{{ 'app.name'|trans }}</span>
|
||||
</div>
|
||||
|
||||
{# The status number is a numeral in every locale this ships in, so
|
||||
it is not translated — but it is decorative beside the heading
|
||||
that actually says what happened, and a screen reader is better
|
||||
served by that heading than by hearing "four hundred and four". #}
|
||||
<p class="text-6xl font-semibold text-ink-faint tabular-nums mb-4" aria-hidden="true">
|
||||
{{- error_code|default(status_code|default(500)) -}}
|
||||
</p>
|
||||
|
||||
<h1 class="text-lg font-semibold text-ink mb-2">
|
||||
{{- error_heading|default('errors.page.server.heading'|trans) -}}
|
||||
</h1>
|
||||
<p class="text-sm text-ink-muted mb-8">
|
||||
{{- error_body|default('errors.page.server.body'|trans) -}}
|
||||
</p>
|
||||
|
||||
<a href="{{ path('app_mail_inbox') }}"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-accent text-accent-ink text-sm font-medium hover:bg-accent-strong transition-colors">
|
||||
<i class="fa-solid fa-arrow-left" aria-hidden="true"></i>
|
||||
{{ 'errors.page.back'|trans }}
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
{% extends 'bundles/TwigBundle/Exception/error.html.twig' %}
|
||||
|
||||
{% set error_code = 404 %}
|
||||
{% set error_title = 'errors.page.not_found.title'|trans %}
|
||||
{% set error_heading = 'errors.page.not_found.heading'|trans %}
|
||||
{% set error_body = 'errors.page.not_found.body'|trans %}
|
||||
@@ -0,0 +1,13 @@
|
||||
{#
|
||||
The 500 page links to the inbox like the 404 does, which is worth a word:
|
||||
if what broke is the inbox, that link goes straight back into the fault. It
|
||||
stays because the alternative — a dead end — leaves someone whose session is
|
||||
fine after a one-off failure with nowhere to click, and a second attempt is
|
||||
the thing that usually works.
|
||||
#}
|
||||
{% extends 'bundles/TwigBundle/Exception/error.html.twig' %}
|
||||
|
||||
{% set error_code = 500 %}
|
||||
{% set error_title = 'errors.page.server.title'|trans %}
|
||||
{% set error_heading = 'errors.page.server.heading'|trans %}
|
||||
{% set error_body = 'errors.page.server.body'|trans %}
|
||||
@@ -4,7 +4,7 @@ mail/original.html.twig
|
||||
Standalone "show original" page (opened in a new tab). Deliberately plain and
|
||||
independent of the app layout; the source is printed escaped, never |raw.
|
||||
#}<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="{{ app.request.locale|replace({'_': '-'}) }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
@@ -4,7 +4,7 @@ mail/print.html.twig
|
||||
Standalone printable view of a single message (opened in a new tab, fires the
|
||||
print dialog on load). Only the sanitized body is rendered as markup.
|
||||
#}<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="{{ app.request.locale|replace({'_': '-'}) }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
{% extends '_layout/_mailbox.html.twig' %}
|
||||
|
||||
{#
|
||||
The query goes through a translated pattern rather than being concatenated
|
||||
ahead of the word "Search": the order of the two is a property of the
|
||||
language, not of the page.
|
||||
#}
|
||||
{% block title %}
|
||||
{% if q %}{{ q }} — {{ 'search.title'|trans }}{% else %}{{ 'search.title'|trans }}{% endif %} — {{ 'app.name'|trans }}
|
||||
{%- if q %}{{ 'search.title_query'|trans({ '%query%': q }) }}{% else %}{{ 'search.title'|trans }}{% endif %} — {{ 'app.name'|trans -}}
|
||||
{% endblock %}
|
||||
|
||||
{% block inbox_tabs %}
|
||||
|
||||
@@ -28,7 +28,13 @@
|
||||
<span class="w-2 h-2 rounded-full" style="background:{{ swatch[1] }}"></span>
|
||||
<span class="w-2 h-2 rounded-full" style="background:{{ swatch[2] }}"></span>
|
||||
</span>
|
||||
<span class="text-[11px] text-ink-muted">{{ ('settings.appearance.theme.' ~ theme.value)|trans }}</span>
|
||||
{# The theme's name is the only thing that says which tile
|
||||
is which, so it is primary content, not a caption: it
|
||||
gets the soft ink (7.5:1 or better in every theme) at
|
||||
12px rather than muted ink at 11px. It was the lowest
|
||||
contrast text on the page and it is the text a person
|
||||
reads most carefully here. #}
|
||||
<span class="text-xs text-ink-soft">{{ ('settings.appearance.theme.' ~ theme.value)|trans }}</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,7 @@ Variables:
|
||||
appearance PublicAppearance the owner's theme, or Paper by default
|
||||
#}
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ app.request.locale }}"
|
||||
<html lang="{{ app.request.locale|replace({'_': '-'}) }}"
|
||||
class="{{ appearance is defined ? appearance.htmlClass : '' }}"
|
||||
data-theme="{{ appearance is defined ? appearance.theme : 'paper' }}"
|
||||
style="{{ appearance is defined ? appearance.cssVariables : '' }}">
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller;
|
||||
|
||||
use App\Repository\User\UserRepository;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
/**
|
||||
* The URLs a person can produce by editing the address bar, and what they
|
||||
* should answer.
|
||||
*
|
||||
* All of these were 500s or nonsense pages. They share a cause worth naming:
|
||||
* every one of them is a value that arrived as text in a URL and was handed
|
||||
* straight to something that assumed it was already valid — an enum, an entity
|
||||
* id, an offset. A URL is user input with no form around it and no validator
|
||||
* attached, which is exactly why it is the input that gets forgotten.
|
||||
*/
|
||||
final class ErrorAndPaginationTest extends WebTestCase
|
||||
{
|
||||
private const string ADMIN_EMAIL = 'e2e-admin@plmail.test';
|
||||
|
||||
/**
|
||||
* A non-numeric id must not reach the entity resolver.
|
||||
*
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function nonNumericIds(): iterable
|
||||
{
|
||||
yield 'thread' => ['/mail/thread/abc'];
|
||||
yield 'message' => ['/mail/message/abc'];
|
||||
yield 'label' => ['/mail/label/abc'];
|
||||
yield 'account' => ['/mail/account/abc'];
|
||||
yield 'attachment' => ['/mail/attachment/abc'];
|
||||
}
|
||||
|
||||
#[DataProvider('nonNumericIds')]
|
||||
public function testNonNumericIdIsNotFoundRatherThanServerError(string $path): void
|
||||
{
|
||||
$client = $this->loggedInClient();
|
||||
|
||||
$client->request('GET', $path);
|
||||
|
||||
self::assertSame(404, $client->getResponse()->getStatusCode(), $path);
|
||||
}
|
||||
|
||||
/**
|
||||
* An unknown inbox tab is a mistyped or stale link, not a fault. It used to
|
||||
* be MessageCategory::from() and therefore a ValueError and therefore a 500.
|
||||
*
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function unknownTabs(): iterable
|
||||
{
|
||||
yield 'nonsense' => ['/mail/inbox?tab=quatsch'];
|
||||
yield 'empty' => ['/mail/inbox?tab='];
|
||||
yield 'numeric' => ['/mail/inbox?tab=7'];
|
||||
}
|
||||
|
||||
#[DataProvider('unknownTabs')]
|
||||
public function testUnknownInboxTabFallsBackToPrimary(string $path): void
|
||||
{
|
||||
$client = $this->loggedInClient();
|
||||
|
||||
$client->request('GET', $path);
|
||||
|
||||
self::assertSame(200, $client->getResponse()->getStatusCode(), $path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Past the last page, the list redirects back onto a page that exists.
|
||||
*
|
||||
* The count is whatever the seeded database holds, which is why this asserts
|
||||
* on the redirect rather than on a row count: the invariant is that the URL
|
||||
* stops claiming a page number the list cannot serve, at any list size.
|
||||
*
|
||||
* @return iterable<string, array{string, string}>
|
||||
*/
|
||||
public static function pagesPastTheEnd(): iterable
|
||||
{
|
||||
yield 'inbox' => ['/mail/inbox?page=999', '/mail/inbox'];
|
||||
yield 'trash' => ['/mail/trash?page=999', '/mail/trash'];
|
||||
yield 'sent' => ['/mail/sent?page=5', '/mail/sent'];
|
||||
yield 'drafts' => ['/mail/drafts?page=5', '/mail/drafts'];
|
||||
}
|
||||
|
||||
#[DataProvider('pagesPastTheEnd')]
|
||||
public function testPagePastTheEndRedirectsOntoTheList(string $path, string $expectedPrefix): void
|
||||
{
|
||||
$client = $this->loggedInClient();
|
||||
|
||||
$client->request('GET', $path);
|
||||
$response = $client->getResponse();
|
||||
|
||||
self::assertTrue($response->isRedirect(), $path . ' should redirect');
|
||||
self::assertStringStartsWith(
|
||||
$expectedPrefix,
|
||||
(string) $response->headers->get('Location'),
|
||||
$path,
|
||||
);
|
||||
|
||||
// And the page it lands on renders.
|
||||
$client->followRedirect();
|
||||
self::assertSame(200, $client->getResponse()->getStatusCode(), $path);
|
||||
}
|
||||
|
||||
/**
|
||||
* The lower bound was always clamped and must stay clamped — `?page=0` and
|
||||
* `?page=-3` render page one rather than computing a negative offset.
|
||||
*/
|
||||
public function testPageBelowOneStillRendersTheFirstPage(): void
|
||||
{
|
||||
$client = $this->loggedInClient();
|
||||
|
||||
foreach (['/mail/inbox?page=0', '/mail/inbox?page=-3', '/mail/inbox?page=nonsense'] as $path) {
|
||||
$client->request('GET', $path);
|
||||
self::assertSame(200, $client->getResponse()->getStatusCode(), $path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A page that already exists is left alone. Without this the redirect could
|
||||
* be unconditional and every test above would still pass.
|
||||
*/
|
||||
public function testAnExistingPageIsNotRedirected(): void
|
||||
{
|
||||
$client = $this->loggedInClient();
|
||||
|
||||
$client->request('GET', '/mail/inbox');
|
||||
|
||||
self::assertSame(200, $client->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* The error templates render, and render translated.
|
||||
*
|
||||
* Rendered directly rather than by provoking a real error: with debug on —
|
||||
* which is how the test kernel runs — Symfony shows the exception page and
|
||||
* these templates never execute, so the only way to catch a typo in one is
|
||||
* to ask Twig for it.
|
||||
*
|
||||
* @return iterable<string, array{string, string}>
|
||||
*/
|
||||
public static function errorTemplates(): iterable
|
||||
{
|
||||
yield '404' => ['bundles/TwigBundle/Exception/error404.html.twig', 'errors.page.not_found.heading'];
|
||||
yield '500' => ['bundles/TwigBundle/Exception/error500.html.twig', 'errors.page.server.heading'];
|
||||
yield 'fallback' => ['bundles/TwigBundle/Exception/error.html.twig', 'errors.page.server.heading'];
|
||||
}
|
||||
|
||||
#[DataProvider('errorTemplates')]
|
||||
public function testErrorTemplateRendersBrandedAndTranslated(string $template, string $key): void
|
||||
{
|
||||
self::bootKernel();
|
||||
|
||||
$container = static::getContainer();
|
||||
$translator = $container->get('translator');
|
||||
$html = $container->get('twig')->render($template);
|
||||
|
||||
$heading = $translator->trans($key);
|
||||
|
||||
self::assertStringContainsString($heading, $html, 'the localised heading is missing');
|
||||
self::assertNotSame($key, $heading, 'the translation key has no entry');
|
||||
|
||||
// Branded, not the stock Symfony page: it carries the app shell and a
|
||||
// way back, and no raw translation key leaked into the markup.
|
||||
self::assertStringContainsString('/mail/inbox', $html);
|
||||
self::assertStringNotContainsString('errors.page.', $html);
|
||||
}
|
||||
|
||||
private function loggedInClient(): \Symfony\Bundle\FrameworkBundle\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;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ use App\Service\Search\SearchQueryParser;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
@@ -121,6 +122,88 @@ final class MessageSearchTest extends KernelTestCase
|
||||
self::assertSame(['Notes'], $this->search('meetings'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The table from the bug report, verbatim.
|
||||
*
|
||||
* One mail, five ways of reaching for it, four of which used to come back
|
||||
* empty. Kept as one test over one fixture because that is the shape of the
|
||||
* complaint: the SAME mail, visible on screen, unfindable by every spelling
|
||||
* of what is visibly in it except the one nobody would type.
|
||||
*
|
||||
* The two failures have different causes and different fixes, which is why
|
||||
* both a prefix pass and a substring pass exist:
|
||||
*
|
||||
* "Testmai" a prefix of a lexeme. `:*` reaches it.
|
||||
* "wirhub" NOT a prefix of anything — the tokenizer emits
|
||||
* "help.wirhub.de" as one host lexeme, so it is a substring
|
||||
* of a lexeme, and only ILIKE reaches it.
|
||||
*/
|
||||
#[DataProvider('reportedSearches')]
|
||||
public function testTheReportedSearchesAllFindTheMail(string $query): void
|
||||
{
|
||||
$this->seedMessage(
|
||||
subject: 'Testmail Betreff',
|
||||
body: 'Bitte besuche help.wirhub.de fuer mehr Infos',
|
||||
);
|
||||
$this->seedMessage(subject: 'Unrelated', body: 'Nothing in particular.');
|
||||
|
||||
self::assertSame(['Testmail Betreff'], $this->search($query));
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{string}> */
|
||||
public static function reportedSearches(): iterable
|
||||
{
|
||||
yield 'whole word, which already worked' => ['Testmail'];
|
||||
yield 'prefix of a subject word' => ['Testmai'];
|
||||
yield 'inside a host token' => ['wirhub'];
|
||||
yield 'a suffix of a host token' => ['wirhub.de'];
|
||||
yield 'the host token entire' => ['help.wirhub.de'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Widening free text must not widen a query that was narrowed on purpose.
|
||||
*
|
||||
* Negation is the one that matters: OR-ing a prefix match onto `-invoice`
|
||||
* hands back precisely the mail the user asked not to see, which is worse
|
||||
* than the bug being fixed here. So websearch syntax turns the extra passes
|
||||
* off rather than fighting them.
|
||||
*/
|
||||
public function testDeliberateWebsearchSyntaxIsNotWidened(): void
|
||||
{
|
||||
$this->seedMessage(subject: 'Invoice reminder', body: 'Payment for the invoice.');
|
||||
$this->seedMessage(subject: 'Payment plan', body: 'A plan for payment.');
|
||||
|
||||
self::assertSame(['Payment plan'], $this->search('payment -invoice'));
|
||||
self::assertSame(['Payment plan'], $this->search('"payment plan"'));
|
||||
self::assertCount(2, $this->search('invoice OR plan'));
|
||||
}
|
||||
|
||||
/**
|
||||
* tsquery syntax typed into the box is text, not syntax. Before the prefix
|
||||
* pass there was nothing to escape — websearch_to_tsquery is total over its
|
||||
* input — so this is new surface and worth pinning: any of these reaching
|
||||
* to_tsquery unescaped is a 500, and `!` reaching it is a search that
|
||||
* returns the complement of what was asked for.
|
||||
*/
|
||||
#[DataProvider('hostileQueries')]
|
||||
public function testTsqueryPunctuationIsTextNotSyntax(string $query): void
|
||||
{
|
||||
$this->seedMessage(subject: 'Invoice reminder', body: 'Payment for the invoice.');
|
||||
|
||||
self::assertIsArray($this->search($query));
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{string}> */
|
||||
public static function hostileQueries(): iterable
|
||||
{
|
||||
yield 'bare colon star' => ['invoice:*'];
|
||||
yield 'unbalanced quote' => ["invoice'"];
|
||||
yield 'boolean operators' => ['invoice & payment'];
|
||||
yield 'negation operator' => ['!invoice'];
|
||||
yield 'unbalanced paren' => ['(invoice'];
|
||||
yield 'nothing but syntax' => ['&|!():*'];
|
||||
}
|
||||
|
||||
public function testLabelMatchesAUserLabelByName(): void
|
||||
{
|
||||
$receipts = $this->seedLabel('Receipts');
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\Appearance;
|
||||
|
||||
use App\Domain\Enum\Theme\Theme;
|
||||
use App\Entity\Embeddable\Appearance;
|
||||
use App\Service\Appearance\AppearanceRenderer;
|
||||
use App\Service\Appearance\BackgroundResolver;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Asset\Packages;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
/**
|
||||
* Whatever accent a user picks, the text on it is readable.
|
||||
*
|
||||
* The accent is chosen from a colour wheel, so this cannot be checked by
|
||||
* inspecting a palette — the palette is whatever someone liked. It has to be a
|
||||
* property of the rule, which is why the sweep below exists: it walks the
|
||||
* accent space rather than a list of colours somebody thought of.
|
||||
*
|
||||
* The reported failure was white on a pink accent at 2.34:1. The cause was an
|
||||
* ink chosen by a threshold over raw, un-expanded channel values, which is not
|
||||
* luminance and crossed over in the wrong place.
|
||||
*/
|
||||
final class AccentInkContrastTest extends TestCase
|
||||
{
|
||||
private const float AA_BODY_TEXT = 4.5;
|
||||
|
||||
/**
|
||||
* Every accent in a coarse sweep of the RGB cube gets a readable ink.
|
||||
*
|
||||
* 6^3 = 216 colours at even spacing. Not exhaustive, but the failure mode
|
||||
* being guarded is a whole REGION of the space getting the wrong ink — the
|
||||
* old rule was wrong for most of the mid-tones — and a region that size
|
||||
* cannot hide between these samples.
|
||||
*/
|
||||
public function testEveryAccentInTheCubeGetsReadableInk(): void
|
||||
{
|
||||
$worst = 21.0;
|
||||
$worstHex = '';
|
||||
$steps = [0, 51, 102, 153, 204, 255];
|
||||
|
||||
foreach ($steps as $r) {
|
||||
foreach ($steps as $g) {
|
||||
foreach ($steps as $b) {
|
||||
$hex = sprintf('#%02X%02X%02X', $r, $g, $b);
|
||||
$ratio = $this->contrastForAccent($hex);
|
||||
|
||||
if ($ratio < $worst) {
|
||||
$worst = $ratio;
|
||||
$worstHex = $hex;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self::assertGreaterThanOrEqual(
|
||||
self::AA_BODY_TEXT,
|
||||
$worst,
|
||||
sprintf('worst accent was %s at %.2f:1', $worstHex, $worst),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The colours from the report and its neighbourhood, named so a regression
|
||||
* says which one broke.
|
||||
*
|
||||
* @return iterable<string, array{string}>
|
||||
*/
|
||||
public static function troublesomeAccents(): iterable
|
||||
{
|
||||
yield 'the reported pink' => ['#F472B6'];
|
||||
yield 'a stronger pink' => ['#EC4899'];
|
||||
yield 'mid grey' => ['#767676'];
|
||||
// Straddles the old 0.6 threshold, where the previous rule flipped.
|
||||
yield 'mid teal' => ['#3AA6A6'];
|
||||
yield 'amber' => ['#F59E0B'];
|
||||
yield 'lime' => ['#84CC16'];
|
||||
yield 'deep indigo' => ['#312E81'];
|
||||
}
|
||||
|
||||
#[DataProvider('troublesomeAccents')]
|
||||
public function testNamedAccentsMeetAA(string $hex): void
|
||||
{
|
||||
self::assertGreaterThanOrEqual(
|
||||
self::AA_BODY_TEXT,
|
||||
$this->contrastForAccent($hex),
|
||||
$hex,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A light accent must get dark ink and a dark accent light ink. Contrast
|
||||
* alone would be satisfied by always picking the further-away ink, so this
|
||||
* pins the direction too — and it is the direction that was wrong.
|
||||
*/
|
||||
public function testLightAccentsGetDarkInkAndDarkAccentsGetLightInk(): void
|
||||
{
|
||||
self::assertSame([24, 24, 27], $this->inkFor('#FDE68A'), 'pale amber needs dark ink');
|
||||
self::assertSame([24, 24, 27], $this->inkFor('#F472B6'), 'the reported pink needs dark ink');
|
||||
self::assertSame([255, 255, 255], $this->inkFor('#312E81'), 'deep indigo needs light ink');
|
||||
self::assertSame([255, 255, 255], $this->inkFor('#000000'), 'black needs light ink');
|
||||
}
|
||||
|
||||
/**
|
||||
* Every stock theme's own accent is readable too — the themes seed the
|
||||
* user's accent setting from their swatch, so a theme ships whatever this
|
||||
* rule then has to cope with.
|
||||
*/
|
||||
public function testEveryStockThemeAccentIsReadable(): void
|
||||
{
|
||||
foreach (Theme::cases() as $theme) {
|
||||
$accent = $theme->defaults()['accent'];
|
||||
|
||||
self::assertGreaterThanOrEqual(
|
||||
self::AA_BODY_TEXT,
|
||||
$this->contrastForAccent($accent),
|
||||
$theme->value . ' (' . $accent . ')',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{0:int,1:int,2:int} */
|
||||
private function inkFor(string $hex): array
|
||||
{
|
||||
$appearance = new Appearance();
|
||||
$appearance->accent = $hex;
|
||||
|
||||
// The background resolver is irrelevant here — the default appearance
|
||||
// uses the theme background, for which it returns null without touching
|
||||
// either collaborator — but the renderer's constructor still wants one.
|
||||
$renderer = new AppearanceRenderer(new BackgroundResolver(
|
||||
$this->createStub(Packages::class),
|
||||
$this->createStub(UrlGeneratorInterface::class),
|
||||
));
|
||||
|
||||
$vars = $renderer->cssVariables($appearance);
|
||||
|
||||
preg_match('/--rgb-accent-ink:(\d+) (\d+) (\d+)/', $vars, $m);
|
||||
|
||||
self::assertNotEmpty($m, 'no --rgb-accent-ink was written for ' . $hex);
|
||||
|
||||
return [(int) $m[1], (int) $m[2], (int) $m[3]];
|
||||
}
|
||||
|
||||
private function contrastForAccent(string $hex): float
|
||||
{
|
||||
return $this->ratio($this->luminance($this->channels($hex)), $this->luminance($this->inkFor($hex)));
|
||||
}
|
||||
|
||||
/** @return array{0:int,1:int,2:int} */
|
||||
private function channels(string $hex): array
|
||||
{
|
||||
$hex = ltrim($hex, '#');
|
||||
|
||||
return [
|
||||
(int) hexdec(substr($hex, 0, 2)),
|
||||
(int) hexdec(substr($hex, 2, 2)),
|
||||
(int) hexdec(substr($hex, 4, 2)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Written out here rather than shared with the renderer on purpose: a test
|
||||
* that reuses the implementation's own maths agrees with it by
|
||||
* construction, including when both are wrong.
|
||||
*
|
||||
* @param array{0:int,1:int,2:int} $rgb
|
||||
*/
|
||||
private function luminance(array $rgb): float
|
||||
{
|
||||
$expand = static function (int $c): float {
|
||||
$s = $c / 255;
|
||||
|
||||
return $s <= 0.04045 ? $s / 12.92 : (($s + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
|
||||
return 0.2126 * $expand($rgb[0]) + 0.7152 * $expand($rgb[1]) + 0.0722 * $expand($rgb[2]);
|
||||
}
|
||||
|
||||
private function ratio(float $a, float $b): float
|
||||
{
|
||||
return (max($a, $b) + 0.05) / (min($a, $b) + 0.05);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\Appearance;
|
||||
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Every tier of every theme's ink ramp is readable on its own surface, and the
|
||||
* tiers stay in order.
|
||||
*
|
||||
* Two separate promises, both of which had been broken:
|
||||
*
|
||||
* Readable. --rgb-ink-faint was 2.56:1 on white — it is the colour the 11px
|
||||
* labels are drawn in, and it was little more than half of what WCAG AA asks
|
||||
* for body text. Every theme failed this at the faint tier and several at the
|
||||
* muted one.
|
||||
*
|
||||
* In order. Fixing the first can break the second, and did: darkening solar's
|
||||
* faint and muted tiers pushed them PAST its own ink and ink-soft, so the
|
||||
* ramp ran backwards and "less important" text came out stronger than the
|
||||
* text it was subordinate to. Contrast alone is not the property wanted; a
|
||||
* legible ordered ramp is.
|
||||
*
|
||||
* Read out of the stylesheet rather than from a table kept beside it, for the
|
||||
* reason ThemeVariableCompletenessTest gives: a table beside it goes stale.
|
||||
*/
|
||||
final class ThemeInkContrastTest extends TestCase
|
||||
{
|
||||
/** WCAG 2.x AA for body text, which is what these tiers draw. */
|
||||
private const float AA_BODY_TEXT = 4.5;
|
||||
|
||||
/**
|
||||
* Each ramp: the four ink tiers and the surface they sit on.
|
||||
*
|
||||
* @var array<string, array{list<string>, string}>
|
||||
*/
|
||||
private const array RAMPS = [
|
||||
'page' => [
|
||||
['--rgb-ink', '--rgb-ink-soft', '--rgb-ink-muted', '--rgb-ink-faint'],
|
||||
'--rgb-surface',
|
||||
],
|
||||
'reading sheet' => [
|
||||
['--rgb-sheet-ink', '--rgb-sheet-ink-soft', '--rgb-sheet-ink-muted', '--rgb-sheet-ink-faint'],
|
||||
'--rgb-sheet',
|
||||
],
|
||||
];
|
||||
|
||||
/** @return iterable<string, array{string, string}> */
|
||||
public static function ramps(): iterable
|
||||
{
|
||||
foreach (array_keys(self::palettes()) as $selector) {
|
||||
foreach (array_keys(self::RAMPS) as $ramp) {
|
||||
yield $selector . ' / ' . $ramp => [$selector, $ramp];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[DataProvider('ramps')]
|
||||
public function testEveryInkTierMeetsAAOnItsSurface(string $selector, string $ramp): void
|
||||
{
|
||||
[$tiers, $surfaceVar] = self::RAMPS[$ramp];
|
||||
|
||||
$palette = self::palettes()[$selector];
|
||||
$surface = $palette[$surfaceVar];
|
||||
|
||||
foreach ($tiers as $tier) {
|
||||
$ratio = self::ratio($palette[$tier], $surface);
|
||||
|
||||
self::assertGreaterThanOrEqual(
|
||||
self::AA_BODY_TEXT,
|
||||
$ratio,
|
||||
sprintf('%s %s on %s is %.2f:1', $selector, $tier, $surfaceVar, $ratio),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[DataProvider('ramps')]
|
||||
public function testTheRampRunsFromStrongestToFaintest(string $selector, string $ramp): void
|
||||
{
|
||||
[$tiers, $surfaceVar] = self::RAMPS[$ramp];
|
||||
|
||||
$palette = self::palettes()[$selector];
|
||||
$surface = $palette[$surfaceVar];
|
||||
|
||||
$ratios = array_map(
|
||||
static fn (string $tier): float => self::ratio($palette[$tier], $surface),
|
||||
$tiers,
|
||||
);
|
||||
|
||||
for ($i = 1; $i < count($ratios); $i++) {
|
||||
self::assertLessThan(
|
||||
$ratios[$i - 1],
|
||||
$ratios[$i],
|
||||
sprintf(
|
||||
'%s %s: %s (%.2f:1) is not fainter than %s (%.2f:1)',
|
||||
$selector,
|
||||
$ramp,
|
||||
$tiers[$i],
|
||||
$ratios[$i],
|
||||
$tiers[$i - 1],
|
||||
$ratios[$i - 1],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel triples of every palette block that declares a surface.
|
||||
*
|
||||
* @return array<string, array<string, array{0:int,1:int,2:int}>>
|
||||
*/
|
||||
private static function palettes(): array
|
||||
{
|
||||
static $cache = null;
|
||||
|
||||
if (null !== $cache) {
|
||||
return $cache;
|
||||
}
|
||||
|
||||
$css = (string) preg_replace('#/\*.*?\*/#s', '', self::stylesheet());
|
||||
|
||||
preg_match_all(
|
||||
'/(:root|\.dark|\[data-theme="[a-z]+"\])\s*\{([^}]*)\}/',
|
||||
$css,
|
||||
$matches,
|
||||
PREG_SET_ORDER,
|
||||
);
|
||||
|
||||
$palettes = [];
|
||||
|
||||
foreach ($matches as $match) {
|
||||
preg_match_all(
|
||||
'/(--rgb-[a-z-]+):\s*(\d+)\s+(\d+)\s+(\d+)\s*;/',
|
||||
$match[2],
|
||||
$vars,
|
||||
PREG_SET_ORDER,
|
||||
);
|
||||
|
||||
$palette = [];
|
||||
|
||||
foreach ($vars as $var) {
|
||||
$palette[$var[1]] = [(int) $var[2], (int) $var[3], (int) $var[4]];
|
||||
}
|
||||
|
||||
// Blocks that carry no surface are not palettes — utilities and
|
||||
// the like. Only a block that paints a background can be measured
|
||||
// against one.
|
||||
if (isset($palette['--rgb-surface'])) {
|
||||
$palettes[$match[1]] = $palette;
|
||||
}
|
||||
}
|
||||
|
||||
self::assertNotEmpty($palettes, 'no palette blocks found in app.css');
|
||||
|
||||
return $cache = $palettes;
|
||||
}
|
||||
|
||||
private static function stylesheet(): string
|
||||
{
|
||||
$path = dirname(__DIR__, 3) . '/assets/styles/app.css';
|
||||
|
||||
self::assertFileExists($path);
|
||||
|
||||
return (string) file_get_contents($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{0:int,1:int,2:int} $a
|
||||
* @param array{0:int,1:int,2:int} $b
|
||||
*/
|
||||
private static function ratio(array $a, array $b): float
|
||||
{
|
||||
$la = self::luminance($a);
|
||||
$lb = self::luminance($b);
|
||||
|
||||
return (max($la, $lb) + 0.05) / (min($la, $lb) + 0.05);
|
||||
}
|
||||
|
||||
/** @param array{0:int,1:int,2:int} $rgb */
|
||||
private static function luminance(array $rgb): float
|
||||
{
|
||||
$expand = static function (int $channel): float {
|
||||
$c = $channel / 255;
|
||||
|
||||
return $c <= 0.04045 ? $c / 12.92 : (($c + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
|
||||
return 0.2126 * $expand($rgb[0]) + 0.7152 * $expand($rgb[1]) + 0.0722 * $expand($rgb[2]);
|
||||
}
|
||||
}
|
||||
@@ -1091,6 +1091,16 @@ drafts:
|
||||
errors:
|
||||
access_denied: Du hast keinen Zugriff auf diese Ressource.
|
||||
not_found: Die angeforderte Ressource wurde nicht gefunden.
|
||||
page:
|
||||
back: Zurück zu deiner Mail
|
||||
not_found:
|
||||
body: Die aufgerufene Seite gibt es hier nicht. Vielleicht ist der Link vertippt, vielleicht wurde das Ziel verschoben oder gelöscht.
|
||||
heading: Unter dieser Adresse ist nichts
|
||||
title: Seite nicht gefunden
|
||||
server:
|
||||
body: Bei uns ist etwas schiefgelaufen, nicht bei dir. Der Fehler wurde protokolliert; ein neuer Versuch gleich hilft meistens.
|
||||
heading: Da ist etwas schiefgelaufen
|
||||
title: Serverfehler
|
||||
|
||||
form:
|
||||
file:
|
||||
@@ -1422,6 +1432,8 @@ search:
|
||||
label: Sortieren nach
|
||||
recent: Neueste zuerst
|
||||
relevance: Relevanteste zuerst
|
||||
title: Suche
|
||||
title_query: 'Suche: %query%'
|
||||
|
||||
sent:
|
||||
empty: Keine gesendeten Nachrichten.
|
||||
|
||||
@@ -1064,6 +1064,16 @@ drafts:
|
||||
errors:
|
||||
access_denied: You do not have access to this resource.
|
||||
not_found: The requested resource could not be found.
|
||||
page:
|
||||
back: Back to your mail
|
||||
not_found:
|
||||
body: The page you asked for is not here. The link may be mistyped, or what it pointed at may have been moved or deleted.
|
||||
heading: Nothing at this address
|
||||
title: Page not found
|
||||
server:
|
||||
body: Something went wrong at our end, not yours. The fault has been logged; trying again in a moment usually works.
|
||||
heading: Something went wrong
|
||||
title: Server error
|
||||
|
||||
form:
|
||||
file:
|
||||
@@ -1392,6 +1402,8 @@ search:
|
||||
label: Sort by
|
||||
recent: Most recent
|
||||
relevance: Most relevant
|
||||
title: Search
|
||||
title_query: 'Search: %query%'
|
||||
|
||||
sent:
|
||||
empty: No sent messages.
|
||||
|
||||
@@ -809,6 +809,16 @@ drafts:
|
||||
errors:
|
||||
access_denied: Ye have no leave to enter here.
|
||||
not_found: That be nowhere on the charts.
|
||||
page:
|
||||
back: Back to yer mail
|
||||
not_found:
|
||||
body: The page ye hailed be not here. Happen the link be mistyped, or what it pointed at were moved or scuttled.
|
||||
heading: Naught at these coordinates
|
||||
title: Page be lost at sea
|
||||
server:
|
||||
body: Somethin' went wrong on our deck, not yours. The fault be in the log; another go in a moment most often sails.
|
||||
heading: Somethin' went wrong
|
||||
title: The ship be takin' water
|
||||
|
||||
form:
|
||||
file:
|
||||
@@ -1128,6 +1138,8 @@ search:
|
||||
label: Sort by
|
||||
recent: Freshest catch
|
||||
relevance: Closest to the mark
|
||||
title: Scourin'
|
||||
title_query: 'Scourin'' for: %query%'
|
||||
|
||||
sent:
|
||||
empty: No bottles cast off.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
# Authenticated GET against the live plm_d stack. $1 = path, rest passed to curl.
|
||||
J=/tmp/claude-1000/-home-karatektus-plmailstuff/b28e1914-8509-423e-8a57-1bb9498e8e5c/scratchpad/c2.txt
|
||||
P="$1"; shift
|
||||
exec curl -s -b "$J" -c "$J" -H 'Sec-Fetch-Site: same-origin' "$@" "http://127.0.0.1:8013$P"
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
D=/home/karatektus/plmailstuff/pl_mail/.claude/worktrees/agent-aeee376464efa3888/worktree-agent-aeee376464efa3888
|
||||
for p in "$@"; do
|
||||
printf '%s -> ' "$p"
|
||||
"$D/get.sh" "$p" -o /dev/null -w '%{http_code}\n'
|
||||
done
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
# One-off container on the running test stack's network, worktree bind-mounted.
|
||||
cd /home/karatektus/plmailstuff/pl_mail/.claude/worktrees/agent-aeee376464efa3888
|
||||
exec docker run --rm --network pl_mail_test_default \
|
||||
-v "$PWD":/app -w /app \
|
||||
-e APP_ENV=test -e APP_DEBUG=1 \
|
||||
-e APP_SECRET=282065e0f71bf96c0dbcf0496c1f5d3e \
|
||||
-e APP_ENCRYPTION_KEY=emL6R8zs0pSXCVV2HvVvbFubriWJIla7kgnqSGqCJ8E= \
|
||||
-e "DATABASE_URL=postgresql://app:app@database:5432/app?serverVersion=18&charset=utf8" \
|
||||
-e MESSENGER_TRANSPORT_DSN=in-memory:// \
|
||||
-e MAILER_DSN=null://null \
|
||||
--entrypoint docker-php-entrypoint pl_mail_test-app "$@"
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
# Count result rows the search page returns for each query given.
|
||||
D=/home/karatektus/plmailstuff/pl_mail/.claude/worktrees/agent-aeee376464efa3888/worktree-agent-aeee376464efa3888
|
||||
for q in "$@"; do
|
||||
n=$("$D/get.sh" "/mail/search?q=$q" | grep -oE '/mail/thread/[0-9]+' | sort -u | wc -l)
|
||||
printf '%-24s %s\n' "$q" "$n"
|
||||
done
|
||||
Reference in new issue
Block a user