Commit Graph
13 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 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 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 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
Paul LütznerandClaude Opus 5 8c3080d00a fix(security): an SVG attachment is not an image, and a shipped install gets headers
CODE-REVIEW S-02 (high), S-04, S-08, S-05, S-06.

S-02 — three controllers decided what may render inline with
`str_starts_with($contentType, 'image/')`, and `image/svg+xml` satisfies it
while executing script as a top-level document. nosniff does not help: nothing
is mislabelled, the type genuinely is what it says. The type comes from the MIME
headers of an incoming mail.

Reachable from the mail itself. MailBodySanitizer::resolveCids() rewrites EVERY
`cid:` in the document rather than only `img src`, and the sanitiser allows
relative links — so `<a href="cid:logo">Rechnung ansehen</a>` has the part id
filled in by the application, and the click opens the attachment route without
the `?download=1` that every template adds.

Now an allow-list (InlineDisposition), which is the difference between
"everything not yet known to be dangerous" and "the formats actually wanted",
plus `default-src 'none'; sandbox` on the response — so a later widening of the
list is a bug rather than a vulnerability. Parameters are stripped before
comparing, because `image/svg+xml; charset=utf-8` would otherwise walk past the
one entry meant to stop it.

S-08 pulled along: the JMAP download had the same prefix test. Guarded today
only by the jmap firewall being stateless and wanting a header a browser
navigation does not send, which is a thin thing to depend on.

S-04 pulled along: the file-picker preview carried no Content-Disposition at
all — implicitly inline, in our origin, with a type chosen by Nextcloud, Immich,
Dropbox, OneDrive or Drive. A file an attacker drops in a shared folder is a
realistic shape for the setups these integrations exist for. A non-image now
falls back to the placeholder rather than to a download: a download prompt fired
by a thumbnail is worse than a grey box.

S-05 — the Caddyfile now sends HSTS, X-Frame-Options, nosniff, Referrer-Policy
and Permissions-Policy, and drops the Server banner. None of these existed
anywhere in the repository, so the ones a live instance showed were its
operator's proxy — while the README's own install instructions are `docker
compose up -d` and "open https://localhost". `defer`, so the per-response
policies on the attachment and image-proxy routes still win. Referrer-Policy is
the one that matters most for a mail client: it sends its users to strangers'
links all day.

S-06 — mercure subscribe defaults to [] rather than '*'. Never reached today,
since both cookie-minting paths pass explicit topics, but a future
createCookie($request) without a topic argument would have issued a subscription
to every user's mail events and looked entirely ordinary in review.

Also: mailpit removed from the dev override. MailerInterface is injected in no
service in src/ — plMail's own outgoing mail leaves through the user's account
via MailSenderRegistry — and the override never set MAILER_DSN either, so the
container has been catching nothing. Both configuration pages said the dev stack
pointed at it; they now say what is actually true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 12:16:02 +02:00
Paul LütznerandClaude Fable 5 9f9f2c9820 fix(backup): APP_SECRET and TRUSTED_PROXIES leave the export
Every other name left this inventory because it describes the machine.
APP_SECRET left because of what a restored copy would do.

Take stock of what this codebase derives from kernel.secret: remember-me
is the signature-based handler, so the sixty-day cookie is signed with
it; sessions and CSRF tokens die with the session; JMAP signs with the
JWT pems, whose contents travel separately; URI signing and uuid47 are
unused. Nothing on disk or in the database is encrypted under it -- that
is APP_ENCRYPTION_KEY's job, and it does travel.

So restoring it changed exactly one thing: a REMEMBERME cookie minted by
the source install kept verifying on the target. Both halves of that
check are in a v2 backup now that users travel -- the signature covers
the app secret and the password hash -- so a browser signed in to the
machine the file came from was signed in to the machine it was restored
onto, without anybody deciding that. Cookies are not scoped by port, so
two stacks on one host hand each other's browsers straight through.

TRUSTED_PROXIES goes for DATABASE_URL's reason instead: the addresses
whose X-Forwarded-* headers this install believes are a fact about what
sits in front of THIS container. KeptDeliberately and External
respectively, so old backups carrying either classify and stay inert.

