Commit Graph
63 Commits
Author SHA1 Message Date
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 Opus 5 4690f9d9ea feat(insight): a strip of what is coming, above the mail list
Up to three dated insights as a band over the list — a parcel out for
delivery, a flight tomorrow — each with what it is, when it is, and the
one button worth having. The facts were already being read; until now
the only place they surfaced was a panel somebody had to go and open.

  InsightPane        the whole visibility rule in one place, because
                     three readers need it: the fragment, the dismiss
                     endpoint and the tests. Empty means the strip does
                     not exist, whichever of the three reasons applies.
  InsightPaneCtrl    the lazy fragment and the dismiss. Re-stamping the
                     dismissal is deliberate and the opposite of
                     InsightActionsController::dismiss — that records a
                     fact about one card, this records a position on a
                     moving list.
  InsightNotifier    insights.changed on mail/user/{id}, published after
                     the flush and once per user per batch, so a tab that
                     comes to read finds rows that are actually there.
  _pane.html.twig    renders NOTHING when there is nothing — no heading,
                     no empty box, no height.

Dismissing the band means "not now": it returns when an insight it has
never shown is extracted, which is decided against createdAt and not
updatedAt. A carrier revising an estimate rewrites a row the user
already waved away, and reading that as news would bring the strip back
for the whole of a parcel's journey.

HappeningSoonController rejected a panel in the mailbox, in writing, for
two costs: width taken from panes that have none to give, and a query on
every mailbox load for a list that is usually empty. Both are answered
rather than ignored — the strip takes height, arrives in a lazy frame,
and renders nothing when empty — and that controller's doc now says so
instead of contradicting what ships beside it.

One line of this is not in either worker's scope and is the thing that
would have shipped silently broken: core--mercure drops any update type
it has no branch for, so insights.changed arrived at the hub, was
delivered, and reached nobody. The branch is there now, and the method
says out loud that a missing one fails quietly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:40:05 +02:00
Paul LütznerandClaude Opus 5 4cfeee9ab5 feat(mail): the unread badge opens the mail it counted
Asked for as "a click on the unread badge should show a view from that label
with all the unread mails". The badge already knew the number; what it could
not do was take you to it.

Clicking one now opens that same view with `?unread=1` — the inbox, Starred,
Archive, Spam, Snoozed, or a label, under one account or across all of them.
A filter on the view rather than a route of its own, exactly as `?account=`
is: the account narrowing, the sort order and the tab all have to keep
working underneath it, and a second route would have had to reimplement each
of them.

Deliberately NOT a link into search, which was the cheap way to do this and
would have been wrong at the edge that matters. Search can already say
`label:Work is:unread`, but it has no `account:` operator — and the folder
rows under an expanded account count within THAT account on purpose, because
a badge promising more than the list beside it shows is the bug
forLabelInAccount() exists to prevent. A search link would have reintroduced
it for exactly those rows.

## The counts had to change first

They were SUM(t.unreadCount) — unread MESSAGES — while every list in the app
draws one row per CONVERSATION. Harmless while the badge was only ever read.
Not harmless once it is the thing you click to see that mail: "Inbox 4" would
have opened three rows, which is the Trash "188 unread against a list of 193"
again, one click apart instead of one glance.

So role, per-label and the LABELS roll-up all count conversations holding
unread mail now. countUnreadForStarred() already did, so this also ends a
disagreement between the badges themselves — Starred counted conversations
while everything else counted messages, and nothing said so. The handbook
already described the roll-up as "a count of conversations", which was not
true when it was written and is now.

UnreadFilteredListsTest asserts the count and the list AGAINST EACH OTHER
rather than against fixed numbers, on fixtures whose threads hold several
unread messages each — the one shape under which the two rules differ, so a
revert to summing fails rather than passing by luck.

## The badge is a real link

Which is why the system rows are a .nav-item DIV wrapping their anchor now,
the shape the label tree and the account folder rows were already built in —
an <a> inside an <a> is not markup that survives parsing, and the row would
have kept whichever one the parser chose. The alternative, a span with a click
handler, would have shipped a feature keyboard users simply do not have.

data-nav-tier rides on the LINK rather than on the pill, and that is not
cosmetic: the tier is padding-block, padding on the wrapper is area the anchor
does not cover, and the row would have looked 36px tall while answering a
click on 20 of them. The wrapper takes its height from the link, so the
geometry is unchanged to the pixel — density.spec.ts measures the anchor and
still reads 36 / 48 / 36.

Trash and Drafts stay spans: those badges are totals, so there is no unread
question being asked. So does the LABELS roll-up, which stands in for several
lists and has no single one to open.

A narrowed list says "Unread only" with "Show all" beside it, in the shared
toolbar rather than in six view templates — a filtered list and a genuinely
quiet one look identical otherwise. The way back is this route rebuilt from
the request rather than named, so route parameters and query parameters both
survive it; only `unread` and `page` are dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 15:04:53 +02:00
Paul LütznerandClaude Opus 5 6125cc3a32 fix(gmail): a relabelled message reaches the tab it was moved to
Gmail classifies after delivery, and re-classifies every time the user drags
mail between tabs, so CATEGORY_* comes and goes on rows plMail has held for
months. Re-fetching exactly those rows is what the relabel sync exists for —
and enrichExisting() overwrote gmailLabelIds, the only signal the categorizer
consults on a Gmail row, without asking what those new labels now mean.

So the category kept whatever ingest computed on the day the mail arrived. The
drift was visible from two directions at once: the reading pane explains the
category live, from the labels, while the inbox tabs filter on the thread's
stored column. A relabelled promotion said "Gmail said so: CATEGORY_PROMOTIONS"
in the reading pane and sat in Primary in the list, until the next
`app:backfill category` run happened to agree with it.

The row is recategorised in the same breath as the labels that decide it, and
then re-adopted onto the thread — the tabs read the thread, not the message, so
the row alone never reaches them. An empty correspondent map is passed on
purpose: gmailLabelIds is non-null by then, so the categorizer takes its Gmail
branch and never reaches the correspondence override that map feeds.

MessageThreader::refreshCategory() is the other half of recordActivity(), which
could not do this job: it adopts only while moving lastMessageAt forward, and a
message being re-read is already counted in that key, so its gate never opens.
Most-recent-wins instead — adopt when this message IS the newest the thread
has. Ties adopt, matching the backfill's DISTINCT ON, which picks between two
rows of equal received_at without ceremony either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:08:25 +02:00
Paul LütznerandClaude Opus 5 73defad98d fix(insight): read the parcels Amazon actually announces
Two mails reported through the new report button, and they failed for
four different reasons — three of them in the gate and the parse, one in
a place nothing was looking.

  supports()   "Dispatched:" is how Amazon says it in English and the
               shipping words had only "versandt" and "shipped", so the
               second mail was refused before extract() ever ran. The
               stems are shorter now: versand, versend, dispatch.
  extract()    Amazon states no tracking number anywhere. Identity is the
               order number plus which package of the order this is, read
               out of the progress-tracker link, and the card links to the
               bare form of that tracker. Only when the carrier hunt came
               back empty, so a merchant mail quoting a real carrier
               number is still read as that parcel and not as both.
  status()     the body's progress bar renders to plain text as all four
               of its stages — Ordered, Dispatched, Out for delivery,
               Delivered — in every mail of the series, so scanning the
               body answered with the last stage the parcel will ever
               reach and called a dispatched parcel delivered. The subject
               carries exactly one stage and is asked first now.
  eta()        "Arriving today", "Arriving Monday". The announcing word
               was found and the date parse then wanted digits. Days named
               in words resolve against the mail's own arrival, never the
               clock, so a backfill lands where it landed before.

The four regressions are the reported mails themselves, kept close to
verbatim, plus the two readings they made possible to get wrong: a second
package of one order must not overwrite the first, and an order number
from a sender that is not a shop stays an order number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:08:03 +02:00
Paul LütznerandClaude Opus 5 e74fa116e5 docs: the handbook learns about reported mail and three new shapes
The radar section gains the three new sources and the one that behaves
differently from the rest — a one-time code carries an expiry rather than
an occurrence, and carries no time at all when the mail states none.

