Commit Graph
709 Commits
Author SHA1 Message Date
Paul LütznerandClaude Opus 5 748e1cdcdf feat(demo): a privacy notice, and two fixes found by using the demo
**The privacy notice gets its own page and its own link.** A section inside the
Impressum is not one — it has to be reachable in its own right — and it is
required even though this instance asks for nothing. A web server writes the
caller's IP address into a log on every request and an IP address is personal
data, so "we collect almost nothing" is a statement that has to be made rather
than a reason to omit the page.

Written from what the software does rather than from a template. The cookies
named are the three it actually sets, observed on a real visit; the claim that
nothing reaches a third party is the content security policy, which permits
connections to this server only; and the retention period is read off
APP_DEMO_TTL through DemoMode::ttlDescription() rather than typed into the
prose, because a notice stating a period the reaper does not honour is worse
than one saying nothing, and two copies drift the first time somebody tunes the
setting.

APP_DEMO_PRIVACY_HOST names whoever runs the machine, who is a processor.
Unset, the page says so in red — the same rule the Impressum follows, for the
same reason: a legal notice naming nobody looks compliant from a distance.

**Pressing Receive twice looked broken.** The second press appeared to do
nothing for up to thirteen seconds. _refreshList() coalesces bursts through a
fifteen-second floor measured from the LAST refresh, so the press right after a
delivery was the one certain to be inside it. The floor is right — a sync
publishes one event per mailbox per account, and it turns eight fetches into
one — but it was being applied to somebody who had just pressed a button and was
watching for the result. refreshNow() skips it; onAccountSynced() still does
not. The difference is whether anybody is waiting.

**The user menu still wore its own tooltip.** The previous fix stopped a NEW
bubble appearing over an open menu but left the one already on screen, which is
why it vanished only when the pointer left. There is a document-level click
listener whose whole job is to take it down, and ui--user-menu#toggle calls
stopPropagation(), so the click never reached it. The listener runs in the
CAPTURE phase now — at the document, before the event reaches its target, where
no handler can hide a click from it. The same reason the scroll listener above
it already uses capture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 12:44:35 +02:00
Paul LütznerandClaude Opus 5 d51c3c8475 release: v0.1.21 — events open to be read
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v0.1.21
2026-08-25 12:22:56 +02:00
Paul LütznerandClaude Opus 5 31c522b373 feat(calendar): an event you can read without being asked to change it
Clicking an event opened the editor, which is a form. To answer "when is this
and where did it come from?" you opened something that asks you to change it,
and every field with no answer showed as an empty input — which reads as
missing data rather than as no data.

Three things were not on that form at all, because it does not edit them. The
DESCRIPTION and the PARTICIPANTS live in the JSCalendar overlay. The
PROVENANCE — the message the event was read out of — the form has never
carried. That last one is the reason this is worth a dialog: an extracted event
is a guess a program made about somebody's mail, and a guess you cannot trace
is one you cannot check.

So a click opens the details now, on the grid and in Happening Soon alike, with
Edit one step in. Reading is the common case by a wide margin and it was the one
that was missing. Delete stays on the editor: it is the only irreversible thing
here, and a read-only view is the wrong place to keep it.

Happening Soon rows open whether or not a mail is behind them. Before this the
only thing on a row you could act on was the provenance link, so a hand-typed
appointment — which by definition has no mail — was the one kind of row with
nothing clickable on it at all.

The source message is resolved through findLatestAppliedMessageByEvent() with a
single event rather than a second lookup. That method encodes a rule with a
reason: the NEWEST applied claim wins, so a reschedule answers "why is this on
my calendar?" rather than the confirmation it replaced. Two copies would
eventually disagree — in the panel and in the dialog the panel opens, which is
exactly where somebody would notice.

Participation reuses calendar.invite.status.*, already translated three ways,
rather than a fourth set of accepted/declined/tentative. RFC 8984 spells the
enum value "needs-action" and the catalogue uses "needs_action", so the value is
normalised at the call site rather than the catalogue being reshaped.

Six specs drove the editor by clicking a chip and now click through Edit. One
deserves naming: calendar-timegrid guards that DRAGGING an event opens no
dialog, and it matched only /edit — left alone it would have gone on passing
while every drag opened a details panel.

HappeningSoonRenderTest built events without persisting them, which was fine
while the template only read their titles. A row links to the event now, and a
link needs an id. The fixtures are persisted rather than the template growing an
`if id` branch that only the test would ever exercise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 12:22:33 +02:00
Paul LütznerandClaude Opus 5 fe08157dba fix(demo): the Impressum variables never reached the container
They were documented, wired into a parameter, read by the controller and
rendered by the template — and setting them did nothing. compose hands a
container only what a service declares, so putting them in an .env file made
them available for ${…} interpolation and stopped there.

The failure is quiet in the way that costs the most time: the operator sets
three variables, restarts, and the page still says they are unset, with nothing
in any log to explain the disagreement. Found by setting them on the live demo
and watching the warning stay put.

Empty defaults, matching the shipped .env, so an instance nobody has configured
still says loudly that it has no operator rather than rendering a blank
heading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 11:32:45 +02:00
Paul LütznerandClaude Opus 5 debf773384 release: v0.1.20 — the badge and the list agree again
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v0.1.20
2026-08-25 11:28:50 +02:00
Paul LütznerandClaude Opus 5 db837d2a7e fix(mail): new mail on a Gmail or Graph account never reached the list
plMail publishes two kinds of sync announcement, and which one arrives depends
on the account. IMAP sends mailbox.synced and can name the folder that changed.
Gmail, Graph and demo deliveries send account.synced and cannot — they have no
per-mailbox sync to report.

The layout bound only the first. Nothing errored: ui--sidebar listens for both,
so the badge went to "Inbox 4" beside a list holding three, and stayed there
until the next navigation put it right. Whether it looked broken depended on
whether you happened to click away afterwards, which is how the counter bug in
mail_writes.js presented too.

The demo is where it became unmissable, because there the Receive button is the
only thing that ever publishes: the badge moved and the list did not, so the
button looked dead. That is the report this came from, and the fix is not
demo-specific — every Gmail and Outlook account has been doing this.

account.synced is handled like the polling fallback rather than like a mailbox
event, because it carries the same amount of information: something in this
account changed and there is no way to tell whether it was the view on screen.
_refreshList()'s own floor keeps a burst to one request, which is what that
floor is for.

The tooltip fix from the last release was wrong in a way that looked right. It
asked whether the trigger sat inside a ui--dropdown whose menu had the `hidden`
PROPERTY set — and the user menu, which is what was reported, is a different
controller hiding its menu with a `hidden` CLASS. It now walks up to whichever
controller owns the trigger, finds that controller's menu target whatever it is
called, and asks checkVisibility() rather than caring how it was hidden.

Both bindings are now asserted. A missing one is silence — no error, no failing
assertion anywhere, just a number that disagrees with the list beside it — so
the test reads the layout as text, the way AccountFormControllersTest does for
the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 11:28:32 +02:00
Paul LütznerandClaude Opus 5 986e0f663d release: v0.1.19 — a demo that has been lived in
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v0.1.19
2026-08-25 11:14:18 +02:00
Paul LütznerandClaude Opus 5 9c2fc922b2 feat(demo): a mailbox the product has actually happened to
The demo had no insights, every message was Primary, and the calendar was an
empty grid. Not because those features were off — because DemoMailbox::seed()
wrote finished rows and skipped PostIngestPipeline, which is where all three
come from. A mailbox assembled that way looks right and is inert: nothing
categorised it, nothing read it, and the filters a visitor is invited to build
had never been run over anything.

The batch now goes through the pipeline the way a real IMAP sync does. Threads
are no longer built here — the pipeline assigns them, and doing both left two
of each — and the JMAP create is no longer recorded here either, for the same
reason. One run for the whole batch rather than one per message, because this
happens while a visitor waits for /demo to answer.

Content had to earn its insights rather than merely suggest them. The invoice
states "Gesamtbetrag 285,60 EUR" and "Zahlbar bis 12.09.2026", which is what
InvoiceExtractor reads; the sign-in mail carries a contiguous six-digit code,
because \d{4,8} does not match "418 259". There is deliberately no parcel:
ParcelExtractor keys on real carrier domains, and putting noreply@dhl.de in a
demo would be impersonating DHL to make a card appear — which the caption under
the README screenshots promises this does not do.

Categories are decided from headers, so the newsletter carries List-Unsubscribe
and the two robots carry Auto-Submitted. That alone was not enough, and the way
it failed is worth recording: it worked on the first seed and not the second.
Every sender was flagged `correspondent`, MessageCategorizer treats that as an
override forcing Primary, and the flag survived into the next run. Fixed from
both ends — the flag is false for bulk senders, since a correspondent is
someone the user has WRITTEN to, and the address book is now wiped alongside
the threads, because upsertBatch will not clear a flag it has already set.