Found while reproducing a 403 that turned out to be a double submit of
the 2FA form (previous commit). The stale-cookie hypothesis was
falsified as the cause of that -- a pre-restore remember-me cookie was
correctly challenged for a second factor, it did not bypass anything --
but reproducing it is what showed the cookie surviving the restore at
all, which is the property this commit removes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 14:49:39 +02:00
Paul LütznerandClaude Fable 5 d1739e6c96 fix(backup): MERCURE_JWT_SECRET leaves the export
It pairs the app with the hub container beside it, and the hub reads it
only at container start -- so a restored value re-keys one half of a
running pair and every EventSource dies until the whole stack restarts,
which is how this was found. Old backups carrying it classify External,
like the DSNs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 14:26:14 +02:00
Claude Fable 5 03a0d6b877 A config backup knows its own operator
Three changes to the config-backup subsystem, made together because they
land on the same two pages and the same document.

**Users, and everything they configured, join the backup.** Restoring onto
a fresh install used to end at "this installation still has no users —
create the administrator account to finish", which is a backup of a server
rather than of an installation. Document version 2 adds a `users` section,
keyed by email because that is what an import matches on. Per person: the
password hash, the TOTP secret and its recovery codes, app passwords, mail
accounts with their IMAP/SMTP and OAuth credentials, aliases, per-user
integrations with their secrets, filters, labels, calendars and the share
links and booking pages whose token digests keep published URLs working.

Everything encrypted at rest leaves decrypted inside the envelope and is
re-encrypted under the target's key on the way in, exactly as the database
section already did — that is the only way a credential survives a move to
a host with a different APP_ENCRYPTION_KEY. Deliberately absent: trusted
devices and push subscriptions (grants to one browser or one phone, and a
trusted device is a skipped second factor), label bindings and sync state
(re-derived, or about a host that is not this one), avatars and background
images (filenames in a volume this file does not carry), and mail, calendar
entries and contacts, which have not moved an inch.

Import policy: a user this install does not have is created whole; a user
it does have is left completely alone, whole. Skipping is the design and
not a caution — a backup is a photograph of a moment that has passed, and
applying a stale one to a live account resets today's password to
February's with no undo and nobody told. Soft-deleted users count as
present. The ids inside a filter, a calendar or a booking page name rows in
the *source* database, so the restore builds a map as it creates rows and
rewrites every reference through it; a reference to something the file did
not carry is dropped rather than guessed at.

The /install restore page now has two endings and asks the guard which one
it is in: sign in as somebody the file restored, naming the administrator,
or — for a document that carried no users — create the administrator as
before.

**MAILER_DSN and MESSENGER_TRANSPORT_DSN leave the export.** Machine-local
deployment choices that compose supplies a default for, in DATABASE_URL's
category: the target's relay and queue are the ones that match the
containers running beside it. Old backups carrying them classify as
External and stay inert.

**The restore result page stops listing non-tasks.** A restored value
already identical to the live one is dropped from "These are yours to do"
and counted in one muted line; the APP_ENCRYPTION_KEY block becomes a
collapsed disclosure that still carries the line the one operator who needs
it came for; and when nothing is actionable the page says so and puts the
way out first.

Two robustness fixes fell out: planning no longer hydrates every user to
build a comparison (per-email now), and neither the existence check nor the
comparison can 500 a review page on an install whose encryption key was
rotated without re-encrypting.

No schema changes — the backup is a file format, not tables.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 14:02:15 +02:00
Paul LütznerandClaude Fable 5 7e3ba2b9c0 Reset the topbar globals per request, stop exporting the database's own keys, and offer a password
Three fixes riding one pass:

LogAlertGlobal memoised per request and was reset per request only under
PHP-FPM. FrankenPHP keeps the service alive, so the first badge a worker
computed was the badge every later request got - an emptied log table
with a yellow outline that survives refresh, seen in the wild. All four
stateful Twig globals now implement ResetInterface like InviteReader
always has; SidebarCounts and CalendarGlobal could additionally leak one
user's counts into another user's topbar.

