mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
748e1cdcdfac01097e19897beb1b21691c1d66c0
709
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
d51c3c8475 |
release: v0.1.21 — events open to be read
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>v0.1.21 |
||
|
|
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> |
||
|
|
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>
|
||
|
|
debf773384 |
release: v0.1.20 — the badge and the list agree again
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>v0.1.20 |
||
|
|
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> |
||
|
|
986e0f663d |
release: v0.1.19 — a demo that has been lived in
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>v0.1.19 |
||
|
|
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>
|
||
|
|
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> |
||
|
|
0dcdc13669 |
release: v0.1.18 — a demo anyone can walk into
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>v0.1.18 |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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 |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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 |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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 |
||
|
|
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> |
||
|
|
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> |
||
|
|
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 |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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 |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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 |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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 |
||
|
|
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> |