Plus a week in the calendar, written relative to today so the demo is never
showing last month, and an HTML newsletter so the reading pane has something to
render that is not prose. The command's own calendar cleanup is gone: it
duplicated seedCalendar() and its comment said the opposite of what is true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 11:13:46 +02:00
Paul LütznerandClaude Opus 5 6e9a441333 fix: an install page a demo cannot close, and eleven flashes nobody translated
Two bugs found by using the thing.

**A demo visitor could lock an install out of /install.** InstallGuard closes
that page as soon as a user exists, and demo mode mints one per arrival — so on
a fresh demo instance the first passer-by or crawler took the install page away
with them, permanently, before the operator had made themselves an
administrator. Nothing announced it either: the page 404s, which is exactly
what it does on a correctly installed instance, so the symptom is
indistinguishable from working. Both the guard and createFirstAdmin's
advisory-locked re-check now count real users only.

**Flash messages reached the page as raw translation keys.** The flash bag on
this install holds sentences, not keys — the toast region prints what it finds,
and cannot translate, because most of these interpolate a name or a count only
the controller has. Eleven call sites across TwoFactorController,
SecurityStepHandler and AdminDashboardController pushed keys instead, so a
rejected code produced a red toast reading "two_factor.flash.code_rejected".
The strings existed and had been translated into all three locales months ago;
nothing looked them up. A static test now scans src/ for flash calls passing a
dotted literal, because nothing about that failure is visible in review — the
call site reads exactly like a correct one.

That fix broke two E2E tests, which is the interesting part: they asserted on
"Two-factor authentication is on" and had been matching the panel heading ALONE
because the toast beside it was showing a key. Translating it made both match
and strict mode rightly refused. They were passing because of the bug.

**Demo mode, from running one publicly.** The account form opened in a
turbo-frame, so the guard's redirect had nowhere to land and the modal span
forever — it now renders a panel saying why the form is not coming. The receive
button was a full browser navigation, which reloaded the page on top of mail
Mercure had already delivered AND wiped motion.js's in-memory set of seen rows,
so the whole list replayed its entrance; it is a stream touching only the demo
bar now, and mail simply arrives. Plus an Impressum, because a public demo in
Germany needs one — blank by default and loudly saying so, since § 5 TMG wants
a real operator and a notice naming nobody looks compliant without being so.

**Interface, all reported from use.** The snooze menu's two distant options were
taller than the near ones, because anything past two days carries the date and
"Sat, Aug 29, 08:00" wrapped inside min-w-56. Clicking a dropdown trigger
focused it, and focus shows a tooltip — so opening the user menu painted its own
hint across the menu. The eight recovery codes ran together on one line: they
were joined with real newlines and <code> collapses those without
whitespace-pre-line, so nothing was rendering what was already there. Confirming
a 2FA code gave no sign anything was happening, and the reply carries codes
shown exactly once. Layout was the last <select> on a panel where every other
short choice is a segmented group. Solar and paper put a white field on a warm
off-white surface, which under the boxed layout's translucency left the search
box with no visible edge. Read receipts had a tab of their own coming — the card
holds nothing else, and signatures left the alias tab before it for the same
reasons. And /settings opened on its fourth entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 11:03:08 +02:00
Paul LütznerandClaude Opus 5 0dcdc13669 release: v0.1.18 — a demo anyone can walk into
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v0.1.18
2026-08-25 10:06:45 +02:00
Paul LütznerandClaude Opus 5 231d0ab0da feat(demo): a public demo mode, with mail that arrives on demand
plMail is hard to show. It is a mail client, so everything interesting only
happens once there is mail — and a stranger cannot reasonably be asked to hand
a demo server their IMAP password to see it. APP_DEMO_MODE fills that gap: mail
that is nobody's, and a button that makes more of it arrive.

One switch for the whole install, not a per-account flag. The promise is that
nothing reaches the network, and that is only checkable if there is one place
to check; a per-account flag would move the promise into every outbound path
individually, where it holds until the first one that forgets to ask. Read
through `default:` so an install that never heard of the variable is a normal
install — the accident worth designing against is a real deployment becoming a
demo, not the reverse.

**A mailbox per visitor.** /demo provisions a throwaway user, signs them in and
drops them in a seeded inbox. One user each rather than one shared account: two
people arriving at once would otherwise be marking each other's mail read and
reading each other's drafts, and the product would look broken while working
perfectly. app:demo:reap deletes them on a timer.

**Receiving.** A bar at the foot of the page walks a scripted queue — a reply,
an invoice with a real attachment, a delay notice, an HTML newsletter — and
wraps at the end, naming what comes next. Delivery goes through the real
PostIngestPipeline rather than writing rows by hand, because what is being
demonstrated is that plMail *does* something when mail arrives: it threads, it
categorises, it runs the filters the visitor just built, and it appears without
a reload. A hand-written row would put mail on screen and none of that would be
true, which is a worse demo than no button.

**Sending.** DemoMailSender is registered above every real sender and claims
every account while demo mode is on, so SmtpMailSender is never asked — "a demo
cannot reach a relay" becomes a property of the wiring rather than a rule each
sender has to remember. It answers a few seconds later, from whoever the visitor
addressed, on the thread they are still looking at.

**What is switched off**: adding or editing a mail account, OAuth and CalDAV
connect, calendar subscribe — a stranger typing their real credentials into a
box on someone else's server has handed their mail away, and there is no version
of that form that is safe to show. Mail sync, push renewal and calendar sync
no-op, since a demo's accounts point at documentation domains. Everything else
stays clickable: locking down all the non-mail surfaces produces a tour of a
product with the interesting doors locked.

Two things this needed that did not exist. The demo mailbox moved out of
SeedDemoMailboxCommand into DemoMailbox, shared with the provisioner, so the
README screenshots and the hosted demo cannot drift into being pictures of
different things. And deleting a user — this project had no such path, because
a self-hosted install's users leave by having their password changed. The
eraser discovers owned classes from Doctrine's metadata rather than a list:
twenty entities point at User today under two different property names, and a
hand-written list would be right until the next entity was added, at which
point the reaper would start failing on a foreign key at three in the morning.

compose.demo.yaml hardcodes APP_DEMO_MODE rather than interpolating it, and
ComposeEnvironmentTest now holds it to that. Compose reads Symfony's committed
.env, which ships the variable as 0, so `${APP_DEMO_MODE:-1}` would have
resolved to 0 for everyone who cloned the repo — and the failure is the bad
kind, where the stack comes up healthy and simply is not a demo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 10:05:40 +02:00
Paul LütznerandClaude Opus 5 23b925a734 chore: stop tracking .idea
The rule was already there — .gitignore line 29 has had `.idea/` in it for as
long as anyone remembers — but gitignore does not apply to files git is already
tracking, so nine of them kept churning in every diff. php.xml and pl_mail.iml
are the noisy pair: the IDE rewrites them on reindex, which meant fourteen
thousand lines of movement in a branch that touched neither.

The files stay on disk. This only stops git having an opinion about them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 10:05:16 +02:00
Paul LütznerandClaude Opus 5 32d69394e5 release: v0.1.17 — Mercure 0.8, two databases, and a correction
Live updates move to symfony/mercure 0.8, held back from the last
release's sweep on purpose and rightly so: HubInterface grew two methods.
Verified against the specs that stop and restart the hub mid-test.

The unit and browser suites stop sharing a database. Both ran as `test`
and so landed on the same one, while a test in the unit suite truncates
tables by design — concurrently that can block the browser suite's
queries and produce timeouts in specs with nothing to do with it.

And a correction. v0.1.16's notes said Playwright was pinned because 1.62
reports painted elements as hidden. That was wrong: the tests were
clicking a one-pixel screen-reader-only radio and the click was not
taking — a failure this suite had already documented as hitting one run
in three. The test was broken before 1.62 and merely intermittent; the
new version made it reliable. The pin came off the same day.

The v0.1.16 entry now points here rather than being quietly rewritten.
Someone who read the wrong explanation should be able to find out it
changed.

Verified before tagging: 4047 unit tests, PHPStan clean, 578 browser
tests under TZ=UTC at CI's two workers.

No migration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v0.1.17
2026-08-24 15:21:48 +02:00
Paul LütznerandClaude Opus 5 24a5ab3de1 build: mercure 0.8, and a database of PHPUnit's own
MERCURE 0.5.0 / symfony/mercure 0.8.0, kept out of the earlier sweep
because it carries the live-update transport and should not ride along
with eighteen patch bumps. It earned that: HubInterface gained
getProtocolVersion() and getCookieName(), so the two hub doubles in
CalendarSyncFinishedNotificationTest stopped satisfying it — a fatal
error, not a failing test. Both implement them now, returning what a real
hub would rather than pretending to configuration these tests do not
have. Verified where it matters: the full browser suite, including the
specs that stop and restart the hub to prove the stream recovers.

A DATABASE OF ITS OWN for the unit suite.