Reporting gets its own subsection in both languages, and it leads with
what reporting costs: the report carries a copy of the mail, that copy is
readable and downloadable by an administrator, and on an installation you
do not run yourself that is worth knowing before you press the button.
Admin gains the panel, the export and the reason downloading marks
nothing handled.

Two traps added per page, both things that actually bite: reporting hands
over a copy of the mail, a code with no stated lifetime gets no expiry,
and the export — unlike the config backup — is plaintext mail.

German re-hashed from the English it was made from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 19:45:44 +02:00
Paul LütznerandClaude Opus 5 9eb6cbfcd7 feat(jmap): the mark's colourway goes over the wire
`LogoStyle` has been a per-user setting since the mark got a catalogue —
thirty-two colourways, read by the topbar, the settings tiles and the
favicon route — and it has never had a wire representation. A client that
was not the browser could not know which mark the account was wearing,
which is exactly the gap `Appearance` exists to close for the theme. The
Android client wants it to choose a launcher icon.

`logoStyle` now sits on the Appearance object beside `theme` and
`layout`, and its value is the enum's own wire string.

── the effective mark, not the column ──────────────────────────────────

What is published is `effectiveLogoStyle()`, not the stored column, and
that is the whole decision here. The mark follows the THEME unless the
user has unlinked it (`logoLinked`, on by default), and every colourway
is also a theme name — so somebody on the ocean theme has an ocean mark
in the topbar and a stored column still saying berry. Publishing the
column would have a phone draw a different mark from the browser for the
same account: the disagreement between surfaces that this object was
built to end, reintroduced by the one property added to describe it.
Every reader in the app already goes through effectiveLogoStyle(); the
wire is now one more.

── read-only, and why that is not a permission ─────────────────────────

It is not settable, and this is the shape that makes it so rather than a
list it happens to be absent from. Being in `PROPERTIES` is what makes a
property readable by NAME — Appearance/get validates its `properties`
argument against it — and it is also what Appearance/set accepts, so a
property added there and nowhere else is silently writable, with no
validation at all in the bargain. `backgroundFile` already had the
answer: name it in the set method's server-set branch, where an echo of
the current value is accepted (get → edit one field → set has to work)
and a different value is refused with `invalidProperties`.

The refusal is honest rather than defensive. `logoStyle` on the wire is a
function of three stored fields, two of which are not on the wire at all,
so a patch naming it says nothing this server could act on — and while
the mark is linked, any value it did store would be overwritten by the
theme on the next read. Setting `theme` moves the mark, and the new
colourway comes back in that call's `updated` map, which is what RFC 8620
§5.3 asks of a property the server changed beyond what was asked.

There is one trap the echo allowance walks into, and it is the reason
this is not just a third name in an existing condition. `id` and
`backgroundFile` are keys `Appearance::applyArray()` has never heard of,
so echoing them falls on the floor by itself. `logoStyle` is a key it
KNOWS, and it writes it onto the stored column. A client doing get → edit
theme → set — the flow the echo exists to permit — would hand back the
effective mark and quietly overwrite an unlinked user's own choice with a
copy of their theme's name, from an edit they never made to a field no
client can see. So the key is dropped before `applyArray()` sees it, and
there is a test standing on that specific loss.

── the session hint, and what went into it instead ─────────────────────

Not in `COMPACT_PROPERTIES`. The Session's `state` is a hash of the
user's account ids and does not move when an appearance changes, so
everything in that hint can be stale for as long as a client keeps its
Session. That is the right trade for theme, layout, accent and density,
where being briefly wrong costs one frame of the wrong palette and the
first `Appearance/get` repaints it. It is the wrong trade for the mark:
the client this was added for turns it into a launcher icon, which is not
repainted on the next frame but committed to outside the app, where a
wrong value sits on somebody's home screen. A value you commit to comes
from the authoritative read, and that read is one call.

The VOCABULARY does go in, as `logoStyles`, and for the stronger form of
the reason the other vocabularies are there. Those are published so a
client does not discover a closed set by being refused; a read-only
property is never refused, so its set cannot be discovered at all — a
client mapping the thirty-two onto assets of its own would have no way to
know when it is holding a word it has nothing for. A list of enum cases
is also the one thing in that capability that cannot go stale.

The handbook says all of this on both sides, including that an
unrecognised value should fall back to the default rather than read as an
error, since the catalogue grows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 13:47:07 +02:00
Paul LütznerandClaude Opus 5 c53222e599 fix(mail): a blocked image is no longer a square
Every blocked remote image was drawn as tall as it was wide. A 600x80
banner became 600x600; a newsletter became several screens of hatching.

The placeholder was a 1x1 transparent GIF, and a 1x1 has an intrinsic
ratio of 1:1. The reading frame styles images `height: auto` so a wide
table cannot force a scrollbar into the pane, and CSS beats the `height`
ATTRIBUTE, which is only a presentational hint. So the sender's declared
height was discarded and the height was computed from the placeholder's
own square ratio. The best-formed case — width and height both declared,
which is how marketing mail is written — was the broken one.

Measured in a browser, before and after:

  width=600 height=80      600x600  ->  600x80
  width=600 height=1200    600x600  ->  600x384   (ceiling)
  width=600, no height     600x600  ->  600x128   (ceiling)
  nothing declared          12x12   ->   12x12

A declared image now gets an SVG placeholder carrying the sender's own
viewBox. An SVG with a viewBox and no width or height has an intrinsic
RATIO and no intrinsic size, which is exactly and only what is wanted:
`height: auto` then computes the sender's height from the sender's width,
at whatever size the pane has room for.

Two ceilings on top, because a placeholder exists to say an image was
here and not to reserve every pixel of one. A box shaped from the
sender's own numbers may grow to a screenful; one whose shape nobody
declared stays small, since there is nothing there to be faithful to.
The cost is a jump when un-blocking an image taller than a screen.

Deliberately not shaped: percentages and mixed units, because a ratio
needs two numbers in the same unit and "50% wide, 80px tall" is two facts
about different things; and absurd magnitudes, because those digits reach
a viewBox and that is arithmetic a stranger's mail does not get to pick.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 12:03:37 +02:00
Paul LütznerandClaude Opus 5 14b8da9801 feat(motion): the list stops sliding and starts cascading
list   slide-down, 30ms, 48px, staggered 16ms

Only the message list. The new-mail row keeps its 600ms drop and the gap
still opens over 200ms with a 200ms handoff; nothing else moved.

Thirty milliseconds is under two frames and that is the whole idea. At
this duration the travel is not a slide — each row spends longer PARKED
at its start state, held there by the backwards fill through its share of
the stagger, than it spends covering the distance. What the eye follows
is the order rather than any row's journey, so the list unrolls instead
of fifty things sliding.

Which makes listStagger the animation rather than a garnish on one, and
it gets its own number for the first time: 22ms is a grace note on
something you can already see, 16ms is the thing you see. The eight-row
cap earns its keep here more than anywhere — fifty rows would otherwise
take four fifths of a second, capped they take about 150ms whatever the
length.

Minimal no longer scales from base(): Full is now shorter than the
ordinary duration, and a Minimal tier that took LONGER than Full would be
a tier that means nothing. It keeps the 30ms and drops the travel and the
cascade, which is what Minimal means everywhere else.

── The direction change cost something, and it is not only a test ──────

Going from slide-right to slide-down broke a spec that passed at 600ms,
and the reason is worth writing down. A row waiting its turn is parked
48px above its place, and a parked row is a STILL row — so hover() is
happy to aim at it. A moment later the row drops and the pointer is over
its neighbour: group-hover stops matching and the action the hover was
meant to reveal never appears.