POSTGRES_PASSWORD, postgres_password and DATABASE_URL leave the backup:
machine-local, initdb-consumed, never applicable on a restore target.
Old backups that carry them still import - recognised and classified
external, not silently dropped.

The export form gains an optional generate button: fills both fields,
puts the password on the clipboard and says so; where the browser
withholds its clipboard the fields unmask instead, because a masked
password nobody was told is not a backup anyone can open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 18:45:21 +02:00
Paul LütznerandClaude Fable 5 567db06844 Restore a config backup by writing it, not by printing it
The import classified every environment value as unwritable and handed
back two dozen lines to paste into .env.local. That was built on a
premise that is wrong for the way plMail is deployed: nobody hand-edits
those. frankenphp/generate-secrets.sh mints them on first run into
var/secrets/generated.env, every service mounts the volume that file is
on, the entrypoint loads it before it execs the server, and the app
process can write it. So the import writes it.

What the entrypoint actually promises, and what everything here follows
from: a real environment variable wins over the generated file, which
wins over .env.local and .env, with an empty value counting as absent at
every level. The shell (load_generated_secrets) and PHP
(config/bootstrap_generated_secrets.php) apply exactly the same rule.

ConfigBackupObstacle — "why can plMail not write this" — becomes
ConfigBackupDisposition, "what becomes of this": applied,
applied-on-restart, shadowed-by-compose, external, kept-deliberately,
not-writable. The restart instruction is now one sentence for the whole
plan rather than one line per value, because it is one action.

Three fates survive, each forced by the deployment rather than by
caution:

  - POSTGRES_PASSWORD, the postgres_password file and DATABASE_URL stay
    external. generate-secrets.sh does rewrite that file from
    generated.env on every boot, but the Postgres image reads
    POSTGRES_PASSWORD_FILE only at initdb — on a database that already
    exists the role keeps the password it was created with, so writing
    one without the other is a stack that cannot authenticate.
  - APP_ENCRYPTION_KEY is kept on purpose: the import re-encrypts the
    backup's credentials under the key in force here, and swapping it
    underneath them would make the rows it just wrote unreadable. It is
    a note now, not a chore — the line is still offered for the operator
    who is also restoring the old database.
  - A name pinned non-empty in the process environment beats the
    generated file at the next start. Detected by comparing the live
    value against what the file holds, because the entrypoint exports
    that file's own contents and "is it in getenv" distinguishes
    nothing. On the stock compose.yaml that is three names.

The /install path is the one this was for: upload and password are the
whole job, everything lands in this instance's own secrets file and
database, and the page asks for one restart instead of a wall.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 18:28:22 +02:00
Paul LütznerandClaude Fable 5 1adac1fe40 Carry an instance's configuration in one password-sealed file
Export from the admin area: env secrets, the JWT keypair and the
database-held provider credentials - FCM included, decrypted inside the
envelope because the restore target holds a different encryption key -
sealed with Argon2id and secretbox at parameters a Raspberry Pi can
afford, in a documented format a php one-liner can open without plMail.

Import shows its plan before touching anything: what it can write is
written and re-encrypted under this instance's key, what it cannot -
process environment, the Postgres role's own password - is rendered as
the exact lines to place by hand. The install wizard offers the same
restore before the first account exists, behind the same guard that
closes /install forever afterwards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 19:12:21 +02:00
Paul LütznerandClaude Opus 5 b4e3cb6afb Correct what translating the handbook found wrong in it
Six defects, all found by somebody reading a page closely enough to
render it in another language. Each is fixed in English and in German
together, and the German markers are re-hashed only where the German
prose now says what the corrected English says — re-hashing alone would
mark a stale translation as current, which is the one thing that check
exists to prevent.

  calendar-model.md      claimed the materialiser honours "exactly four
                         things in a patch" above a table of five, and its
                         traps section named a different four. The truth
                         is five keys read, of which four are the patch
                         EventInstanceEditor writes; `excluded` is not a
                         patch field at all and is spelled by
                         RecurrenceRuleConverter::exclusionOverrides(),
                         because the one override value whose only job is
                         to be exactly right should not have two places
                         that must be right about it.

  CLIENT_DEVELOPMENT.md  said snooze was "not exposed over JMAP today" and
                         offered itself as the canonical "ask, don't work
                         around" case — while Thread/set has been
                         implementing exactly that. Somebody asked. Its
                         methods table was also missing Thread/set,
                         SearchSnippet/get and all four calendar methods,
                         which the same page announces two screens above.

  install/docker.md      described the missing blob volumes as a live
                         defect. They were added to compose.yaml hours
                         after that page was written — by the report that
                         found them — and nobody updated the page. It now
                         documents ten volumes and keeps the story as
                         history, with a pointer to the CHANGELOG note for
                         anyone upgrading past it.

  providers/imap-smtp.md named a form label no interface shows. The
                         hardcoded labels in AccountType are overridden by
                         the Twig template, so the fields are "Email
                         address" and "Username".

  settings.nav.calendars had no German, so one item in a German settings
                         menu rendered in English.

  CONTRIBUTING.md        said "four translators" where there were six.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:41:59 +02:00
