mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
ffeb7732ecc588162a6c79e6198c5826eae32945
63
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
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
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
62ba7cae48 | Merge branch 'feat/public-calendar-style' | ||
|
|
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> |
||
|
|
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. |
||
|
|
9732b51671 | Merge branch 'fix/backup-restore-writes' | ||
|
|
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>
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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> |
||
|
|
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> |
||
|
|
84a5eed8b3 | Merge branch 'feat/push-monitoring' | ||
|
|
2bf3c38add | small fixes that claude couldnt commit because who knows | ||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
a3e37dfc9d |
Merge branch 'feat/jmap-calendar-expand' into integration/jmap
# Conflicts: # docs/de/CLIENT_DEVELOPMENT.md # docs/de/internals/jmap.md |
||
|
|
bafdb26064 |
Merge branch 'feat/jmap-submission' into integration/jmap
# Conflicts: # docs/de/CLIENT_DEVELOPMENT.md # src/Jmap/README.md |
||
|
|
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>
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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>
|
||
|
|
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> |