The horizontal entrance could not do that. A row moving sideways keeps
its own band of the screen; a row moving vertically hands its position to
the row above it. So the moving-target window the class comment already
documents is now ~130ms rather than 600ms, and it can land you on the
wrong row rather than on empty list.

Specs that hover immediately after a load wait for stillness, with that
reasoning at the site rather than in a commit nobody will find.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 11:47:59 +02:00
Paul LütznerandClaude Opus 5 c41a68876e feat(motion): a whole list arrives as its rows, one after another
Your configuration from the console, and the promise it forced us to
rewrite.

  list   slide-right, 600ms, 48px, staggered 22ms

The list itself no longer animates. A grey rectangle fading tells you a
rectangle changed; fifty rows coming in from the left tells you what
changed. That needed a third verb, because a row cannot carry both of
its entrances in the markup — the long drop that means ONE new mail, and
the group entrance that means a folder change — and which one it is
doing is decided at runtime, not when the template was written:

  data-enter          animate me
  data-enter-new      animate me the first time you see this id
  data-enter-children animate my children, like this, not me

Whoever gets there first wins. A row reached as part of a list is marked
as spoken for, so its own entrance cannot fire on top.

slide-right did not exist in plMail — the console had it, the app did
not. Added at twice the travel of the vertical entrances: six pixels
sideways reads as a twitch, because there is no rest position the eye
expects it to fall to.

── The promise ─────────────────────────────────────────────────────────

MotionLevel opened by promising that an entrance never gates
interaction — "a row is clickable while it is still fading in". That was
written when nothing travelled more than six pixels. At 48px it is not
true, and three unrelated specs found it before anybody had to: two
clicked a row's select box and hit the sidebar behind it, one measured a
phone layout mid-slide and reported a badge at x=-14.

The promise is withdrawn rather than quietly broken, and what replaces it
says exactly what is exposed: a row keeps its width and its line, so the
middle of it is always safe; the two ends travel. Nothing is made inert
and no click is swallowed.

The bound on it is better than a promise anyway. Noticing a list has
changed and choosing where to click takes longer than the animation, and
for anyone it does bother, Minimal removes every pixel of travel and None
removes the animation. That is what the setting is for: a motion budget
nobody can opt out of has to be timid, and one they can opt out of does
not.

Tests that assert about POSITION now wait for stillness first, through a
shared helper — tests/e2e/support/motion.ts — which documents both ways
this bites so the next one is a lookup rather than an afternoon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 11:17:08 +02:00
Paul LütznerandClaude Opus 5 4c7752006b feat(motion): new mail drops into a gap the list opens for it
Your configuration from the motion console, made real.

  mail row   slide-down, 600ms, 48px, spring
  room       200ms
  handoff    200ms
  overflow   the departing row fades where it stood

The row is now the only surface in plMail with numbers of its own, and
MotionLevel says why at length: it is the only animation here carrying
INFORMATION rather than reassurance. Everything else says "this is here
now", which the eye needs briefly and then resents. This one says "this
one is new", and it is affordable at that length for two reasons that
both have to hold — it is rare, because nothing in a redraw plays it, and
it gates nothing, because the row is readable and clickable from its
first frame.

The gap is the interesting half. Nothing is ever inserted into this list:
it is refetched and morphed, so by the time anything can be animated the
new row is already in place and every row below it is already one row
lower. There is no gap to open. So it is played backwards — measure where
every row was, and afterwards put the survivors back and release them.
What the eye sees is rows travelling down to make space, which is a true
account of what happened, arrived at from the wrong end.

Two corrections to the morph that shipped this morning, both found by
this work rather than by the tests that were meant to cover them:

  The list was nesting a duplicate <ul> inside itself, one level deeper
  per refresh. Idiomorph treats the element you hand it as CONTENT, so
  passing the fresh <ul> asked for a list containing a list. Every
  assertion still passed — the rows morphed correctly and simply lived a
  level further down. There is an assertion on the shape now.

  Exempting the `hidden` attribute on an open menu was too narrow: the snooze menu also
  hides individual entries and un-hides them on open, so the menu stayed
  standing with its contents blanked. An open menu is now skipped whole.

Minimal does not scale the row down proportionally, it opts out — a
shortened version of a gesture whose whole point is its length is not a
smaller gesture, it is a worse one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 10:42:47 +02:00
Paul LütznerandClaude Opus 5 12d0c10a51 docs: the handbook learns about the motion tier
Both languages, including the part somebody will look for and be confused not
to find: the operating system's reduced-motion request overrules the setting
rather than being merged with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:42:49 +02:00
Paul LütznerandClaude Opus 5 34bdc7b822 docs: the handbook learns about live results and split hostnames
Two behaviour changes from the search work, in both languages: results appear
under the suggestions as you type, and hostnames and addresses are indexed by
their parts.

The preview is described as a preview, including what it gives up and why it
stands aside for an operator — a reader who is told "the ten most recent
matches" and then finds Enter returning more would reasonably conclude
something was broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:25:54 +02:00
Paul LütznerandClaude Opus 5 f9a5a17f7e docs: a README you can read in five minutes, and the handbook to match
The README had grown to 318 lines and explained how to register a Google Cloud
Pub/Sub topic before it had finished saying what plMail is. That belongs in the
handbook, which is where it already was. What is left is 101 lines: the
handbook link first, what it is, the highlights, the screenshots, and the two
ways to install it — Docker, and TrueNAS as dataset, edit one path, paste.

The lead paragraph under the H1 is load-bearing, not decoration:
bin/build-site.php lifts the first paragraph into the landing page's hero, so a
README that opened with the badge row put raw markdown there and overflowed the
phone layout.

The handbook follows the two behaviour changes in this branch — the send pill
being the cancel, and an account row opening that account's inbox — in both
languages, with the German hash re-pinned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:47:57 +02:00
Paul LütznerandClaude Fable 5 2828dc31dc feat(compose): sending with a pulse and a way out, and forwards that behave
The send button's "Sending…" becomes a living one-two-three dot cycle — a
static label reads as a hang — and a Cancel appears beside it for the
in-flight moment: every send lands in a cancel window, so a click here is
remembered and the window's own control is pressed the instant it renders.
The watcher lives at module scope because the stream that renders it
replaces the very element a controller-held watcher would die with.

A forward opens unsigned (its content is the mail being passed on), with
the address rows unfolded and a blinking caret in a typable To — Tom
Select's enhancement is awaited, not hoped for — and the quoted original
counts as content, so no forward is questioned as an empty mail. The
"show quoted text" fold stays default but becomes a choice: Settings →
General → Composing, stored only when switched off, absent key meaning
default as the settings bag always reads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 00:27:44 +02:00
Paul LütznerandClaude Fable 5 8533993c4b feat(insights): user-facing configuration, straight off the registry
Settings → Insights lists whatever the extractor registry holds — name,
hint and an On/Off segment per extractor, so a new extractor appears here
by existing. Off is stored as the disabled set, so next release's extractor
starts working without anyone finding a new toggle. The endpoint validates
keys against the registry and carries the same CSRF discipline as its
neighbours; an extractor without catalogue lines falls back to its key
rather than printing a translation path. Handbook gains "The radar" in both
languages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 19:35:38 +02:00
Paul LütznerandClaude Fable 5 c7979c7d85 feat(appearance): the pl mark drops Google's colours and becomes a choice
The topbar monogram wore Google's literal brand hexes, which is why it read
as someone else's product. The mark is redrawn — shared top and bottom
lines, a square-ish bowl, a tighter tail turn, ink centred in its frame —
and its geometry now lives in exactly one place, a Twig macro that the
topbar, the settings tiles and the favicon all draw through.

The paint is a per-user setting: thirty-two colourways in LogoStyle
(single colours, duotones, ink with one coloured flick, and sweeps), picked
under Settings → Appearance → Logo, applied to the topbar live and saved
through the same pipeline as every other appearance knob. Berry — the
product's own sweep — is the default every account starts on.