Paul LütznerandClaude Opus 5 4d103cfbdf Write the handbook, and make it fail the build when it goes stale
README says what plMail is and gets you running; CONTRIBUTING says how to
change it. Nothing covered the part in between — using it, operating it,
and registering the Google and Microsoft applications it talks to. That
is docs/: 34 pages across features, installation, providers and
internals, mirrored to the GitHub wiki.

The wiki is a mirror and never a source. bin/mirror-wiki.php flattens the
tree into the wiki's flat namespace, rewrites every relative link,
generates the sidebar from the index so the two cannot disagree, and
stamps each page with a footer saying where it came from. A browser edit
survives until the next push to main. That cost is deliberate:
documentation not versioned with the code drifts from it, and the drift
is invisible until somebody follows an instruction that stopped being
true.

Which is the whole problem, so the checkable half is checked.
DocumentationCoverageTest fails the build when a command is missing from
the reference or documented and gone, when a variable in .env is absent
from the configuration page, when a relative link dangles, when a page is
unreachable from the index or promised and missing, or when a page has no
"Things that bite" section. It cannot check whether a paragraph is TRUE,
and says so — this is the floor, so review attention goes to the part a
machine cannot have an opinion about. CONTRIBUTING has claimed since it
was written that the command table "is part of the definition of done";
nothing enforced it until now, and it was wrong in three places.

Writing it found things. They are fixed here rather than documented
around, because a handbook describing a bug accurately is still a
handbook nobody can follow.

  compose.yaml            mounted no volume for var/attachments, var/raw
                          or var/uploads. Workers write those during sync
                          and the web container serves them back, so each
                          kept its own copy in its own layer — downloads
                          404, and everything died on a recreate. The
                          override and the TrueNAS file both mount them;
                          the file the README tells an operator to run did
                          not. The same omission cost real data once
                          before. See the CHANGELOG for moving what is
                          currently stranded.

  app:calendar:materialise  new, nightly. Occurrences are drawn when an
                          event is saved and nothing moved that window
                          again, so a weekly standup ran out of dates in
                          two years and its reminders stopped with them —
                          silently. The query it runs had existed since
                          the materialiser did, documented as exactly this
                          sweep, and had never been called.

  Microsoft wizard        asked for three of the nine permissions plMail
                          requests, and a token already issued is not
                          upgraded when the rest are added later.

  GmailWatchService       told you to grant Pub/Sub Publisher to
                          gmail-api@system.gserviceaccount.com. The
                          account is gmail-api-push@; a topic granted
                          exactly what the docblock asked for never
                          delivered a notification.

  IcsExporter             said it wrote no VALARM. It has written them
                          since alerts landed.

  CLIENT_DEVELOPMENT.md   said there was no JMAP calendar API and that
                          SearchSnippet/get was absent. Both shipped.

  CONTRIBUTING            said only app:calendar:push registers a push
                          channel; subscribing has done it since the
                          subscribe flow learned to.

  Three docblocks         named getters removed long ago, or described a
                          method two below the one they sat on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:13:25 +02:00