Both suites run APP_ENV=test, and doctrine.yaml's
`dbname_suffix: '_test%env(default::TEST_TOKEN)%'` therefore pointed both
at app_test. One database, two suites — and one test in here truncates
real tables, because exercising a data reset is its whole purpose.

Sequentially that is safe; Postgres rolls a TRUNCATE back like anything
else. Concurrently it is not: TRUNCATE takes an ACCESS EXCLUSIVE lock, so
live queries from the browser suite block until the unit test's
transaction ends, and specs with no connection to any of this report
timeouts. It fits the transaction abort seen here once and never
reproduced.

TEST_TOKEN is Symfony's own mechanism for exactly this, so the fix uses
it rather than inventing a second DATABASE_URL. bin/test-unit.sh sets it
and does the create/migrate/seed a fresh database needs.

The seeding is the part worth guarding: PageRendersTest, ListFragmentTest
and others SKIP THEMSELVES when the admin user is missing, so without it a
green run would quietly be ~50 tests smaller than it looks — a worse
failure than a red one. Checked rather than assumed: 4047 tests either
way, no skipped or incomplete markers.

The assertion count moved, 11540 → 11526, and settled there across runs.
That is the point of the change showing up: the unit suite was reading
rows the browser stack had left behind, and now it reads only its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 15:08:17 +02:00
Paul LütznerandClaude Opus 5 c8e4c8fdaf test(imap): a UID no other fixture will pick
CI failed PHPUnit earlier today on

  duplicate key value violates unique constraint
  "uniq_message_mailbox_imap_uid"
  DETAIL: Key (mailbox_id, imap_uid)=(397, 5990) already exists

in GhostMessageReaperTest, which seeds nine messages into one mailbox and
gave each a random_int(1000, 9999) UID.

That is a birthday problem wearing a disguise. The odds are not "one in
nine thousand"; they are the odds that ANY TWO of the messages a test
seeds collide, and two other fixtures were drawing from ranges of 999 and
900 — where a handful of messages collide about one run in a hundred.

Nothing here could ever have been reproduced on demand, because there is
nothing to reproduce: the dice are rolled again on every run, and the
failure is a property of the range rather than of anything in the code.

A counter cannot collide. Per process is sufficient — every one of these
tests runs inside a transaction it rolls back, so no number outlives the
process that issued it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 14:30:24 +02:00
Paul LütznerandClaude Opus 5 b22e5a3be4 test(appearance): click the label, and take the Playwright pin back off
The pin was wrong and so was the reason I gave for it.

I claimed 1.62 had changed what counts as visible, on the strength of
measuring the element under 1.61 and finding it painted. Probed under
1.62 the same element reports isVisible() TRUE. The geometry told me the
element was fine; I turned that into a claim about the tool.

The actual failure, from the log:

  locator.check: Clicking the checkbox did not change its state

A forced click on an `sr-only` radio — a 1px clipped box — that lands
without taking. label-bulk-toggle.spec.ts documents this exact sentence
and works around it, noting it hit "roughly one full run in three". So it
was already broken under 1.61 and merely intermittent; 1.62 made it
reliable. Eight failures at once instead of one every third run is the
tool exposing a broken test, not breaking a working one.

The fix was one helper away in the same file. pick() has always clicked
the LABEL, for the reason its comment gives — the radio is invisible and
the label is what a person clicks. setToggle() never caught up. It does
now, waits for the write it triggers, and asserts the radio actually
moved.

The last straggler was self-inflicted: the corner is a stored preference
restored by afterEach, which used the same broken helper, so my failing
runs left it switched off and the next run's "on by default" assertion
was reading their residue.

578 pass on 1.62.1. The other seven check({ force: true }) sites are
untouched — none of them failed, and rewriting them on suspicion is how
tests acquire workarounds nobody can later justify.

v0.1.16 shipped with the wrong explanation in its notes; the next release
carries the correction rather than a rewritten tag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 14:24:48 +02:00
Paul LütznerandClaude Opus 5 e8f941e6c8 release: v0.1.16 — the reading pane gets its height back
Message bodies had stopped sizing themselves: every mail an 80px
letterbox with a scrollbar inside a scrollbar, and on a self-hosted
instance the only sign was a console error.

The body renders in a sandboxed frame with no reach into the page, so the
only way it can be sized is a small script inside it measuring itself and
saying so — and that script was blocked. A srcdoc frame is governed by the
embedding page's policy as well as its own, and a nonce cannot satisfy
both: the page's belongs to the request that rendered the page, while the
conversation is rendered by a later one as you click it. They coincide
only on a full page load, which is why it was broken on every open and
fixed by every reload.

Authorised by the script's hash now — the same in every request, and
stricter than the nonce it replaces.

It only ever happened on real installs: the full policy is Report-Only
under debug, and only enforced policies block.

Also Symfony 8.1.5 across eighteen packages, tailwind-bundle 1.0, the CI
actions a generation forward, and TypeScript 7 with a typecheck that
passes clean for the first time. Playwright is held at 1.61 — 1.62 calls
painted, unclipped elements hidden.

Verified before tagging: 4047 unit tests, PHPStan clean, 578 browser
tests under TZ=UTC at CI's two workers.