The favicon follows the person now, so it is a route rather than a file:
/branding/favicon.svg answers in the viewer's colourway, revalidated by an
ETag that IS the style value, and anonymously it serves the product
default. The static icon set (favicon.svg, the PWA PNGs, apple-touch) is
repainted in Berry as the sessionless fallback, and the manifest and
theme-color meta follow its tile.

Two colourways from the design board are deliberately absent: the Google
original, and the four-distinct-hues restatement of it. A test pins both
out, along with the seven-strokes-per-style contract the macro depends on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 17:49:54 +02:00
Paul LütznerandClaude Fable 5 bf5d6bd65f feat(mail): the category tabs get the Gmail treatment — icons, coloured pills, sender hints, all live
Each tab wears its category's glyph (outline at rest, solid on the tab you
are on), a "3 new" pill in Gmail's per-category identity colour, and a
second line naming who the new mail is from, newest arrival first — the
senders query is one grouped read bounded by the 24-hour window, with the
newest-message-per-thread pick done in PHP. The unread counter is gone from
the tabs on purpose: unread already has the sidebar badge and the bold rows,
so the tab keeps the one number only it can say.

The strip is live end to end. The counts payload gains a "senders:category:"
family — the payload's one string value, joined and localised server-side —
and the sidebar controller patches pill text, aria labels and hint lines
document-wide on the same sync the badges ride. A hintless tab centres its
label inside a fixed-height strip, so a hint arriving over a sync changes a
line of text and never the geometry around it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 17:49:28 +02:00
Paul LütznerandClaude Opus 5 1d7266880f docs: the client guide stops describing a server from two months ago
Three corrections and one new section, in both languages.

The guide said "None of this is reachable over JMAP today" about Appearance,
and repeated it in the parity checklist. That has been false since v0.0.35:
Appearance/get and Appearance/set serve the whole singleton, and the Session
publishes the vocabularies and ranges. Left as it was, it was actively telling
client authors to build a local-only theme system for a feature that already
syncs — so it now names the nine newer properties, and the two things that bite
on the first write: booleans are validated strictly, and the per-surface
densities need an explicit JSON null rather than an omitted key.

Thread/set is documented as taking two properties rather than one, and the New
marker gets a section of its own — what newness means, why it is not unread,
how to read it and how to retire it, and the sentence that matters most to
somebody writing a mail list: if your client draws rows and does not report the
displays, you are leaving the user's other clients wrong.

The German mirror moves with it, hash included; DocumentationCoverageTest is
what would otherwise have caught this a release later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 14:58:04 +02:00
Paul LütznerandClaude Opus 5 0fb5bc2dbf docs: the appearance page catches up, and health stops promising a menu item
The handbook was last swept at v0.0.34, so this covers two releases: the
v0.0.35 items that shipped without a docs pass, and everything since.

**Appearance** was the page furthest behind and gains five sections. The
live preview and its resizable card (240–900, remembered per user, revealed
from a button in the card header on a phone and deliberately not remembered
there). The mail-list group: the account corner, preview lines and unread
emphasis. Typography, with the compose editor named as being outside the
scale — that size is per-message formatting that goes out in the sent HTML,
which is the part a reader would otherwise file as a bug. Per-surface
density, and why only density is per-surface. Backgrounds now apply live and
have a fourth kind (a flat colour) that was never documented at all.

Two limits are stated rather than glossed: sender discs cannot be switched
off because that disc IS the row's checkbox, so "off" paints over the
identity and keeps the target; and the preview's 900 is a ceiling, not a
promise — it stops around 374 on a 1280-wide window.

**Account health** documents the behaviour change directly, because someone
who read the old page will go looking for a nav item that is no longer
there on a good day. The page also gains the two push verdicts — lapsed
(renewal never ran, look at the scheduler) versus live-but-silent (the
Pub/Sub leg) — the three dates each card is judged from, and the fact that
"not delivering" is only ever reported from evidence, so silence is not
proof push works.

**Mail** gains the folding sidebar with its distinct-thread unread roll-up,
and corrects undo send: the old text said cancelling is decided before
anything is transmitted, which stopped being true in v0.0.35. A cancel can
now lose, and losing says so rather than handing back an editable copy of
delivered mail.

**Accounts** gains the fourth push badge state and the widened teardown
(calendar removal and an admin reset now hand registrations back too).

Fixed in passing: docs/features/accounts.md told operators to grant
`gmail-api@system.gserviceaccount.com` the Pub/Sub Publisher role. It is
`gmail-api-push@` — GmailWatchService's docblock records that the wrong one
was followed once and delivered nothing, since it is not an address Google
publishes from. A topic granted exactly what the page asked for never
delivers a single notification, in both trees.

Every German page re-stamped; the two trees are in sync and the eight
documentation-coverage assertions pass. Screenshots are NOT regenerated —
settings.png, admin.png and filters.png predate the shared card style, and
every screenshot shows the sidebar before its headings became disclosures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:53:32 +02:00
Paul LütznerandClaude Opus 5 8a918a95e8 docs: the compose window as it actually is, and a page for what is broken
Everything shipped since v0.0.33 that a person using plMail can see, in both
language trees.

MAIL. The "Composing" section had drifted furthest and is rewritten around
what the toolbar now does: the emoji picker (full Unicode, localised search,
skin tones, recents — and the rule the whole feature is built on, that `:)`
and `:smile:` survive typing), inline images as `cid:` parts that are not
attachments and are not counted as any, the link popover and its second face
on an existing link, signatures and the three states that matter, scheduled
send with its one-minute floor and thirty-day ceiling read on the user's own
clock, and the more-options menu including the honest disabled encrypt button.

Read receipts get their own section rather than a line, because the receiving
half is a privacy default: Never, per address, with ask and always as opt-ins,
a receipt only ever sent on a read the user performed themselves, and an
automatic send downgraded to "ask" when the return address disagrees with the
sender.

The "New" marker is documented as the different axis it is — shown, not
opened, and never longer than 24 hours.

HEALTH. New page, both languages, linked from the index and from accounts:
what the five problem classes mean for a person's mail, and reconnect-in-place
— the repair that keeps mail, labels, rules, threads and calendars, and is
refused outright when the re-consented identity is a different address or
provider. Plus calendar sync backing off instead of retrying forever.

ACCOUNTS. Display order and the primary account are separate now, so the page
says so: dragging is cosmetic, "Make primary" is the control, and the coloured
dot is an identity colour that no longer moves — status lives on the health
page. The old trap said the opposite and promised no such button was coming.

CALENDAR. The grid is drawn in the user's configured zone rather than UTC, and
an event saved to a hidden calendar appears in no view — the editor lists
hidden calendars, the views do not.

Two things found already wrong and corrected in passing: mail.md claimed how
far back a mailbox syncs is a per-account setting, and README.md's tour caption
offered to "choose how much history to sync". No such setting exists on
Account, and accounts.md said so on the same page.

Both trees end in sync, every translated-from hash re-stamped.
phpunit OK (3462 tests, 8985 assertions) — the baseline exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:31:06 +02:00
Paul Lützner c8dffdf0a1 fix(mail): a message is read on our terms, not the sender's
Opening a message told the sender it had been opened. Eight images loaded from
images.ctfassets.net the moment a newsletter was rendered — one of them 526x5,
which is what a tracking pixel wears when it is pretending to be a spacer — and
each one carried the reader's IP address, their user agent, the time, and, from
a per-recipient URL, which recipient it was. Read receipts, working perfectly,
for anyone who cared to send one.

Remote images are blocked now, and the block is not the interesting half. The
interesting half is that saying yes does not undo it: an opted-in image loads
through /mail/image-proxy, which fetches server-side and streams the bytes back,
so the sender learns that somebody's plMail asked and nothing else. There is
also "always for this sender", per user, in a table with a unique constraint —
because trusting a sender is a button in a message, the same message can be open
in two tabs, and an allowlist that a double click can duplicate is an allowlist
that a race can lose an entry from.

The proxy is a service that connects wherever a stranger's mail tells it to,
from inside the container network, so its rules are stated in order and tested
one by one: https only; port 443 only, which removes port-scanning rather than
blacklisting the interesting ports; every address the host resolves to must be
public, and the connection is then PINNED to a checked address through the
client's `resolve` option, because checking and letting the client resolve again
is the whole rebinding hole; redirects followed by hand, three at most, each hop
re-running all of that, since max_redirects would follow a public host to
169.254.169.254 past every check; an image content type; 8 MB. Every refusal
answers with the same transparent GIF, because telling the caller "refused"
rather than "timed out" against an internal address hands back exactly the
port-scan result the rules exist to deny. The signature on the URL is NOT the
SSRF defence and says so in its own docblock — anyone with an account can mail
themselves a link and be handed a valid signature for it.

Deliberately not IntegrationUrlValidator, which has an allow-list and an
admin-pinned base URL because a user is naming their own Nextcloud on a LAN.
Importing its escape hatches here would mean an admin who allowed a LAN host for
Immich had also allowed every sender's mail to reach it. The docs there claimed
pinning the resolved IP "needs something Symfony's client does not expose";
that is not true, `resolve` does exactly that, and the correction is in this
commit next to the code that disproves it.

The body no longer renders in the app's own DOM. The sanitizer held — nothing
got through it — but "no gaps, forever, against every sender" is a claim that
has to keep being true, and the price of it being wrong once was a same-origin
script with the session cookie. It renders in an iframe srcdoc whose value is
what is absent from its sandbox: no allow-same-origin, so an opaque origin with
no cookie, no storage and no reach into this document; no allow-forms, so a
phishing form cannot even be submitted; no top-navigation, no modals, no
downloads. allow-scripts IS present, and that is a deviation from the brief
worth stating plainly: height is a fact only the framed document can measure,
a frame without allow-same-origin cannot be measured from outside, and the
alternatives were a fixed height or a guess. It is paid for by the frame's own
CSP restricting script-src to a per-render nonce, which our thirty lines of
height-and-hover reporting carry and no markup arriving in an email can. That
CSP also names our origin in img-src explicitly — not 'self', which in an opaque
origin matches nothing — so the blocker and the browser both have to fail before
a pixel fires.

Every warning is drawn OUTSIDE the frame. A banner inside the sandbox is a
banner the mail can paint for itself, and a "Show images" button a message can
forge is worse than no button. The hovered-link preview follows the same line:
detected in the frame, where the links are, drawn by the parent, so a message
cannot paint a status bar claiming a different destination.

A Hetzner-branded invoice phish sat in Spam and rendered at full fidelity with
nothing said about it. Messages in Spam now carry a banner, and the sender-name
check is two rules that fit in a sentence each, because a warning nobody can
check is a warning people learn to click through. Rule 1: the display name
spells out a domain that is not the sender's. Rule 2 runs only when the display
name carries a legal form as a whole word — GmbH, Inc, Ltd — which is what
separates an organisation from a person; then, if none of its non-generic words
of four letters or more occurs in the sender's registrable domain, the names
disagree. "Hetzner Online GmbH" <support@ownkhalsick.com> fires; the same name
at hetzner.com does not; "Deutsche Bahn AG" at bahn.de does not; "Jane Cooper"
is never judged at all. DKIM passing for the sender's own domain suppresses
both, since a domain that signed the message may write what it likes. A
lookalike domain containing the brand is a known miss and has a test saying so
— catching it means edit distances against a brand list, and every false
positive that bought would be spent on ordinary mail.

A trusted sender still gets no images inside Spam. The allowlist records a
belief about a sender; a message in Spam is the provider disagreeing about
whether this really is that sender, and the cautious reading of a disagreement
is the right one.

Blocking runs at RENDER time, not at ingest, and both reasons matter. Every
message already stored was sanitized before this existed, so an ingest-time
block would have protected only mail that had not arrived yet. And the answer
depends on who is asking, because the allowlist is per reader — a stored form
cannot carry an answer that varies by reader.

Two layout fixes ride along, from the same report. Attachments rendered after
the full HTML body, some 750px below the fold on a long message, and an
attachment nobody scrolls to is an attachment nobody was sent; the chips move
under the header, the same partial, relocated rather than duplicated. And the
message header is sticky within the reading pane, so the answer to "who is this
from" is not a scroll away from the sentence that raised the question.

Verified in a browser, because every claim here is a claim about what the
browser does. tests/e2e/rendering-security.spec.ts asserts on route
interception — no connection to the sender's host, before or after opting in —
and its matcher is a hostname predicate rather than the obvious glob, which
would have matched the proxy's own URL because the sender's URL lives inside its
query string, and reported a leak at the exact moment the feature worked.

Two traps found on the way, both now in the handbook. Dom\HTMLDocument rejects
LIBXML_NOWARNING with a ValueError rather than ignoring it, and the blocker
fails closed, so the symptom was every message rendering as plain text:
perfectly private and completely unreadable. It also needs the UTF-8 override,
because MailBodySanitizer drops <head> and the body has no meta charset left.
2026-08-11 17:36:23 +02:00
Paul LütznerandClaude Fable 5 e8421f9f08 Every theme defines every variable, so a switch cannot leave one behind
A theme is chosen by rewriting `data-theme` on <html>, which unsets the old
theme's block in one step. Anything that block declared and the new one does
not therefore falls back — to :root, to .dark, or to whatever an inline style
left there — and the user is looking at a colour nobody picked for the theme
they are in.

Three of the six themes were four-line diffs against a light-tuned base, so
most of what they painted was inherited rather than chosen, and two of the
inherited values were visibly wrong. Nord and Dusk took --rgb-info from :root,
which is the light theme's link blue, and rendered it on a near-black surface
beside a --rgb-danger, --rgb-warning and --rgb-success that had all been
lightened for that surface — info was simply forgotten when the others were
tuned. Both also took --rgb-ink-soft from .dark, a neutral grey that in Nord's
case is DARKER than its own --rgb-ink-muted, so the ink ramp ran backwards.
Solarized had a #f3f4f6 inset panel on a #fdf6e3 page.

So every palette block now declares the whole inventory: 38 variables plus
color-scheme, spelled out in :root, .dark and each of the five [data-theme]
blocks. Reading one block tells you what the theme looks like without tracing
three levels of cascade, and there is nothing left for a switch to leave over.
`light` and `system` still have no block of their own on purpose — :root is
the light palette and .dark is the dark one, and a copy under a selector would
be a second place to forget.

── The accent was a genuine remnant, and a persistent one ─────────────────

Theme::defaults() seeds settings when a theme is picked, and it named an
accent for Paper and Dark only. The accent is written inline on <html> from
the user's setting, so an inline style is exactly what survives a switch:
picking Paper and then Nord left Nord wearing Paper's clay. Not just in the
preview — save() reads the value back out of the form, so it was written to
the account and came back after the reload. The theme picker's own swatch
showed a frost blue the app never painted.

defaults() is now one expression for every case, reading swatch()[2], so there
is no case that declines to overwrite. pickTheme() also clears any seeded key
the incoming theme does not carry before applying the ones it does; that
clears nothing today, and it is what keeps this fixed if a theme ever grows a
key the others lack.

── system on a dark desktop ───────────────────────────────────────────────

The picker toggled `.dark` from data-dark, which is Theme::isDark(), and
System is not dark by that measure — it is whatever the OS says, which is the
question the no-flash script in app.html.twig asks on every load. So picking
System on a dark desktop dropped the class and painted light until the next
navigation put it back. It now asks the same question the script does.

── One mail-sheet, in the theme's own light ───────────────────────────────

app.css had two @utility mail-sheet blocks: the warm-paper redesign from
aa0edab and the white one from the first reading-view pass. @utility blocks of
the same name do not merge — the later one wins outright — and the white one
was further down the file, so the redesign had been dead code that read as
live for a fortnight. --rgb-sheet and --rgb-sheet-ink were added by that same
commit and wired to nothing.