No migration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v0.1.16
2026-08-24 14:05:45 +02:00
Paul LütznerandClaude Opus 5 fbdc61b5bd build(deps): Symfony 8.1.5, tailwind-bundle 1.0, TypeScript 7 — and Playwright held at 1.61
Five of the six open Dependabot PRs, taken locally in one pass rather
than merged one at a time: each rebase triggers the expensive workflow
and they all touch the same two lock files. The sixth, the actions group,
was merged as it was — its own CI was green, and it clears the Node 20
deprecation the runner has been printing.

  symfony/*                     8.1.0/8.1.1 → 8.1.5   (18 packages)
  symfonycasts/tailwind-bundle  0.14.0      → 1.0.0
  @types/node                   22          → 26.2.0
  typescript                    5.6         → 7.0.2

PLAYWRIGHT IS PINNED AT ~1.61.1, deliberately, and this is the only part
worth arguing about.

1.62 failed eight cases in appearance/list-display.spec.ts, every one of
them asserting that a clip-path'd element is visible. Tailwind was the
obvious suspect and is innocent: the compiled clip-path and spacing rules
are byte-identical between the two builds. Reverting Playwright alone
took it from eight failures to none.

Then the question that actually matters — whether 1.62 is RIGHT, because
"element is hidden" could mean a real UI bug. It is not. The account
corner measures 10×10 at (957, 336), display block, visibility visible,
opacity 1, an opaque background, and a parent that does not clip. It is
on screen and painted. 1.62 changed what it counts as visible for
clipped elements and is wrong about this one.

`~1.61.1` rather than `^`: a caret would walk straight back into 1.62 on
the next clean install.

TypeScript 7 earned its place. It found a genuine error — el.click() on
`HTMLElement | SVGElement`, in a test written this morning, since TS7's
lib.dom no longer puts click() on SVGElement — plus nine implicit-anys
that TS5 had also been reporting into a void, because CI does not run
typecheck. All eleven fixed: `npm run typecheck` is clean for the first
time. A typecheck that is permanently red is one nobody runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 13:52:34 +02:00
Paul LütznerandClaude Opus 5 e4a642b1bf fix(mail): the message frame is authorised by a hash, so Turbo cannot break it
Reported after the last fix: the frame still does not measure itself on
the FIRST open of a mail, only after a reload.

Sharing the request's nonce was right and not enough. A page's policy is
fixed when the page loads, and a conversation is normally rendered by a
LATER request — fetched into a Turbo Frame — which has a nonce of its
own. The srcdoc inherits the page's policy on top of its own, so the
script carried a nonce that policy had never heard of. The two coincide
only on a full page load, which is exactly the shape of the report.

There is no request that renders both, so no nonce can work here.

A hash can. It is a property of the script text: identical in every
request, so the page's policy and the frame's own can both name it
without either knowing when the other was rendered. It is also tighter
than the nonce it replaces — a nonce authorises anything carrying it, a
hash authorises one exact script and nothing else. The script has no Twig
in it at all, which is what makes this possible.

The script moved into templates/mail/_frame_script.js so the hash is
DERIVED rather than maintained beside it. A written-down hash drifts the
first time somebody edits the script, and that failure is silent, remote
and production-only: under debug the policy is Report-Only, so every test
would still pass while every real message sat at its 80px floor.

Three tests. The one that matters asserts the PAGE's header names the
hash — the half the frame cannot check for itself, and the one that fails
on the original bug; verified by reverting the listener. The K-07
rendering-security spec now pins the whole directive rather than the
prefix, so 'self' or 'unsafe-inline' creeping in beside the hash fails
too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 13:52:34 +02:00
dependabot[bot] ffeb7732ec build(deps): Bump the actions group across 1 directory with 12 updates (#7)
Bumps the actions group with 12 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `4` | `7` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `3` | `4` |
| [docker/login-action](https://github.com/docker/login-action) | `3` | `4` |
| [docker/metadata-action](https://github.com/docker/metadata-action) | `5` | `6` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `6` | `7` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `4` | `7` |
| [actions/download-artifact](https://github.com/actions/download-artifact) | `4` | `8` |
| [actions/cache](https://github.com/actions/cache) | `4` | `6` |
| [actions/setup-node](https://github.com/actions/setup-node) | `4` | `7` |
| [actions/configure-pages](https://github.com/actions/configure-pages) | `5` | `6` |
| [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) | `3` | `5` |
| [actions/deploy-pages](https://github.com/actions/deploy-pages) | `4` | `5` |



Updates `actions/checkout` from 4 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

Updates `docker/setup-buildx-action` from 3 to 4
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

Updates `docker/login-action` from 3 to 4
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

Updates `docker/metadata-action` from 5 to 6
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5...v6)

Updates `docker/build-push-action` from 6 to 7
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

Updates `actions/upload-artifact` from 4 to 7
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

Updates `actions/download-artifact` from 4 to 8
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

Updates `actions/cache` from 4 to 6
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v6)

Updates `actions/setup-node` from 4 to 7
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v7)

Updates `actions/configure-pages` from 5 to 6
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v5...v6)

Updates `actions/upload-pages-artifact` from 3 to 5
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v5)

Updates `actions/deploy-pages` from 4 to 5
- [Release notes](https://github.com/actions/deploy-pages/releases)
- [Commits](https://github.com/actions/deploy-pages/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/configure-pages
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/deploy-pages
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/upload-pages-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/metadata-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 12:58:26 +02:00
Paul LütznerandClaude Opus 5 ff29d7ba7d fix(mail): the message frame can measure itself again
Reported from a live instance: message bodies stopped resizing, with
"Executing inline script violates the following Content Security Policy
directive 'script-src 'self' 'nonce-…'" on every open.

A srcdoc frame inherits the EMBEDDING page's Content-Security-Policy on
top of the <meta> one it carries itself, so the height reporter inside it
— the only reason `allow-scripts` is on that sandbox at all — has to
satisfy both. MessageRenderer minted a nonce of its own, which the
frame's policy named and the page's policy had never heard of. The moment
the application itself grew a CSP, the script was blocked by the
inherited one and every message sat at its 80px floor with a scrollbar
inside a scrollbar.

The error names the policy that did it: 'self' appears in the page's
script-src and never in the frame's.

It uses the request's nonce now. Nothing is given away by sharing it —
the nonce is already in that document, beside the email's own markup, and
the whole point of a nonce is that markup which did not come from us
cannot name it. Asking CspNonce for the value is also what tells the
response listener the header must carry one.

WHY THE SUITE DID NOT CATCH IT, which matters more than the fix.

Under APP_DEBUG the full policy is sent Report-Only — deliberately: the
profiler injects inline scripts nothing can nonce. Report-Only does not
block. The browser stack runs debug, so message-frame-height.spec.ts
measured a frame that worked perfectly there while being blocked in
production, where the same policy is ENFORCED. Verified: that spec passes
with this fix reverted.

So the guard is a unit test. It asserts the invariant the enforced policy
depends on — the frame's nonce IS the request's — which is true or false
whichever header the environment happens to send. It fails without the
fix.

Also the two deprecation warnings from the same console:
Turbo.config.forms.confirm replaces the removed-in-future
Turbo.setConfirmMethod, and mobile-web-app-capable joins the Apple-
prefixed meta rather than replacing it — older iOS is the platform that
tag was written for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 12:51:18 +02:00
Paul LütznerandClaude Opus 5 016f445444 test(calendar): a save is finished when the server says so, not when the dialog goes
The alerts spec adds a 45-minute reminder, saves, reopens the editor and
expects seven checkboxes. It got six on all fourteen polls — so the
reminder was genuinely absent, not slow to appear, and the failure read
as the feature not saving at all.

submit() clicked Save and waited for the modal to be HIDDEN. That says
the client has moved on; it says nothing about the server having written
anything. Every caller then reopens the editor to read back what was
saved, so the GET raced the POST and was answered with the event as it
was.

Fixed in the helper rather than at the call site that happened to fail —
all six uses go through it — and the status is read rather than assumed,
because a refused save answers just as promptly as an accepted one. Every
button routed through it posts under /calendar/event, so one predicate
covers save, delete and move.

This is the fourth instance today of one mistake: an observable that is
already true, or true for the wrong reason, standing in for a wait. The
others were the scheduled-send cancel (a composer that was already open),
the two new search tests (toBeHidden, satisfied the instant it was
called), and this.

Verified: 16 consecutive runs of this spec, then the full suite under
TZ=UTC at CI's two workers — 578 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 12:05:37 +02:00
Paul LütznerandClaude Opus 5 71a2384fda test(e2e): room for a long test, and a question for a short one
v0.1.15's tag run: one failure, one flaky, neither in anything the
release touched.

snoozed-list timed out as a WHOLE at thirty seconds — no assertion inside
it was wrong. It seeds, opens two menus, waits on two writes and walks
three lists, and on a two-vCPU runner carrying five hundred other tests
that legitimately outgrew the default. test.slow() is what Playwright
offers for saying so, and it leaves every assertion inside on its own
honest timeout rather than widening any one of them.

compose-undo-reopen is NOT given more time, on purpose. It failed with
the composer resolving on all fourteen polls — continuously present for
the whole five seconds, not slow to disappear — and a window that never
closes is not a window that closes late. A longer wait would move where
it fails and teach us nothing.

What it gets instead is a question. Two things could produce it: the
click never reached close(), or something re-rendered the dock after it
did. The second is the live candidate, because this is the undo path and
putting a window back is precisely its job — a stream arriving after the
reader has closed it would look exactly like this. The message says so,
so the next occurrence tells us which rather than restarting the
investigation.

Not reproduced locally: 32 consecutive runs of that spec at two workers
under TZ=UTC, all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 11:26:48 +02:00
Paul LütznerandClaude Opus 5 c90d599fd8 release: v0.1.15 — cards that say what they are
The radar panel's undated section was named after the only thing that
used to reach it. Parcels whose mail never stated an ETA have no date
either, so they arrived under "From your repos" with no status, no
tracking and no way back to the mail. Every card carries its mail now,
and says what it is when it has no date to lead with.

Also: the live-updates dot reports that it is connecting rather than
showing nothing at all while it is, and same-day parcels stop shuffling.

Verified before tagging: 4044 unit tests, PHPStan clean, 578 browser
tests under TZ=UTC at CI's two workers.

No migration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v0.1.15
2026-08-24 11:02:46 +02:00
Paul LütznerandClaude Opus 5 42404f8128 fix(insights): the undated cards say what they are, and link to their mail
Reported as "from your repos has dhl links".

The undated section was written when GitHub was the only kind without a
date, and three separate places quietly encoded "undated means repo": the
heading, the card, and the external link.

It stopped being true the moment anything else failed to state a date. A
courier mail that never says WHEN still tells you about a parcel —
ParcelExtractor passes the ETA straight through, and that is null when
none was found — so those parcels landed in a list headed "From your
repos", stripped of everything the dated card would have given them: no
status, no tracking link, no way back to the mail.

THE CARD is now one partial for both sections. There were two copies, and
they drifted the way two copies do: the dated one grew a status pill, a
Track button and a link to the mail, while the undated one stayed the
shape GitHub had left it in. An undated card now leads with WHAT IT IS,
which the kind chip already knew but only told a screen reader — sighted
readers got a coloured tile and an icon, which says "parcel" to whoever
already knew.

EVERY CARD offers the mail it was read from. An insight is a claim about
a mail, so the mail is what settles it, and the undated ones were both
the ones missing it and the ones where a reader most needs it, there
being no date to explain what they are looking at.

THE EXTERNAL LINK printed GitHub's mark and the word "GitHub" for any
payload url at all, in three templates. It picks the mark from the KIND
now and falls back to naming the link's host — modest on purpose: it says
where the link goes without claiming to know what is there. Only GitHub
sets a url today, which is exactly the assumption that cost the section
its name.

ORDERING, while here. The dated list ordered on happensAt alone, and a
parcel's ETA is a DAY: every parcel due on one day sits at that day's
midnight, so ties were the common case and the planner decided both their
order and, under a capped list, which one fell off the end. The undated
list has it worse — the harvester stamps a whole batch of mail inside the
same second.

Heading is "Also spotted" / "Außerdem gefunden", which stays true whatever
lands there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 11:02:27 +02:00
Paul LütznerandClaude Opus 5 b79b50affa fix(mercure): the stream dot says it is connecting while it is connecting
State was only ever set from onopen or onerror, so between constructing
the EventSource and the first of those there was none — and the topbar
dot renders nothing at all until it is given one. No colour, no tooltip,
on every fresh page load.

Normally that gap is milliseconds and invisible. It is not always: an SSE
request can HANG rather than fail — a hub still coming up accepts the
connection and holds it — and then neither handler fires, so the dot sits
blank and untitled for as long as that lasts, over a stream that is
genuinely still trying. "Reconnecting to live updates…" is the honest
thing to show there, and the dot already carries that label.

This is what CI kept reporting as a flake, twice on a release tag, as
`data-state` being "" rather than "connected" — not a wrong state, no
state.

The same test's first assertion now gets the sixty seconds the reconnect
eight lines below it already had. It is the same post-restart connect:
the case before this one stops and starts the hub. Fifteen seconds was
never a claim about the feature, it was the default, and on a two-vCPU
runner after five hundred other tests it was simply short.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 10:50:33 +02:00
Paul LütznerandClaude Opus 5 bc26c7e57f release: v0.1.14 — a registration handed back, and a tab that keeps your filter
Renewal used to erase a Graph subscription id one line before the code
that needed it to send the DELETE, so every renewal that failed for a
transient reason left Microsoft holding a live registration plMail had
just forgotten — delivering notifications nothing could match, hourly,
until it lapsed. No mail was ever lost to it: push brings a sync forward
and polling has always been the backstop. app:push:cancel-subscription
cleans up the ones already out there.

The inbox tabs now carry the filter they are clicked inside, which the
toolbar's own "Show all" had been doing in the other direction all along.

And the browser suite reads times in the zone the page is rendered in
rather than the one the test process happens to be in — the reason
v0.1.13 passed eighteen local runs and still went red on the tag.

Verified before tagging: 4042 unit tests, PHPStan clean, 578 browser
tests under TZ=UTC at CI's two workers.

No migration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v0.1.14
2026-08-24 09:52:09 +02:00
Paul LütznerandClaude Opus 5 ae46ce4f11 fix(inbox): a category tab keeps the filter you are looking through
The unread badges became links, so a sidebar badge opens its view narrowed
to ?unread=1, and the toolbar shows a chip saying so with a "Show all"
beside it. That link is careful: it rebuilds the URL from the request and
strips only `unread` and `page`, so every other narrowing — ?tab=,
?account=, ?sort= — survives being switched off.

The tabs did not do the mirror image. Each linked to `?tab=<category>` and
nothing else, so reaching Social from an unread inbox answered with ALL of
Social — silently, because the chip went with it, and the only way back in
was the sidebar badge again. The two dimensions of this list were mutually
exclusive: filter, or change tab, and the second undid the first.

Now built the same way round: carry the current query, drop `tab` because
that is what the link sets, and drop `page` because page four of Primary
is not page four of Promotions. Rebuilt from the request rather than
naming `unread`, for the reason the toolbar gives — naming it would fix
today's filter and quietly lose the next one.

Which tabs EXIST is deliberately left alone. That comes from the totals
per category, not from the filtered set, so the strip does not reshuffle
when the filter goes on and a tab with nothing unread shows its empty
state instead of disappearing under the pointer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 09:38:45 +02:00
Paul LütznerandClaude Opus 5 0dfe02dd4f feat(push): a way to cancel a Graph subscription plMail no longer knows
The companion to the renewal fix, for the orphans already out there.

"GraphNotification: unknown subscription" names an id, and the endpoint
that logs it can do nothing with one: without a matching account there is
no token to cancel it with, so it kept saying so once an hour until
Microsoft let the registration lapse — up to three days.

Naming the account on the command line is what makes it actionable. Any
account in the same mailbox can revoke it, and the id from the log line
is the only other thing needed:

  php bin/console app:push:cancel-subscription --account=10 \
      --subscription=701dc0d3-b4ed-4ad8-94f2-a263a90a30bb

Every test here is a wrong invocation, deliberately: the successful path
needs Microsoft, and what actually costs somebody an afternoon is a
command that takes a mistyped account id and says nothing useful. A Gmail
account is refused before any call goes out, since a DELETE carrying a
Google token would report whatever Microsoft said about it and explain
nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 09:25:41 +02:00
Paul LütznerandClaude Opus 5 722b427de6 fix(graph): a renewal that fails must hand the old subscription back
Warnings 1 and 2 in the admin log are the same bug, one of them the cause
of the other.

renew() caught every failure, cleared local state, and let subscribe()
do the teardown. subscribe() returns early when there is no subscription
id — which there no longer was — so the DELETE never went out. Every
renewal that failed for a transient reason therefore left Microsoft
holding a LIVE subscription that plMail had just forgotten.

Microsoft then delivered notifications for it. No account matched the id,
so GraphNotificationController logged "unknown subscription" — throttled
to once an hour, for up to the three days it takes to lapse, about a
registration nobody could cancel because the only record of it had been
erased. Its own note says as much: "Microsoft still holds this
registration; it stops when it expires."

Now the two failures are told apart. A 404 means Microsoft has already
let go: nothing to hand back, nothing an admin can act on, so it clears
local state and logs at INFO — the ordinary end of a subscription's life,
which is warning 1 and was never a fault. Anything else — throttling, a
5xx, a dropped connection — has to assume the registration is still live
and hands it back before replacing it.

The asymmetry is deliberate: assuming it is gone when it is not orphans a
live subscription for three days, while assuming it is there when it is
not costs one DELETE that answers 404 and is swallowed.

unsubscribe() is called from renew() rather than left to subscribe()
because subscribe() bails before its own teardown when no public URL is
configured. Handing the old registration back matters even when a new one
cannot be built.

The test asserts the teardown was ATTEMPTED, not the account's final
state: both the fixed and the broken version end with the id null, so the
state says nothing at all. Verified by reverting the fix — it fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 09:19:33 +02:00
Paul LütznerandClaude Opus 5 f12dfee756 test(mercure): the dot test says it needs a hub instead of inheriting one
The v0.1.13-era CI run went green with one flaky: the topbar dot never
left its blank state within 15s, so `data-state` read null rather than
"connected" and the indicator looked broken.

The case before it stops and restarts the hub. `waitForHub` returns as
soon as its probe gets a 401, which proves the process is listening — not
that it is ready to carry a subscription — so this test arrived asking
for a stream from a hub still finding its feet, with no precondition of
its own. Every other case here that needs a live hub waits for one.

Unproven, and worth saying so: it went flaky once, on a 2-vCPU runner,
after 577 other tests, and has not reproduced. This is in because a test
that depends on a hub should say it does. No timeout was raised, and the
15s budget is untouched — if the hub is genuinely slow to serve a stream,
this still fails, which is the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 17:54:54 +02:00
Paul LütznerandClaude Opus 5 cbfca378f8 test(e2e): the suite reads the clock the page is rendered on
The v0.1.13 tag went red here, and none of it was the release.

WALL CLOCKS
Snooze times are computed in the BROWSER, deliberately — the server is
never told the session's zone — but the row is rendered server-side in
the user's zone, which falls back to Europe/Berlin. The spec then read
the hour back with Node's getHours(), which answers in the RUNNER's zone.

All three agree on a developer's machine, which is why this passed
eighteen consecutive local runs. On a GitHub runner the browser and Node
are UTC while the server is still Berlin: the mail came back at 18:00,
the row said 20:00, and the assertion reported the wrong TIME being shown
when the truth was the wrong CLOCK being read.

APP_TIMEZONE now names the zone rendered times are in, in one documented
place; the assertion converts into it rather than trusting the process.
And playwright.config pins the browser to it, so a menu that offers six
in the evening cannot produce a row that says eight — the divergence is
an artefact of an unpinned runner and no real user has it, since their
browser and their configured zone are the same place.

Reproduced before fixing: reverting the conversion under TZ=UTC fails
with the CI failure exactly.

TWO FIXTURES LEFT TO CHANCE, both found by running under TZ=UTC at CI's
two workers.

calendar.spec seeds calendars and never seeded a mailbox, but its pane
cases assert on the mail list beside the calendar. Those rows were
whatever the last spec on the worker left: bulk-select-all archives two
hundred messages and then clears them, after which `[data-row-layout]`
matches nothing and the failure reads as a broken container query when
there is simply no row to lay out.

admin-panels assumed the Processes panel starts open. Collapse is stored
per admin user, the admin is shared rather than per worker, and a run
that fails anywhere after the first toggle leaves it collapsed — so the
next run failed on its opening assertion, reporting a broken feature and
really reporting its own earlier failure. What collapsed it this time is
not established; the test establishes its own precondition now, which is
the part that was missing either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 17:06:29 +02:00
Paul LütznerandClaude Opus 5 d7b2334614 release: v0.1.13 — new means new, on every device
The marker was already account-bound; what it meant was not quite right. Mail
already read elsewhere arrived announcing itself as news you had had, a reply to
your own mail could never be new at all, and a phone coming out of a pocket
showed whatever was on screen when it went in.

All of it server-side, so the Android app gets this release without an app
update — it reads the marker over JMAP.

Behind that, a day on the E2E suite: nine fixes, two of them real product bugs —
a search dropdown that reopened itself after being dismissed, and a subject
threading lookup that let the query planner decide which conversation a reply
joined. Eighteen consecutive clean runs, and not one timeout raised to get them.

No migration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v0.1.13
2026-08-23 16:30:14 +02:00
Paul LütznerandClaude Opus 5 6c6b412367 fix(mail): break ties in the three orders that were not breaking them
ListSortOrder::applyTo() already carries the rule for the folder lists —
sort, then id — and says why: LIMIT/OFFSET over a non-deterministic sort
can show one conversation on two pages and another on none, and
lastMessageAt ties are ordinary in a mailbox rather than an edge case.
Three queries spell their own ORDER BY and so never got it.

The one that matters is findMatchingNormalizedSubjectThreadForAccount,
and not for pagination — it takes ONE row. Two candidate conversations
sharing a lastMessageAt left "which conversation does this message join"
to the query planner, so the same mailbox could thread the same message
differently on two runs. It was picking the OLDER of the two, which the
new test pins by id: of two equally recent conversations, the one started
later is the better guess.

findDueSnoozed ties by construction — snoozing a selection gives every
thread in it one wake time — and everything due is woken whatever the
order, but with a cap on the batch the boundary was arbitrary.

findForLabels is the paginated one, and the only one where a tie could
actually lose a row off a page. It has no callers anywhere in the
codebase; it is fixed rather than deleted because removing it is a
separate decision from correcting it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:28:32 +02:00
Paul LütznerandClaude Opus 5 9cef36a7ba test(snooze): the menu the click opened, not the first one on the page
openSnoozeMenu looked up
`[data-controller="mail--snooze-menu"]:not([hidden])` across the whole
page and took `.first()`. Every thread row renders its own copy of that
partial and the list toolbar renders another for the selection, so the
one it found was whichever came first in the document — not the one the
click had just opened. The caller then looked inside a closed menu
belonging to something else and reported the entry it wanted as missing:
"the snoozed row offers no way to cancel the snooze", for a feature that
was there all along.

`:not([hidden])` went with it, and was part of how this survived review.
The dropdown is opened and closed by class and never by the hidden
attribute, so the filter excluded nothing while reading like a check on
whether the menu was open.

Scoped to the row now, and asserted open before it is handed back.

What made it intermittent rather than constant is not established — the
seeder stamps each thread a minute apart, so it is not two rows tying for
position. The page-wide lookup is wrong either way, and with the menu
scoped there is nothing left for the ordering to change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:04:19 +02:00
Paul LütznerandClaude Opus 5 ca1ba9b7d3 test(compose): a wait that was already over before it started
"Reopening a scheduled draft offers to call it off" cancelled the hold
and then waited for the composer to come back — except the composer was
already open, from the click on the Drafts row further up, and never
closed. `toBeVisible` was satisfied the instant it was called and waited
for nothing at all, so the navigation that followed raced the cancel
request. On a loaded machine the navigation won, the list still carried
the hold, and the spec reported that calling off a scheduled send does
not work.

It waits for the POST now, and reads its status rather than assuming it:
a refused cancel answers just as promptly as an accepted one.

The two sibling cases are fine as they are and are left alone — both
cancel from a state where the composer is CLOSED, so the assertion that
it comes back is a real signal rather than a restatement of where things
already were.

Also: openSnoozeMenu no longer hands back a menu it has not seen open.
Callers look for an entry inside it straight away, so a click that opened
nothing surfaced as "the snoozed row offers no way to cancel the snooze"
— a sentence about a missing feature, describing a menu that was not up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:40:33 +02:00
Paul LütznerandClaude Opus 5 2871378f48 test(e2e): four specs that were relying on luck rather than on fixtures
All three found by running the suite repeatedly instead of once, which is
the only way this family shows up: nothing about the code differs between
the run that passes and the one that does not.

THE BOOKING RATE LIMIT
`booking_attempt` allows six bookings an hour per IP. The suite books
once per run from one address, so the seventh run inside the hour got
"That is a lot of bookings from one place" instead of "That is booked" —
and a CI retry is a second booking in the same hour. The limiter state is
cleared per test now, per test rather than per file because --repeat-each
would otherwise just move the refusal to the seventh repeat. Nothing in
the suite asserts throttling, so emptying the pool costs nothing.

THE SLOT COUNT
"One radio fewer than before" compares two page loads that need not be
showing the same week. The page offers the soonest week with anything
free, so booking the last slot of this week moves it on to the next: the
count went 1 → 110 and the spec called the double-booking guard broken
for doing exactly the right thing. It now asserts the BOOKED slot is gone
by value. (Down to one slot because the all-day event seed-grid-events
puts on today blocks the whole day for booking — an all-day event with no
freeBusyStatus is the owner's time.)

THE MISSING LABEL
Three confirm-dialog cases drive the label delete form, which only exists
if the user has a label; the spec seeded only mail and left that fixture
to whichever other file happened to make one first. They passed or failed
on file ordering, together, as "no confirmed action on this page to
exercise". They seed their own label now.

THE PREFETCH SLEEP
new-mail-marker hovered a link and waited 500ms for Turbo to prefetch.
Plenty on an idle machine; not plenty on a loaded one — and with no
prefetch in hand the click is an ordinary navigation, so the case stops
being about prefetching and fails claiming the badge survived. It waits
for the response carrying the header the server itself branches on,
bounded so a hover Turbo declines to re-issue cannot hang the test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:18:15 +02:00
Paul LütznerandClaude Opus 5 9d389a042e test(calendar): two specs that were asserting what day of the week it is
Both read as broken features and are the calendar being on a Sunday.

DRAGGING A BLOCK SIDEWAYS
The seeder puts the timed event on TODAY — it has to, because every case
in the file above it reads that event in the day view — so which column
it occupies moves with the calendar. The test dragged it one column
RIGHT, which on the last day of the week is off the end of the grid:
the controller has nowhere to drop it, the block stays put, and the
assertion fails as "data-starts-at did not change". Six days in seven it
passed, so the failure arrives looking like whatever was committed that
weekend. It now hit-tests which column the block is actually in and
drags away from whichever edge it is against, asserting ±24h to match.

THE BOOKED APPOINTMENT'S CHIP
`app:test:seed-grid-events` puts an ALL-DAY event on today and sets no
`freeBusyStatus`, which per BookingAvailabilityReader blocks the whole
day — deliberately, since that is the owner's time. So when a worker
runs calendar-timegrid before calendar-sharing, today is fully booked
out and the page's soonest free slot becomes tomorrow. On a Sunday
tomorrow is in the NEXT week, and the assertion looked at this one: the
chip is legitimately absent, the booking is perfectly good, and the test
reports the badge as broken.

Three conditions have to coincide — grid events seeded first, same
worker, last day of the week — which is why it was roughly one run in
three rather than every Sunday. The spec now keeps the chosen slot's key
and goes to `/calendar/week/{that date}`, so it no longer has an opinion
about which day the page offers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:01:58 +02:00
Paul LütznerandClaude Opus 5 a531482e38 fix(search): a dismissed suggestion list stays dismissed
The dropdown is drawn twice for one query: the operator matches come
straight out of the browser, and the server's live results drop in
underneath whenever they arrive. Both finish by calling `#settle()`,
which opened the dropdown whenever it had items — unconditionally.

So a response landing after the reader dismissed the list reopened it.
Escape closed it and it came back; the second Escape then hit a visible
dropdown and closed THAT instead of clearing the box, which is the
behaviour search.spec.ts was asserting. Clicking away has the same
mechanism through a different handler, and is the worse of the two: the
list springs back over whatever has just been clicked onto, with the box
no longer focused.

On a fast local connection the response beats the keypress, so nothing
is seen and the spec passed; the slower the connection, the more
reliably the suggestions come back by themselves. It surfaced as
"Escape closes the list" failing only in a loaded parallel run, which
reads as an infrastructure flake and was the bug reproducing.

Both tests hold the suggest response with `page.route` until after the
keypresses, so the ordering belongs to the test rather than to the
machine — and they wait for the response to be RENDERED before
asserting. Without that wait they passed with this fix reverted:
`toBeHidden()` is satisfied the moment it is called, so asserting
straight after the release only re-checked the state Escape had already
left behind. The live-results status line is the signal, because the
endpoint returns it whether or not it found anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:01:46 +02:00
Paul LütznerandClaude Opus 5 a8b792255a feat(mail): a "Reply" badge, escalating New for answers to your own mail
Asked for as an escalation of New: more visible, for new mail that answers
something you sent, and otherwise behaving exactly as New does. And correctly
guessed to need no schema change — though not for the reason it looks like.

**The badge.** `isAnswerAt()` is `isNewAt()` plus one condition: the
conversation carries the Sent role. Thread labels are the union of their
messages', so a thread you have sent into says so already, and combined with an
unread message having arrived since — which is what makes it new again — that IS
a reply. No column, no header parsing.

In-Reply-To would be the more literal reading and is worse: it is absent or
wrong often enough that clients thread on subject as a fallback, so a badge
keyed to it would go missing precisely on mail from correspondents whose client
is careless. The thread has already done that reconciliation once.

It is an escalation, not a third state. `data-thread-new` stays on the badge and
the row keeps `data-new="true"`, so every count, every dot and the display
report that retires it go on treating an answer as new. All that changes is
which of two labels the row wears, and the colour: `bg-success`, the one token
in the palette that reads as "good news arrived".

**And the gap underneath it, which is why this could not have worked before.**
`listedAt` was written only when a thread was CREATED. Nothing cleared it when a
message joined an existing conversation — so a reply landing on a thread
somebody had already looked at arrived permanently un-new, its marker retired by
a render that happened before the reply existed. The case the marker is most
obviously for was the one case it could not announce.

Unread mail joining a thread now clears it. Guarded on unread rather than on
direction, which covers both things that reach that branch looking like arrivals
and are not: a sent copy coming back from the server is claimed onto the row
plMail already had and was marked seen when it was sent, and mail already read
in another client is exactly what the previous commit stopped announcing.

Two time-dependent calendar flakes fixed on the way, both found by the clock
rolling over to a Sunday. A daily series seeded "today" has occurrences only on
the days the week has LEFT — five on a Monday, one on a Sunday — so
`toBeGreaterThan(1)` passed six days out of seven. The grid fixture is anchored
to the start of the displayed week now, and the repeating-event spec asserts in
the month view. Neither had anything to do with the day's changes, which is
precisely what makes that shape of flake expensive: it looks like whatever was
committed that day broke the calendar.

4034 unit tests, 555 browser tests, PHPStan clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 12:53:45 +02:00
Paul LütznerandClaude Opus 5 9ef6b8730c fix(mail): mail you have already read is not new, and returning to the app re-reads the list
Two reports from Android, and they turn out to be separate faults.

**"I often see new mail that's already marked as read."** True, and by design
until now: `isNewAt()` meant "never shown, and recent", with a test asserting in
as many words that "newness is not unreadness … that is the entire feature
rather than an accident of it".

That definition is coherent and it produced an interface nobody could read. It
is worth being precise about why, because my first explanation was wrong:
newness is NOT per client. `listedAt` is a column on the conversation, so
whichever plMail surface draws the row first speaks for all of them — the app
reports a display with `Thread/set { isNew: false }`, the browser POSTs to
/mail/threads/listed, and both write the same column. There is now a test
crossing the two surfaces to prove it, because neither surface's own tests could
make that claim.

The gap is mail read somewhere that is NOT plMail. The provider's own web
client, a phone's built-in mail app, Thunderbird — any of them sets \Seen, and
the next sync brings the message in already read while no plMail surface has
ever drawn its row. It arrives new AND read: a row that is at once not-bold and
badged New, telling the reader nothing they can act on. For anyone whose
provider has a web client that is the common case rather than an edge one, which
is what "often" meant.

Being shown a row was always a proxy for having seen the mail; having READ it is
the stronger form of the same statement, and the two only ever disagree in the
direction that misleads. So newness now requires unread as well, in the entity
and in the SQL predicate the counts use — one rule in two languages, and
drifting them apart would put a dot over a list with nothing bold in it.

The reverse is unchanged and still tested: retiring the marker says nothing
about whether anybody read it. The marker is a subset of read, not a synonym.
The old test is inverted rather than deleted, with the old reasoning quoted in
it, because it was a considered decision and the next person deserves to see
what changed and why.

**"New removal and read status don't update how they should."** Not the marker
— nothing refreshed on resume. A phone suspends a backgrounded browser: the
Mercure stream is dropped, the poll timer stops, and server-sent events have no
replay, so every change made while the app was away is missed. The pane
refreshed on becoming visible only when a sync event had arrived WHILE hidden,
and on a suspended phone there are none to arrive. Coming back showed the state
from before: mail read on another device still bold, badges another client had
already retired still on screen.

It refreshes on becoming visible now, full stop. _refreshList() already
coalesces bursts and defers while a write is in flight, so returning repeatedly
costs at most one fragment fetch per MIN_REFRESH_MS. The desktop was hiding this
behind a sixty-second poll, which reads as "slow" rather than "wrong" — which is
why it was reported as an Android bug.

Also fixed, and the cause of a flake that has been wandering around the suite
all day: `app:test:seed-bulk --clear` created the account it was asked to
remove. account() mints one when it finds none, so clearing a user who never had
a bulk mailbox left a second account behind — which changes what "the first
account" means on the settings page, and is how compose-signature-and-options
came to write a signature to one account and open the composer with another. It
now removes the account, as its help text has always claimed, and the spec
clears before as well as after so an interrupted run cannot poison the next one.

Verified both fixes fail without them. 4030 unit tests, 554 browser tests,
PHPStan clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 12:23:37 +02:00
Paul LütznerandClaude Opus 5 db47eef41e release: v0.1.12 — actions that can be taken back
Two write-throughs of a running instance found the same shape of bug in several
places: a label that could be put on and never taken off, an archive with no way
out, a bin with no way out. Plus select-all that means all of them, a preview
line made of what the reader would actually see, and a 404 in the reader's own
language and theme.

No migration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v0.1.12
2026-08-22 21:00:43 +02:00
Paul LütznerandClaude Opus 5 2549b7e019 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>
2026-08-22 20:09:32 +02:00
Paul LütznerandClaude Opus 5 155b421553 fix(mail): the snooze menu says which day, and the Snoozed list says when
Reported as "'Dieses Wochenende' stellt in die Vergangenheit zurück", tested on
a Saturday. It does not: the maths resolves to the FOLLOWING Saturday, which is
right — a snooze to a weekend that has already started is not a snooze. Checked
against the exact moment in the report before changing anything.

Two real faults produced that reading, and both are worth having found:

**The menu was not in chronological order.** The four options are rendered in a
fixed order, and on a Saturday or a Sunday "this weekend" has rolled to the next
one — so it sat ABOVE "next week", which is two days away. Someone reading down
and taking the first acceptable option got the furthest away. The docblock
claimed "soonest first" all along; it is true now.

**The label was a weekday and a time.** "Sa., 08:00" is unambiguous only inside
the coming week; eight days out it reads as this morning, which is exactly how
it was reported. Anything more than two days away names its date now, and today
and tomorrow keep the short form they are clearest in.

**The Snoozed list showed the arrival time.** Every other list shows that, and
it is the one thing this list is not about — there was no way to tell a mail
returning in an hour from one returning in a fortnight. It shows the wake time
now, in the accent colour so it reads as a different fact.

The other half of that finding — "there is no way to cancel a snooze" — turned
out to be false: the menu has always offered it on an already-snoozed row. It is
covered by a test now, along with the mail coming back to the inbox.

**A correction to my own earlier change.** The row's snooze menu was opted into
the top layer while fixing the label menu, and that breaks it: the option is
still the hit-target at its own coordinates, no console error is raised, and the
POST never goes out. The label menu is pinned the same way and works, so it is
not the popover API on its own. This menu was never reported as trapped, so it
goes back to what worked, with the finding written down rather than left as a
silent revert. The escape is opt-in precisely so one menu's fix cannot break
another; it did, and the opt-in is what made it recoverable.

Four test fragilities fixed on the way, all of them mine except the last:

- navigating straight after a click reads the list from before the write. Both
  new specs wait for the POST now; without it they fail as "the conversation is
  not there", which is indistinguishable from the feature being broken.
- an hour compared against a zero-padded clock: `8:00` never matches `08:00`, so
  the test passed all afternoon and would have failed every evening after six.
- `check()` on a `peer sr-only` checkbox lands without changing its state on
  about one run in three. Set-and-dispatch instead — the change event is what
  the controller listens for, and the subject of that test is the label menu.
- the app-password spec matched the masked hint across the whole list, which is
  a strict-mode violation as soon as one earlier run leaves a token behind. It
  is scoped to its own row now, and 34 orphans from aborted runs were purged.

Full suite: 573 passed, 0 failed. 4020 unit tests, PHPStan clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:30:21 +02:00
Paul LütznerandClaude Opus 5 86e96d3b51 fix(mail): the preview is what the reader would see, and Sent names the recipient
Two findings from the crawl of the running instance.

**The list preview was the sender's text/plain part, verbatim.** That field is
not a summary of the mail — it is whatever the sender chose to put in the half
of it most recipients never see. Two ways that goes wrong turned up in the same
inbox on the same day: most rows previewing as "Email is only available as html"
(which really IS the text part, written by a bulk sender), and one previewing as
`<p style="margin:0 0 16px 0;"> Hallo…` from a sender who put markup there. The
first was reported as untranslated English, and it is not a string of ours at
all.

The preview is made from the rendered body now, falling back to the text part
when there is no HTML. That fixes both without recognising either — there is
deliberately no list of known-bad sentences here, because the next sender to
write their own version of it has not been met yet.

From `bodyHtmlSafe`, never `bodyHtml`: stripping tags off raw sender HTML would
put the contents of a script or style element into a list row as text. Costs no
query — the row already hydrates the whole Message to read bodyText, so the HTML
is in memory either way. Block tags become spaces before the rest are stripped,
or the last word of one paragraph runs into the first of the next.

**Sent listed the sender.** In a folder of mail you sent, that is your own name
on every row — the one fact that tells no two rows apart. It was not even
consistent: rows read "ich" or the account's display name depending on whether
the participant list had learned one, so the same person appeared under two
spellings in one list. Drafts has always got this right ("An: …"), which is
where the answer came from, and why this is a scope flag on the existing row
rather than a second template.

Taken from the newest message rather than the thread's participants: a
conversation you have replied into contains everybody, and the question in this
folder is who THIS went to.

A functional test rather than a browser one, and the reason is worth recording:
reaching Sent through the interface means actually sending, and the browser
stack has no SMTP — the message would sit on the export queue and never arrive.
What is under test is what the row renders from a message that IS in Sent. The
inbox is asserted unchanged in the same file, because the failure mode of a
per-list flag is the wrong list answering "to" and replacing every incoming
sender with your own address.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 16:36:56 +02:00
Paul LütznerandClaude Opus 5 1afeec651b fix(compose): a compose URL opened as a page is a page
Reported from a crawl: `/compose/new` hit directly renders raw HTML — system
font, native buttons, and every state of the send pill at once as one run of
text. The window is a FRAGMENT by design: no <html>, no stylesheet, no
JavaScript, because it is built to land in the dock frame that already has all
three.

The paths that reach it are ordinary — a bookmark, a middle-click into a new
tab, the back button after a session expired, any Turbo fallback without
JavaScript.

Fixed without a second renderer. A GET with no frame behind it is redirected to
the mailbox carrying the URL it was trying to open; the dock frame fetches that,
and the frame's own request has the header, so the window is rendered by exactly
the same code path as every other route into it.

Three things that had to be got right:

**The frame parameter is dropped.** A reply's link carries
`?frame=compose_inline&thread=…`, and that frame only exists on the thread page
it was rendered from. Carried to the mailbox it names a frame that is not there,
Turbo has nowhere to put the response, and the composer silently never appears.
The dock is the right home for a bookmarked composer anyway. thread and replyTo
are kept, so the draft is still the reply it was.

**GET only.** A POST reaches the same method — the autosave and the send
re-render the window with validation errors and a 422 — and those are not
navigations. Answering one with a redirect turns "your recipient is invalid"
into a page load that loses what was typed. Seven controller tests caught this.

**The parameter can only name our own compose paths.** It becomes a `src` this
page fetches, so anything else would be an open redirect with the session
attached. Asserted against an absolute URL, a protocol-relative one, and an
unrelated internal path.

The fourteen controller tests that requested the window without the header now
send it, through a shared trait — they are testing the fragment, so they should
ask for it the way the dock does.

Also: two of my own new specs were sharpened after failing once in a parallel
run and passing in isolation. The label one scoped its menu lookup to the list
toolbar, since a thread pane renders its own single-target instance and which
comes first depends on what is open; the restore one matched "Delete" exactly,
because /delete/i also matches "Delete forever" — a locator that can resolve to
either of two buttons, one reversible and one not, is not one to leave in a
suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 16:26:43 +02:00
Paul LütznerandClaude Opus 5 0d1c89760f fix(mail): a label can be taken off again, and the archive has a way out
Two findings from the write-through of the running instance, both of the same
shape: an action that can be applied and not undone.

**A label put on from the list could never be taken off.** The bulk label menu
is shared by every row, so it renders with nothing ticked — and it decides
attach-or-detach from the tick (`attach = dataset.attached !== "true"`). A label
that WAS attached therefore showed as unticked, and the next click attached it
again. Clicking looked like a toggle and was an idempotent attach.

The single-target menu in a conversation never had this, because the server
renders `activeLabels` there, which is why it reads as working until you try it
from the list.

The menu now reads the selection when it opens. The rows carry their label ids
(`data-label-ids`) for it: the chips beside the subject cannot serve, because
they stop at three and carry no ids. Ticked when EVERY selected row has the
label, which is the reading that makes the next click do what the tick promises.
Gmail draws a third, indeterminate state for a partial selection; that is nicer
and is a different change.

**An archived conversation could be reached from no action anywhere.** Not the
row, not the thread toolbar, not the overflow menu, not the label menu — and the
one plausible candidate, Archive, was offered ON already-archived mail, where it
does nothing and says nothing about having done nothing.

MailPlacement knows about the archive now, so "move to inbox" appears anywhere
that is not the inbox, and restore() drops the Archive label as well — otherwise
a restored conversation would sit in the inbox and the archive at once.

Delete forever stays limited to the bin and spam. Not because it could not work
in the archive, but because archiving is ordinary filing and a delete-forever one
click from it has no undo to catch the mistake.

Both are pinned, and both tests were verified to fail with the right message
before the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:53:41 +02:00
Paul LütznerandClaude Opus 5 c848bc5474 fix(compose): the suggestion panel stops moving the dialog
The reported bug, fixed the other way round from the obvious one.

The dock window was content-sized under a 90vh cap, and the dock is anchored
bottom-right — so the suggestion list appearing grew the window and the whole
thing climbed the screen, then dropped back when the list closed. Typing a
recipient made the dialog jump.

Taking the list out of the flow is one line and is wrong. Subject sits directly
under To, so an overlaid list covers it, and a click aimed at Subject lands on
whichever suggestion is under the pointer and enrols it — which once silently
added a third party to a message addressed to someone else. Three specs exist to
keep that fixed and they caught the attempt.

So the list stays in the flow and the WINDOW stops resizing: a fixed height,
with the scrollable region absorbing the panel. The list pushes the body down
inside an outline that does not move, which is what the report was actually
about. Gmail's dock window is a fixed size for the same reason, and 90vh still
caps it on a short screen.

The new test asserts both halves together, because either alone is satisfiable
by breaking the other: a fixed window with an overlaid list passes "does not
move" and covers Subject, and an in-flow list in a growing window passes "does
not cover Subject" and moves the dialog. Verified it fails with the right
message when the height is taken away.

Full Playwright suite: 564 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:45:58 +02:00
Paul LütznerandClaude Opus 5 1e4924d754 release: v0.1.11 — the sandbox holds on the way out as well as in
One critical stored XSS (answering a mail ran what the sender put in it), one
high (an SVG attachment served inline), a CSP on the application document that
is enforced rather than merely reported, and security headers on a stock
install. Plus the send behaviour people asked for, a real delete, and the E2E
flake that turned out to be one bug in the test stack's session storage.

No migration. Upgrading is a pull and a restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v0.1.11
2026-08-22 14:47:51 +02:00
Paul LütznerandClaude Opus 5 d3e4eced03 fix: three corrections the suite caught, one of them a recipient bug
**The shared dropdown escape was too broad.** Sending every ui--dropdown menu to
the top layer fixed the ones trapped in a pane and broke the ones 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. It is opt-in now
(`data-ui--dropdown-escapes-value`), and the snooze menu on a mail row asks for
it because it opens inside the mail pane.

**The recipient dropdown is NOT reparented to <body>, and that is now written
down.** `dropdownParent: 'body'` fixes the reported symptom in one line — the
compose window grows to make room for the suggestion panel instead of the panel
overlaying it — and it reintroduces something worse. Subject sits directly under
To, so an overlaid panel covers it, and a click aimed at Subject lands on
whichever suggestion is under the pointer and enrols it. That is a real report:
it silently added a third party to a message addressed to someone else, and
three specs in compose-recipients.spec.ts exist to keep it fixed. They caught
this.

So the panel stays in flow and the window growing is the price, with the
reasoning in the code so the next person does not spend the same hour. The way
out is a layout change — the panel opening where it cannot cover the next field
— not a parent change.

**The session guard swallowed too much.** It cancelled every 401 and 403, not
just the redirect that would have swapped a login form into the shell, so the
compose dock's own "your session has expired, reload and sign in" — said in the
place the user just clicked — never rendered. Only the redirect is cancelled
now; a bare 401 travels on to whoever asked for it, who usually has something
more specific to say.

compose-safety's held-shape test sets its own setting and restores it in an
afterEach rather than an afterAll, so a failure there cannot leave the worker's
user on a setting the rest of the file does not expect.

Full Playwright suite: 563 passed, 0 failed. That is 563 rather than the 517 a
green run used to report — the exclusive project depends on the main one, so
every flake was also skipping those 19 specs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:39:14 +02:00