Resolved to the redesign, keeping the one thing the white block had that it
did not: the isolate/stacking-context fix, without which Chrome re-samples the
app background through the sheet at some viewport sizes and flashes the pane
dark.

The sheet's colours now come from the theme rather than from a fixed palette.
Light is a constraint it has to meet for the mail's sake — an email's HTML was
authored against a light background, and rendering it on a near-black surface
misrepresents what was sent — but WHICH light is the theme's business: Nord
reads its mail on snow storm, Solarized on base3, Paper on the cream it is
already made of. Everything else inside the sheet derives from those two ends
of the ramp, so the hairlines, insets and fields need no channels of their own.

── Keeping it that way ────────────────────────────────────────────────────

ThemeVariableCompletenessTest parses app.css and fails when a block drifts
from the inventory, when a knob appears outside :root, when a variable in the
inventory is read by nothing, when mail-sheet is defined twice, or when a
theme seeds a different set of settings than its siblings.

theme-switch-parity.spec.ts is the acceptance criterion: it switches through
every theme IN SEQUENCE without reloading — the order matters, because it
takes a previous theme to leave a remnant — and asserts the painted palette,
the class list and color-scheme match what a cold load of that theme produces.
Under both layouts, and once more for system on a dark desktop. Both new tests
were confirmed to fail against the code they replace.

README screenshots re-recorded: the reading pane is the warm sheet the
redesign intended rather than white, and Paper's shadows and raised surfaces
are its own warm ink rather than neutral black.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 15:43:55 +02:00
Paul LütznerandClaude Fable 5 13e6190103 feat(sync): remove the per-account "sync only the last X mails" cap
The setting promised something it did not deliver. The cap applied per
run rather than only to the first, so an IMAP mailbox that gained more
than the cap between two runs had its middle skipped while lastSeenUid
jumped to the newest UID fetched — and since the cursor never walks
backwards, the documented remedy of "clear the setting and re-run" did
not in fact walk the gap. What the user got was not "the newest N", it
was a mailbox with holes in it.

So it goes, rather than being repaired: the web control, its controller
and route, the entity property and its Microsoft carve-out, the
enforcement in both syncers, the JMAP `syncLimit` field, and the EN/DE/
en_PI strings.

Removing enforcement does not go and fetch the mail the cap kept out.
Future runs are unbounded; an operator who wants the old mail runs
`app:reset`. A boot-time migration is the wrong place to start a full
re-sync of every capped account at once.

What stays is the backfill machinery, which is a different thing wearing
a similar name: it is how a large mailbox arrives over several runs.
needsBackfill() now reads any non-zero target as unfinished, which is
what the old expression reduces to once the cap is gone — 0, the whole
mailbox, is the only value that can mean "everything is in".

The `urn:plmail:params:jmap:sync` capability keeps backfillTarget and
backfillPending. With no retention setting left, an unfinished backfill
is the only honest reason the server can give a client for mail it does
not have yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 15:21:57 +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 f48c1e3031 Cover the four shared views, and say in the docs what the recipient now sees
Light, as asked: the arithmetic is the range builder's test and the render tests
only assert what a render test is uniquely able to say.

SharedCalendarMonthBuilderTest becomes SharedCalendarRangeBuilderTest with its
month assertions unchanged in meaning — only the anchor is spelled as a date now,
because all four views share one URL shape — plus the seven columns of a week,
one column of a day, the agenda's shared-days-only listing, and the bounded
paging of each. Two of its cases are there because they caught real bugs while
this was being written:

  The agenda's step was built by sprintf as "-1 30 days", a string PHP accepts
  and does not mean, which silently answered null for every backward step and
  made a three-month link look like a one-page one. A fortnight fixture cannot
  catch it, so the new case uses three months.

  A midnight-crossing entry is now on both columns of a grid. ShareLinkReader
  files each entry under the day it starts, which is right for a list and wrong
  for a grid: a published column drawing nothing at 00:30 is this page saying its
  owner is free at an hour they are not.

The controller tests get one smoke per view — it answers, it sets no cookie, it
offers all four ways out of itself as links, and a busy/free link leaks no title
in any of them — plus the assertion that the appended list is gone and its
"Nothing on this day" property arrived in the agenda. The leak test asks its four
secrets of every view for the same reason: the guarantee is meant to hold by
construction, and this asserts the construction is still what each view is built
on.

Two of them needed a second fixture. A fixed three-day link is used where a
rolling fortnight's edges move with the calendar — fourteen days from a Monday is
two whole weeks, so on Mondays a rolling link has no partly-published week and an
"unpublished column" assertion would have passed six days in seven.

And the authenticated views are asserted through the extracted shells, because
nothing else in the suite rendered the owner's week or agenda: an extraction that
broke either would have been found by a person rather than by a build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 15:57:55 +02:00
Paul Lützner 62ba7cae48 Merge branch 'feat/public-calendar-style' 2026-08-07 18:45:33 +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ützner fd3530b51c Make the public calendar pages look like the calendar they came from
A shared link opened a list of days and a booking page opened a wall of
times. Both were correct, both were themed against the `:root` fallback —
which is not a theme anybody picked, it is what a stylesheet resolves
against before a user is known — and neither was recognisable as
belonging to the application it came out of.

The shared page is now a month grid with the day list under it, and the
grid is literally the authenticated calendar's: calendar/_view_month.html.twig
was pulled apart into calendar/_month_shell.html.twig — the weekday strip,
six equal rows, hairline gutters, today/weekend/spill-in cells — which
both pages embed. The month view keeps its own chips, its "new event"
affordance and its "N more" link and renders exactly what it did.

The booking page is a week: seven columns of slot pills with prev/next,
opening on the first week that has anything in it, in a card that names
the appointment, its length and its zone.

Both pages wear the OWNER's appearance — theme, accent, radius, density —
via PublicAppearanceResolver, which hands the template three strings
rather than the User so it cannot print a name it does not have. Paper
for an account that never chose; a `system` theme resolves against the
reader's own machine. No user id is passed to the renderer, so a custom
uploaded background is dropped rather than 404ing with the owner's id in
the URL. Still no session and still no cookie, which the tests assert.

Redaction is unchanged and re-asserted over the new markup. Chips carry
no calendar colour, deliberately: ShareLinkReader's rule is that a shared
page says nothing about the owner's calendars, and colours would let a
busy/free link be grouped by which diary each block came from. Days the
window does not cover are dimmed, kept out of the list and named in a
legend, because a blank cell that read as a free one would have the page
claiming availability nobody published; paging is bounded to the months
the window touches for the same reason.

Also fixes a translation key that has been on screen since the feature
shipped: the window under the heading asked for `calendar.share.window`,
a node with two children, and printed the key itself.
2026-08-07 18:37:33 +02:00
Paul Lützner 9732b51671 Merge branch 'fix/backup-restore-writes' 2026-08-07 18:29:47 +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
Claude 1195a9d7a3 Sort search results by date, and let the reader say otherwise
Search answered in ts_rank order and only that, so a keyword that matched
a mail from 2004 and two from 2026 put them on the page in an order with
no relation to when any of them arrived. Read as a page that had ignored
the question. The default is now newest-first — what the reader expected,
and what every mail client they already use does — with relevance kept as
the other position of a switch beside the pagination.

The switch is two links into the same route inside the list frame, so
changing order is one navigation rather than a write followed by a
reload, and the controller remembers the choice as a side effect of
answering it. Remembered in the user's settings bag, beside the sidebar's
expanded account and the calendar pane's mode, because it is the same
kind of thing: one string of view state that nothing queries by and that
should follow the user to their other devices. Switching starts again at
page one — page four of one order is page four of nothing in the other —
while paging carries the order in the URL.

Both orders now end in thread_id DESC, which fixes a bug that predates
the switch. `ORDER BY rank DESC, last_message_at DESC` is not a total
order, and LIMIT/OFFSET over a partial one is free to break ties
differently per page: measured on 20k tied rows, three pages of 50
returned 148 distinct rows — two shown twice, two shown never. Not exotic
either, since ts_rank is degenerate for a query that stems to nothing and
ties every row in the result.

JMAP's EmailQueryRunner compiles its own ordering and is untouched.
2026-08-07 18:06:27 +02:00
Claude 2bc984c2d5 Draw one meeting once, on every surface that lists occurrences
A screenshot of "Happening Soon" showed the same meeting on two
consecutive rows: the copy an extractor read out of the invitation onto
the account's calendar, wearing the participants icon, and the copy the
provider mirrored onto a Remote calendar, wearing the plain clock. Both
rows are correct in the database — two remote objects with their own
remoteId, etag and sync state — and they carry the organiser's UID, so
EventClusterer has recognised them as one meeting since it existed. The
grid draws them as one merged chip. This panel read occurrences and drew
a line each, which is the worse place for the duplication to land: a grid
at least draws its two chips inside one visibly shared hour, while a list
of twelve lines simply claims there are two things.

Display-only, then. Nothing had materialised that should not have.

So the clusterer stops being the grid's. Every surface that turns
occurrence rows into a list a person reads goes through it:

  Happening Soon collapses, and the SQL cap is over-read so the slice can
  be taken afterwards — twelve rows has to mean twelve meetings, not
  twelve rows of which six are duplicates. HappeningSoonRow now carries
  its cluster, so the template wears the same conic-gradient dot the
  merged chip does; a list that silently merges is indistinguishable from
  one that lost something. Its kind and its provenance are read off the
  cluster's EXTRACTED member rather than off whichever row the query
  returned first, or the icon and the "why is this on my calendar?" link
  would appear and disappear with the sort order.

  The shared calendar page and its .ics feed collapse, before redaction
  rather than after: merging afterwards would compare blocks the
  redaction made deliberately indistinguishable, and every meeting in an
  hour would fold into one. Publishing a meeting twice is not a leak, but
  it is a lie about how busy the owner is, and it files as two meetings
  in a subscriber's client.

  The alert sweep folds on user + UID + start + trigger instant. The
  delivery ledger could not do it — its unique constraint is keyed on the
  EVENT, which is precisely the thing there are two of — so one meeting
  sent two notifications at the same second. Keyed on the USER because
  the sweep is global and a UID is the organiser's, and on the TRIGGER
  rather than the alert because alert keys are minted per row.

Left alone, deliberately: the topbar dot already answers with the first
occurrence and returns; booking availability subtracts busy intervals and
subtracting one twice removes the same slot; the calendar .ics export is
one calendar per document, where a UID is already unique; and JMAP
answers with the ids of the rows it holds, which a client must be able to
get back.

And an extracted copy under a UID of its own stays a second meeting.
Collapsing that would mean matching on title and time, which turns a
weekly 1:1 held with two different people at the same hour into one chip
— a meeting quietly disappearing from a calendar, which is the worst
shape a calendar bug takes. Dismissal answers those, not a wider merge.
2026-08-07 17:34:35 +02:00
Paul LütznerandClaude Fable 5 6aeaa828c4 Let a user remove a registered push device from settings
Every existing way a subscription disappears is the server's decision: the
endpoint answered 410, Firebase said UNREGISTERED, the browser re-subscribed
and replaced its own row. None of them reach the case that actually turns up
in support — an old build of an app still registered under a device id nothing
uses any more, delivering a second copy of every notification to the same
phone. That row is healthy and succeeds every time, so nothing retires it, and
until now the only remedy was a DELETE typed into psql.

Settings -> Notifications now has a Remove on each device row. POST only,
CSRF-protected, and the row is looked up scoped to the authenticated user so a
guessed id resolves to nothing rather than to somebody else's device.

The delivery log is deliberately untouched. PushDelivery is addressed by device
id and has no relation to the subscription precisely so its rows outlive it,
and this must not become the one path that quietly takes them down: "this
device stopped being delivered to on Tuesday" is exactly what somebody asks
about a device they removed. A test pins that, so a later tidy-up with a
OneToMany and a cascade fails rather than erasing history.

Wording says what removal does and does not do: deliveries stop, nothing is
uninstalled or switched off on the device itself, and an app that still wants
push registers again the next time it runs. EN and DE, in the confirm and in
the toast, and in the handbook in both languages.

Not in WebPushController: that is the service worker's JSON API, unsubscribing
the browser it is running in by the device id it holds locally. This is a
settings form about a device that is usually not the one being sat at.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 17:06:53 +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ützner 84a5eed8b3 Merge branch 'feat/push-monitoring' 2026-08-06 18:00:46 +02:00
Paul Lützner 2bf3c38add small fixes that claude couldnt commit because who knows 2026-08-06 16:29:22 +02:00
Paul LütznerandClaude Fable 5 287c93805d Record where every push went, and stop scheduled sends being invisible
Every attempt both senders make lands in push_delivery - transport,
device, outcome, the error name that tells a dead phone from a Firebase
outage, latency - browsable under /admin/push/deliveries and echoed per
device in the user's notification settings. Nothing of the payload past
its @type is stored, and the record survives the subscription it is
about.

A held submission now answers EmailSubmission/get as pending with its
real release time, a cancelled one as canceled, and the changes feed
reports both transitions - the release time used to exist only in the
create response, which forced clients to keep schedules device-local.
Cancelling a never-submitted mail is refused instead of arming a flag
the next real send would silently trip over.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 15:59:07 +02:00
Paul LütznerandClaude Fable 5 e5596a9b47 Send push over FCM for clients that ask for it, configured from the admin page
A second transport beside Web Push: PushSubscription/set create accepts a
vendor fcmToken, the dispatcher fans out per transport, and delivery goes
through FCM HTTP v1 with an OAuth token minted from the service account
the admin pasted - no new dependency, openssl and the http-client do it.
The admin page takes the service-account JSON (encrypted at rest) and the
instance's google-services.json, refuses mismatched pairs, and the session
advertises fcm plus the public client config a stock Play Store build
needs to initialise Firebase at runtime against this very instance.

Committed by the supervisor: the implementing session hit its usage limit
after writing code and tests but before running them; results recorded in
the merge that follows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 15:09:43 +02:00
Paul LütznerandClaude Fable 5 cbd27e0d26 Stop telling client authors to build the address book the server now serves
The three passages in CLIENT_DEVELOPMENT.md that said there is no JMAP
Contacts predate Contact/autocomplete and were left in place by the
feature branch deliberately, because the German mirror pins a sha1 of the
English source and the two files have to move together. They now do:
both name the capability and the method, and the fallback advice is
demoted from replacement to supplement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 09:07:07 +02:00
Paul Lützner a3e37dfc9d Merge branch 'feat/jmap-calendar-expand' into integration/jmap
# Conflicts:
#	docs/de/CLIENT_DEVELOPMENT.md
#	docs/de/internals/jmap.md
2026-08-06 09:03:40 +02:00
Paul Lützner bafdb26064 Merge branch 'feat/jmap-submission' into integration/jmap
# Conflicts:
#	docs/de/CLIENT_DEVELOPMENT.md
#	src/Jmap/README.md
2026-08-06 08:59:10 +02:00
Paul LütznerandClaude Fable 5 5d21d445d9 Answer a calendar query with occurrences when the client asks for them
A collapsed CalendarEvent/query names a series and says nothing about
which days it lands on, so an Android client drawing a month had one way
to find out: ask about one day at a time and see which windows the series
comes back in — up to 31 round trips to place one weekly meeting. It
could not expand the rule itself, and must not: the phone and the web
would disagree at DST boundaries and on overridden instances.

expandRecurrences: true, as draft-ietf-jmap-calendars defines it, is one
entry per occurrence in the window, ordered by the occurrence's start,
with position/limit/total counting occurrences. It is a projection of a
read that already happens — findInRange() returns occurrence rows and the
collapsed answer throws the extra ones away — which is the payoff of
materialising occurrences at all. With the argument absent or false the
response is byte-for-byte what it was.

  Calendar/OccurrenceId          the synthetic id, <eventId>_<recurrenceId>,
                                 and why the separator is "_" and not the
                                 ";" other implementations use: RFC 8620
                                 §1.2 confines an Id to A-Za-z0-9, "-" and
                                 "_", so a semicolon or the colons of an
                                 ISO timestamp is an id a conforming client
                                 library may reject before this server's
                                 answer is read. The draft says "opaque"
                                 rather than naming a separator for exactly
                                 that reason.
  Query/…QueryRunner             the expansion, and the horizon refusal.
  Method/…GetMethod              resolves an instance id, which is what
                                 makes the feature usable: a client pairs
                                 /query with /get on "#ids", so ids the
                                 getter refused would leave the expansion a
                                 list of strings nothing accepts.
  Mapper/CalendarEventMapper     one occurrence as a JSCalendar object —
                                 the series with its override applied,
                                 recurrenceId set, recurrenceRules and
                                 recurrenceOverrides null as the draft
                                 requires.
  Method/…SetMethod              refuses an instance id BY NAME rather than
                                 answering notFound about an id this server
                                 minted.

Three decisions worth arguing with rather than discovering. A one-off
event keeps its plain series id even in an expanded answer: its single
occurrence is the event, and the plain id is the one /set accepts back.
The instance object carries a plMail seriesId, because /set cannot write
one instance and without it a client that expanded a query would hold
only ids it cannot edit through. And a window reaching past the
materialised horizon is refused with cannotCalculateOccurrences instead of
answered short — collapsed, an overrunning window is merely thin, but
expanded the answer IS the list of instances, so a series that stops at
the horizon comes back as a series that ends and nothing says otherwise.
timeZone is refused alongside expansion for the same reason: this server
does not convert, and a client told nothing would draw a month in the
wrong zone.

Fourteen tests over the cases that separate a projection of the
materialised rows from a re-expansion of the rule: a moved instance is
drawn and ordered at its new time and still named by its old, an excluded
one is absent, a cancelled one leaves the query and still resolves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 08:56:00 +02:00
Paul Lützner 5e23d1d324 Send as the identity a client picked, at the time it asked for
Two decisions a JMAP client could already express and this server threw
away. `EmailSubmission/set` read `emailId` and nothing else: the
`identityId` naming the alias to send as was ignored — every mail left as
the account's own address, reported as success — and the session
advertised `maxDelayedSend: 0`, so a client had no way to ask for a
future send at all.

  identityId       resolved against the account's sendable aliases and
                   written to Message::fromAddress, which is where
                   MessageSendService reads the From header from. An id
                   that resolves to nothing is refused with
                   forbiddenFrom rather than falling back to the account
                   address — that fallback is the bug, not the safety
                   net: it sends from an address the user did not pick
                   and says "pending".

  scheduled send   RFC 8621 §7 defines no scheduling property; it
                   carries FUTURERELEASE (RFC 4865) as HOLDFOR /
                   HOLDUNTIL in envelope.mailFrom.parameters, so that is
                   what is read. The hold becomes a DelayStamp, sendAt
                   in the answer is the real release time, and 30 days
                   is both the advertised maxDelayedSend and the
                   enforced ceiling — one constant, so the promise and
                   the refusal cannot disagree.

The identity list moved out of Identity/get into IdentityResolver, read
by both methods: an id the server published is exactly an id it accepts,
by construction rather than by two copies agreeing. EmailSubmission/get
now reports the identity the mail really left as; it reported the
accountId, which is not an id Identity/get publishes once an account has
aliases.

Cancellation came almost free and is offered: an update of undoStatus to
"canceled" sets Message::cancelled, the flag the web composer's undo
button already sets and SendMessageHandler already reads when the
envelope comes due. Nothing is recalled from the queue — the send
declines to happen — so it is reliable for a held submission, a race for
an immediate one, and refused with cannotUnsend once sentAt is set. What
it cannot do is be seen afterwards: there is no submission row to hold
"canceled", so EmailSubmission/get answers notFound for what is an
unsent draft again. That is written down rather than papered over.

Two things fell out of building it. The dispatch moved to after the
flush, because a submission now rewrites a column the worker reads and
handing the bus an envelope before the commit races it. And submit()
clears a stale `cancelled`: the flag is consumed by whichever envelope
comes due first, so a cancelled hold that was submitted again would have
had the *new* send swallowed by the old flag, with "pending" returned
for mail that never went out.

The rest of the envelope is checked rather than applied, since the
pipeline sends the From and the recipients on the row: a mailFrom or
rcptTo naming anything else is refused by name, as are unknown
parameters.

No schema change, no deployment change.
2026-08-06 08:46:09 +02:00
Claude a3a122dc85 Store the attachments an Email/set update names
`attachments` was in EmailPatchApplier::DRAFT_PROPERTIES, so the patch was
accepted, and JmapDraftWriter::update() never read the key. Email/set answered
`updated`, nothing was stored, and a following Email/get showed an empty list
and hasAttachment: false — while a subject in the same patch had applied. A
phone composer, which autosaves and so always has a draft before a file is
picked, had to delete and recreate the draft to attach anything, handing the
user a new Email id every time.

Update now goes through the same attachment path create does, with JMAP
whole-value semantics: the array is the complete set the draft should end up
with, a part left out is removed (bytes as well as row), and a part re-listed
by the `p-` blobId Email/get handed out is kept where it is — same id, no
second upload of bytes the server already holds. A rename on such a part is
applied rather than ignored, because filename reaches the download endpoint and
the outgoing MIME part.

create() and update() share one implementation rather than two copies of blob
resolution: create is the whole-value case with no existing parts. That also
makes create refuse a non-array `attachments` instead of ignoring it, and makes
both paths derive hasAttachment through DraftAttachmentService::syncFlag()
rather than assigning it.

A blobId that will not resolve — malformed, expired, or another account's — is
refused with invalidProperties, and the plan is built before a byte is written,
so a refused update leaves the draft exactly as it was, subject included.

phpstan-baseline.neon loses one entry (the `$stored > 0` comparison went with
the rewrite); none were added.
2026-08-06 08:45:44 +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 629d937d97 Translate the handbook into German
All 34 pages, under docs/de/. The site serves them at /docs/de/, the
language picker switches between them, and a page that ever falls behind
can simply be deleted — the English falls back with a banner saying so.

The handbook duzt. plMail is something a person runs for themselves on
their own machine, and a manual that siezt is describing a different
product. That correction arrived after four of the six translations were
drafted, and those pages were rewritten rather than patched: a handbook
that switches register halfway is worse than either choice consistently.

Two defects in the coverage test, both found by translators hitting them:

  The link check resolved a translated page against the English tree by
  replacing '/docs/de/' inside the DIRECTORY — which never fires for a
  page sitting directly in docs/de/, whose directory has no trailing
  slash. Every page one level down passed and only the language's own
  index failed, so the bug was invisible in exactly the files anybody
  writes first. Derived from the relative path now.

  The traps-section exemption was keyed to literal English paths, so
  docs/de/CLIENT_DEVELOPMENT.md was required to carry a section its own
  source is excused from. A translator dutifully invented one, which is
  the failure mode worth naming: a test that demands nonsense usually
  gets it. The exemption follows a page into its translations now, and
  the invented section is gone.

And one in the site builder, which was breaking English pages too. Links
carrying a fragment were never rewritten from .md to .html — the greedy
target group swallowed the '#', realpath could not resolve what was left,
and the link was emitted untouched. Every cross-page anchored link in the
handbook 404'd while the same link without an anchor worked.

The site's chrome is translated too. A German page framed in an English
search box says the translation was an afterthought.

plMail's own German is now consistent with it: fifteen strings in the
calendar and integration screens siezten while the rest of the catalogue
duzt. They were written today, which is how they diverged.

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