mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
12842bb3bee616d72e392acc830302afbee896e4
275
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9c20aa8183 |
fix(compose): undo, unschedule, discard and attachment POSTs need a token
Six state-changing compose routes checked ownership and nothing else, so any page the user visited could call off a send, discard a draft or add and remove attachments. They now require the shared 'ajax' token, and every fetch() caller in the compose controllers sends it in the X-CSRF-Token header (unschedule's caller already did). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
8289891830 |
feat: failed requests raise a toast instead of breaking the page
A failed Turbo response used to be rendered: the error page drawn as the page (or merged under it — a picked PDF avatar left plMail's 500 hanging off the settings screen), or "Content missing" in a frame. - request_errors.js catches every Turbo visit, form and frame. A failed response is not rendered; the page stays as it was and an error toast says what happened. 422 (a form with its errors), 401/403 (the session guard) and elements marked data-turbo-errors="self" (the compose dock) are left alone. A network failure gets its own words; a frame response of the wrong shape is visited as a page. - requestFailed() gives controllers' own fetch() calls the same toast. An audit of the 60 calls fixed the 22 files whose user-initiated requests failed silently (star, archive, labels, drag and drop, bulk actions, reorders, appearance save, undo/cancel send, and more), and left background polling and preferences quiet on purpose. - Every request gets an 8-hex id: X-Request-Id on the response, `id` on its log entries, "Ref. …" in the toast with a copy button, and a Reference filter in Admin → Logs. A proxy's own id is kept. - toast.js clones the server's toast partial, so a script's toast is the same pane and theme as a streamed one. The sync button uses it, and its hardcoded English is now translated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
3bff24340a |
feat: the picker for send-later and booking hours; month arrows
calendar--when now takes three shapes: start and end with days (the event editor), one date and time (compose's send later, bounded by the field's min and max), and times only (a booking page's daily hours). The shared attributes and words live in calendar/_when.html.twig. Also: - the month view has previous/next arrows, stopping at the bounds - "Quarter hours" rounds a :05 to the nearest quarter and closes; it used to reopen at once because the minute sat between quarters - the all-day label hugs its text, so the empty row under the picker no longer ticks it - "Today" becomes the weekday when the strip is narrow, asked of the strip's own width - ui--datetime, the interim text time field, is gone with its last use Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
a9486c6ec2 |
feat(calendar): a start-and-end picker in the event editor
Two cards say when the event starts and ends; one panel under them sets whichever is chosen: a six-day strip (a month behind the calendar button), then the hour, then the minute (every five minutes and an exact field behind "···"). Picking the start's quarter-hour moves on to the end, which also offers length chips. Choices that would put the end at or before the start are disabled. On the 12-hour clock the grid is 12, 1…11 with am/pm; all day hides the times. The form's startsAt / endsAt inputs stay, hidden, as the posted and validated values; calendar--event-form still keeps the length when the start moves. It replaces the browser's datetime-local, whose time and date follow the browser's locale rather than the user's settings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
6ec93e098a |
fix: time fields follow the 12/24-hour setting, not the browser's locale
The event editor, the custom send-later time and the booking page's
hours used native datetime-local / time inputs, which print the time in
the browser's locale whatever the page says: "03:00 PM" for someone
who chose the 24-hour clock.
ui--datetime keeps the native input as a hidden value carrier (same
name, id, ISO value, targets and actions) and draws a native date field
plus a text time field printed by the clock setting and read liberally
("15", "1530", "3pm", "3:30 p"). Edits fire input/change on the
carrier; writes to it redraw the fields; focus and aria-invalid are
passed through; the label is re-pointed and the time field is named
"<label>, time".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
881b7026b3 |
feat: a running clock, in the topbar, under the name or in the sidebar
Settings → General → Clock gains "Show the time": - On the Happening Soon button (default): the button shows the time on every page, falling back to a clock icon when nothing is coming up, and the panel it opens is headed by the time and today's date. - Under the plMail name: weekday and time as a second line. - At the bottom of the sidebar: a larger time, the date and the next thing coming up, which opens Happening Soon. - Nowhere: the topbar as before. The server prints the first reading in the user's zone and format; ui--clock advances it on each minute boundary and when the tab becomes visible, and rewrites date text only when the day changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
db1d2c2ea1 |
feat(labels): a rename reaches every tab, not just the one that made it
The price named in v0.2.34: a list navigation stopped re-rendering the sidebar,
so a label renamed in another tab or on another device waited for the next sync
instead of arriving on the next folder click. This pays it.
`labelListsStream()` had already built the markup — it is the answer to the
request that is publishing — so the stream itself goes on the wire rather than a
nudge. A nudge would throw that markup away and make every listening tab go and
ask for a label tree somebody already had in hand: a round trip plus about a
dozen queries each, which is precisely the cost v0.2.34 removed. The usual
objection to putting content on a wire, that it becomes a second copy of a rule
living elsewhere and the two drift, cannot apply here: the payload IS what that
one home produced. Same topic and same shape as SendOutcomeNotifier, which has
published server-rendered stream markup all along.
Scoped to `mail/user/{id}` built from the session's own user, never from a
request parameter — byte-identical to the topic both cookie minters authorise,
and `subscribe: []` in mercure.yaml means no cookie grants anything wider. The
test asserts the owner's id is the topic, that a second real user's is not, and
that the second user's label name does not appear in what is published.
**The toast stays off the wire.** `_lists.stream.html.twig` is rendered twice —
once with a null message for the wire, once with it for the response — because
otherwise every other tab would pop "Label updated." for something that did not
happen there. Measured: the second render costs zero extra statements, the tree
being read once and SidebarCounts memoising per request.
A hub that is not there is not an error. The publish is wrapped and logged like
every other notifier, because running without Mercure is a supported way to run
plMail.
Not covered, and written down where it will be found: a label renamed through
JMAP's `Mailbox/set` does not publish. That path has no rendered sidebar in hand
and building one would put a dozen queries and a Twig render inside a JSON API
method, whose failure would then fail the API call.
The e2e spec has a negative control — commenting out the publish makes it fail
with "the rename never reached the second tab" rather than passing anyway.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
1a735d6f33 |
fix(mail): a sidebar link must not claim a frame the page has not got
Clicking Inbox in the sidebar from the admin page rendered the message list on
a bare page — no sidebar, no topbar, nothing. Introduced in v0.2.34, which gave
those links `data-turbo-frame="inbox-list-frame"` so a list navigation would be
answered with the list. `_sidebar.html.twig` is shared by four layouts and only
one of them has that frame: admin, calendar and settings extend the plain app
layout, so on all three every folder link named a frame that was not there.
**The mechanism is the prefetch, and it is worth writing down because the
obvious theory is wrong.** Turbo handles a missing frame correctly: measured
with sixteen seconds between hover and click, past the prefetch cache's life,
the click issues its own request carrying NO `Turbo-Frame` header and gets the
whole document. It is the *prefetch on hover* that does not check, sends the
header, and puts a fragment in a cache keyed by URL alone — and the click that
follows makes no request at all. Measured:
t=0ms HOVER
t=150ms FETCH /b turbo-frame: list x-sec-purpose: prefetch
t=1150ms >>> CLICK <<< (no request)
t=2150ms settled: body = <turbo-frame id="list">… sidebar: FALSE
So the markup no longer names a frame at all. It carries an inert marker and
`ui--sidebar` promotes it to the real attribute only where `inbox-list-frame`
is in the live DOM. A Twig wrapper that set the attribute per host page was
written first and measured dead: Turbo does not inherit `data-turbo-frame` from
a non-frame ancestor, only from a containing `<turbo-frame>`. The client-side
answer is also the only one that can work at all, because two of the three
places that render these rows — `_account_folder_rows` and
`label/_lists.stream` — are rendered by their own actions and never learn which
page they are on.
**`Vary` was needed after all**, and the paragraph in ListFragmentGlobal saying
otherwise was wrong. It argued that `max-age=0, must-revalidate, private` left
nothing for a cache to confuse. Measured in Chromium, twice: a request with no
`Turbo-Frame` served the stored FRAGMENT from cache without a request, and
separately, one answered 304 rendered the stored fragment — the reported
symptom with no prefetch involved. `must-revalidate` governs staleness, not
representation, and `private` licenses the browser's cache rather than
excluding it. Both representations now carry
`Vary: Turbo-Frame, X-List-Fragment`; one-sided would still let a stored full
page answer a frame request.
The three tests already in ListFragmentTest passed against this bug because
every one of them starts on a mail page. The new ones start where the report
did, and were checked against v0.2.34: `/admin` shipped eighteen links claiming
a frame it has not got.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
3bd1ac7199 |
perf(mail): answer a list navigation with the list
A search cost 29 database round trips. One of them was the search. Two findings, both measured with QUERY_BUDGET_VERBOSE against a fifty-row page. **Five of those statements were the same question asked more than once**, by services with no way to see each other. The sidebar's system block fetched Inbox, Archive and Trash a row at a time; the topbar's calendar dot and its "happening soon" trigger each read the visible calendars; the health indicator read every account and every calendar a second time. The roles arrive in one `IN (…)` now, and the calendar and account reads are answered from a single per-request read the narrower questions filter in memory — equivalent because each narrow query was its superset plus one predicate, with the same ordering. Nothing is cached past the request and the write paths still go to the database directly: a list of calendars held past the moment one is created is worth more than the query it saves. 26–29 statements down to 21–24, and no repeated shape left on any of the six lists. **The rest was a whole document rendered for a navigation that wanted a list.** Choosing a folder, a category tab, the next page, a different sort or a search navigates the message list and nothing else — and Turbo, on a frame navigation, already takes the one frame it asked for out of the answer and drops the rest of the body. The sidebar, topbar, calendar pane and reading pane were being rendered and sent so the browser could throw them away. Those navigations are answered with the frame now: 21–24 statements down to 9–13, and the sidebar keeps its scroll position and open sections instead of being rebuilt into the same shape. **The answer still carries the document's head, in full, and it has to.** A title alone is not enough, which was the first attempt and it failed silently — the URL advanced and the tab went on reading "(3) Inbox". Turbo gates rendering on `shouldRender = isVisitable && trackedElementsAreIdentical`, and importmap() emits three `data-turbo-track="reload"` elements that no template writes; a head without them has a different signature, so nothing renders at all. Past that gate `mergeProvisionalElements()` deletes from the live head every meta the answer omits, which is the csrf token, the cache-control meta the back button depends on, and the title itself. One rule clears both: send the head the page would have sent. `data-sync-scope` moved onto the frame. Outside it, a navigation left the page polling for the folder it had just left. The named cost: a label renamed in another tab or on another device reaches this tab's sidebar on the next sync rather than on the next click. Everything else it shows is already pushed — label mutations re-render it through labelListsStream, counts through refreshCounts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8dc22b49c0 |
feat(search): show which words a result matched on
A row asserted a match and then showed something else. The preview was the opening hundred characters of the body, which for most hits does not contain the search term at all, and nothing was marked — so the only way to learn why a conversation was in the list was to open it. Every occurrence is marked now, in the subject and in the preview, and the preview is a window of the message AROUND the match. The fragment comes from the same Postgres `ts_headline` the JMAP snippet has always used, over the same query that found the mail, so the marking is stemmed exactly as the search is and cannot mark something the search did not match on. **It also closes a hole that was open in the JMAP API.** `ts_headline` is a highlighter and not an escaper: it inserts the delimiters it is given and returns the rest of the document byte for byte. Asking it for `StartSel=<mark>` produced a string in which the sender's markup and ours were the same kind of thing, and the check that stood in for escaping — "does it contain `<mark>`" — could not tell them apart. A subject or text part carrying `<img src=x onerror=…>` shipped it to every client as snippet HTML, and per RFC 8621 a client is right to render that. The class docblock claimed the opposite and had done since it was written. The delimiters are two control characters now. The whole string is escaped and only then are they swapped, pairs only, in one class that owns both the options and the conversion so they cannot be changed apart. `toHtml()` answers JMAP; `toMarkup()` answers Twig, which is what lets the row print a fragment without `|raw` — that file still does not contain one. Three things worth knowing: - `ShortWord` went from 3 to 0. At 3 `ts_headline` trims short leading words, so "Oak for the alcove" searched for `alcove` came back as the bare word — a row that has rewritten the subject rather than highlighted it. - A highlighted subject is used only when it still equals the thread's subject with the markers removed; anything else falls back. And it goes to a separate variable, because `subject` is also spent on `aria-label`, where a screen reader announcing the markup would be a worse regression than the one this fixes. - The headline replaces the preview only when it actually contains a marker. That makes the two documented `body_text` regressions self-sorting: a bulk sender's "this email is only available in HTML" does not contain the term, so it does not win. One batched query per account per page, not one per row: the budget test is a hard ceiling and a per-row headline would blow it instantly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e97046472d |
Hold the list refresh while a conversation is being dragged
The inbox stopped answering the mouse. No mousedown, mouseover, click or keydown reached the document — and yet a programmatic click on a tab still ran the whole Turbo chain and rendered correctly, so nothing was wrong with the page's JavaScript. Chrome still believed a drag was in progress and was eating the input upstream of DOM dispatch. THE ROW LEAVES AND THE DRAG NEVER ENDS. Every listener in mail--dnd is on <body> and depends on the event bubbling up from the row. A refresh landing mid-drag — new mail over Mercure is the ordinary way — replaces the list, and an event dispatched at a detached node has nothing to bubble THROUGH. dragend is never heard, #teardown never runs, and `data-dnd-active` stays on <html>, which Turbo does not replace. So the fix is to stop the row being pulled out from under the gesture rather than to clean up after it has been: a native drag session belongs to the browser and has no abort, and nothing the page can do will end one. mail--mail-pane#hold already says "not now" for exactly this class of reason and defers rather than drops — _refreshList remembers it and release() takes it immediately — so a drag holds it and the mail that arrived meanwhile appears the moment the drag is over. Three smaller things behind it, none of them the fix on its own: dragend is bound to the row as well as to <body>, so it is heard from a row that has left the document. Measured rather than assumed: with a drag in flight, removing the row cleans up nothing at all, and dragend on the detached node clears every mark. #onDrop used to return early when the target no longer resolved, leaving #carrying set and the hold outstanding, and relied on a dragend arriving afterwards — the one event this file can no longer assume. turbo:before-render and turbo:before-frame-render tear the marks down, so a render that reaches us anyway cannot leave drop-target highlighting lit. #teardown is reachable from five directions now, so the release is guarded on there having been something to release. An unbalanced one is not merely untidy: mail--mail-pane#release clamps the count at zero but refreshes the list frame on reaching it, so a spurious release costs a fetch of the whole list. dnd-lifecycle.spec.ts pins the three paths, and all three fail without this. Its header says why it dispatches events where dnd.spec.ts drives a real pointer: that file owns the gesture, this one owns the bookkeeping, and a real drag is the wrong instrument for the second rather than the expensive one — the browser will not deliver script-driven input while it owns a drag session. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9835756aec |
Make the calendar hold together at every width
Seven columns used to divide whatever width there was, so a phone — or a narrow docked pane — gave each of them about fifty pixels, and a block showed its colour and little else. A column has a floor now: below the width where seven of them fit the grid pans sideways under an hour gutter that stays pinned, and four days are legible where none were. The time-grid shell's own header costed that fix as disproportionate once — "three scroll containers kept in step by JavaScript" — and it is not, because the three grids already share one scroller; all that was left was making each one's first cell sticky. A BLOCK IS TWO ROWS NOW. The time on one line with the calendar's colour as a bar down the left, the title under it, which is a whole title in a 96px column where one line was four characters of each. Day view keeps the single line and is told so rather than measured for it: a day column is the whole grid and is never the width that makes stacking worth doing. The agenda's date rail is gone. A day is a heading with its events under it, and the times sit in a lane that is a grid track rather than a fixed guess — sized against every time in the view, so it is 43px where an "all day" needs it and 33px where nothing does. The month gives up its words entirely below 5.5rem of cell and draws one bar per entry, which is what a phone can honestly say in forty pixels, and "N more" is gone along with a count that a variable cell height could never make true; the date is the link into the day instead. The grid opens on now rather than on a fixed 07:00, and a chip too small for its own title opens to it while the pointer rests on it. Width only: growing the height made the chip inside stop following its parent, and a click aimed at the middle of a Day-view block landed under the button and opened nothing. Two bugs fell out of the work. An all-day entry sorted below the morning east of UTC, because its start is a floating midnight in UTC while a timed one's is an instant — the time grid never showed it, since it puts all-day entries in a band of their own and never asks. And a grid block had never had a background at all: an inline colour was overriding the surface class written beside it, so the only paint it carried was a tenth of a calendar colour over whatever happened to be behind it. Opening on now has one consequence worth naming: a fixture at 10:00 is on screen all morning and scrolled above the viewport by the afternoon, and `boundingBox()` answers for an off-screen element with coordinates that land under the pinned day headings. calendar-timegrid's drag helpers scroll the block into view before measuring it now; without that the suite passed for hours and then did not, and the failure read as "dragging is broken". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
38110256b9 |
Send the account menu to the top layer
Reported as sitting behind calendar furniture. Its `z-[200]` read as "above everything" and meant nothing of the sort: it ordered the menu inside the HEADER's z-20 stacking context and said nothing about the header against the rest of the page, so it was on top only for as long as nothing outside the header made a context of its own. The calendar keeps making them — especially once a background image turns the panes' backdrop-filter on, which is the appearance the report came from. I could not reproduce the collision: four widths, both appearances, the page and the docked pane, and the menu was on top in all of them. So this fixes the property rather than the sighting. The top layer is above every stacking context in the document by construction, which makes the whole class impossible instead of making this instance less likely. MANUAL, not auto. _thread_row.html.twig records the top layer being tried on two menus and reverted because their options silently stopped firing, cause unfound. `auto` brings light dismiss — the first pointerdown closes the popover, so the click meant for an option lands on nothing and no request goes out, with nothing in the console. `manual` has none, and the controller's own document-click handler closes it exactly as before. Verified by clicking through: Settings navigates, and the menu survives a fixed z-index 2147483647 overlay across the whole viewport. A popover's containing block is the viewport, so the positioning the classes used to do is measured from the avatar and written on open, and cleared on close. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
834b0256db |
Release v0.2.25
Let a filter choose the folder it saves attachments into. "Save attachments to Nextcloud" had no folder beside it, so every filter-driven upload landed in the root of the connection — while the same save done by hand from a message opens a browsable chooser. The rule editor now opens that same chooser rather than a second one that looks like it. The destination picker grew a part-less mode: a save from the reading pane has an attachment in hand and browses to a folder to put it in, a rule is naming a folder for mail that has not arrived, so with nothing in hand the choice is reported back to the editor instead of uploaded to on the spot. Presence of the part field is what tells the two apart — an explicit part=0 still 404s, so the save path keeps its ownership check. Nothing behind the editor needed building. RuleActionExecutor, the queue message, the handler and the driver have all carried a folder the whole time; there was no way to set one. Released together with the bulk-queue work in the commit before this one; the changelog entry covers both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ab9ecbc9e0 |
Hand a whole-view bulk action to a worker, which it never was
RunBulkStatusHandler exists to take a mark-read or archive over a whole view out of the request — its docblock quotes the thirty-second timeout that produced it, and BulkStatusController answers with an indicator so nobody waits. Neither was true: the message was never listed in messenger.yaml's routing map, and Messenger handles an unrouted message inline in whichever process dispatched it. Every whole-view action ran inside the web request, under the limit it was written to escape, at request concurrency — which is where the production deadlock came from. It gets a queue of its own rather than joining maintenance, because somebody is watching an indicator for it and queueing it behind an embedding backfill would be slower than the inline version it replaces. That means one new container, worker-bulk, in every compose file and in the handbook. The deadlock itself is retried now instead of failing the job, three times per chunk, and a collision that outlasts those hands the job back to the queue rather than ending it. The failure path no longer writes through an EntityManager Doctrine has closed, which is what it was doing for every failure that came out of a flush. And the list catches up: nothing ever refreshed it on the strength of the job, only on the toolbar's own "the write landed" event, which is now the instant the work was QUEUED rather than finished. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bddd6eed97 |
Release v0.2.22
Keep a day heading inside the day it names, and a digit at digit width. Three reports, two causes. The week headings overflowed their own columns below about 80px of column — the + for Monday was drawn over Tuesday's heading and still created events on Monday — and today's ring was squeezed to min-content and clipped 2px by the scroller it sits at the top of. The composer drew every number from Noto Color Emoji, which covers `#`, `*` and 0-9 as the bases of the keycap emoji, so ten digits measured 170px where the text around them draws them in 84. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d53e7c4963 |
Report browser errors to the admin panel, in a card of their own
A JavaScript fault was visible only to whoever had the console open, which on a self-hosted mailbox means nobody. Uncaught exceptions and rejected promises from plMail's own scripts now reach Admin → Logs, together with whatever the Content-Security-Policy blocked. Its own card rather than rows in the log, because it holds a different kind of thing. A server error is one event in one request; a broken line in a controller runs on every page load for every user. So a row is a distinct fault — message, file, line — with a count and a lifetime, and the same fault reported again increments it. Merged into the log, one bug would be four hundred rows and the log would be unreadable past it. THE FILTER IS THE FEATURE. Most of what window.onerror sees is not ours: the console screenshot that prompted this was an ad-attribution metrics script failing inside requestIdleCallback, injected by an extension. A panel that accepted that fills with other people's bugs and stops being read, which is the same as not having one. A report is kept only when it can be attributed to a script this server sent — an injected script has no URL (Chrome's `VM947`), and a cross-origin one arrives as the bare string `Script error.` with no file, line or message. The rule is applied in the browser and again in the recorder, because the browser half is part of the code being reported on. CSP violations come to the same card through /csp-report. That endpoint cannot be CSRF-protected and is public in security.yaml, because the browser generates the request itself: no token, no header we chose, and on a login page no session. The rate limiter is the bound. It is worth having because the full policy is enforced in production, so what arrives is what this installation actually refused to load in a real browser. The count means page loads, not executions — the client reports each fault once per page, capped at five. Measured rather than assumed: throwing the same error twice on one page records one sighting. Documented that way on both sides after the first version claimed the server would count what the client never sent. `column` is a reserved word in Postgres, so the field is `column_number`. The quoted DDL created the table happily and every insert into it was a syntax error — same trap User::$usr already avoids. Verified on a running stack by throwing real errors in a real browser: an error from a script on this origin recorded, an anonymous injected one refused, the card rendering both kinds grouped with counts. Its first catch was a false alarm, and a useful one to record. It reported the Mercure connection as blocked by `connect-src` — which was true, and was me browsing `localhost` while MERCURE_PUBLIC_URL names `127.0.0.1`. Different origins, so the app's own policy refused its own hub. Browsing the host the config actually names produces no violation at all. The compose comment calling that URL "same-origin" is correct on its own terms; it just stops being true if you reach the stack by another name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
044aa01846 |
Reported mail is one feature: one list, one triage, one export you choose
Two kinds of report — a mail an extractor should have understood and did not, and a mail somebody found in the wrong tab — were two tables behind two panels with two export buttons. That split made sense to whoever wrote them and to nobody using them: an admin opening this section is asking "what is still mine to do", and that question does not have two answers. The tables stay apart, because the evidence really is different: a wrong tab is a disagreement between three classifiers, a missed insight is a mail body a parser could not read, and making them inherit would put a dozen always-null columns on each row and tie a schema change on one to the other for ever. What is unified is everything above them. ReportedMail carries the handful of fields a triage list is built from and the entity alongside for the fold and the export; ReportedMailProvider is the only place that knows there are two tables; the panel, the buttons and the file are written against the pair. Category reports gained handledAt, which is what makes them belong in the same list — a worklist you cannot cross anything off is not a worklist. Before this the only way to stop looking at one was to delete every one of them, taking the evidence for a rule away at the moment somebody might want to check it again. The export's scope becomes the selection. "Everything, or only the unhandled part" answers the question an admin has on their first visit and none of the ones they have afterwards; the one that comes up is "these six, the ones about the shop". So the list filters — by status, and by the problem itself, with the chips derived from the rows because for a wrong tab the problem IS a pair of categories and only this install knows which pairs it has produced — and the export takes what is ticked. Filtering never unticks anything: a row hidden by a chip is out of the selection while it is hidden and comes back ticked, so a selection built across two chips survives. An empty post still means everything, which is what a browser without JavaScript sends. Each record now carries its kind, every field its kind has, and the same one-line rendering the panel shows and the copy button copies. The document is plmail.reported-mail version 2 — a new name rather than a bumped version alone, so a consumer written against version 1 fails on the name instead of half-reading a file whose records it only understands some of. The row is a <div> and not a <label>, though a label around the whole row would give the checkbox a better hit target: a label may not contain a <form> or a <details> and both are there. Chromium happens not to forward label activation from a nested button, which means the invalid version works right up until the row is read by something that is not Chromium. Verified on a running stack: six reports of both kinds in one list, the status chip hiding the handled one, select-all writing five keys into the export form, a problem chip narrowing that to one without losing the other four, and the export returning scope:selection with the full record and its line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d42012cedf |
Report the category the tabs actually sort by, and the evidence behind it
The first real miscategorisation report read `filed:updates should:updates` about a conversation its owner was reading in Primary. Both halves of that were wrong in the same way: `filed` was `message.category`, and the mailbox lists threads and filters the tabs on `thread.category`. A thread takes one category from its newest message and a pin overrides it outright, so on any conversation with more than one message the two are free to differ — and it is the thread that decides which tab somebody is looking at. So the report described a column nobody can see, and the popover's "category" line did too. Both now read the thread, falling back to the message for one not yet threaded, and both say so when the two disagree: the panel explains that the conversation takes its category from its newest message, which is the difference between a panel that looks broken and one that is informative. The report row was also too thin to act on. It carried the four verdicts and none of the inputs they came from, so a line could be entirely true and still leave nobody able to say what to change. Added: the message's own category when its thread disagrees, whether a pin is holding it, whether the reader had asked for the provider to be overruled, whether the model was asked at all (`model:-` could not tell "never asked" from "asked, no answer"), whether there was any plain text for it to read, and List-Id's value — the one bulk header whose value is a rule-writing input rather than a tracking link. The card moves from Admin → AI into Reported mail, where somebody who has just pressed Report goes looking for it, and is grouped by `filed → should` rather than listed by date: twenty reports are rarely twenty problems, and the fix for one group is not the fix for another. Each group and each row can be selected, and the selection can be copied or downloaded as a text file — so whoever is changing one rule can take the reports about that rule and leave the rest. A report that agrees with where the mail already is now 409s rather than landing as a row saying nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fabaf49a80 |
Let a reader report mail as filed in the wrong tab
Categorisation is decided by three things that disagree — a provider's labels, a header cascade, and a model — and whether the answer is any good is a judgement only the owner of the mailbox can make. Everything else in this feature can be measured against fixtures somebody invented. This is the one route by which real mail, wrongly filed, becomes something a rule or a prompt can be changed on the strength of. The control sits at the bottom of the header popover, where the three answers already are. A SELECT AND A BUTTON rather than five buttons: knowing a message was misfiled is a fraction as useful as knowing where it belonged — "promotions, not primary" is a rule somebody can act on, "wrong" is a shrug. The tab it is already in is not offered, because "it should be where it is" is not a report. It records the DECISION and its evidence, not the message: what each of the three said, what the rules matched on, which of them was deciding, which bulk headers were present, the sender and the subject. Not the body — the most sensitive part of a message and the least useful here, since most marketing mail has no plain-text part at all and the classifier sees "(no plain text part)", so the column would be empty for exactly the mail most often reported. Sender and subject ARE kept, and on a multi-user installation an administrator reading these sees them for other people's mail. That is a real cost, taken deliberately: a report saying only "something was wrong" is not worth the button, and it is why nothing is recorded without somebody pressing it. The reports collect in Admin → AI rather than a nav entry of their own, because that is where somebody ACTS on them — the prompt and the model are a card above. The product is the TEXT: one line per report, built in the entity rather than the template because a format assembled in Twig would change whenever the table's layout did. Copy takes the block; clearing is a deliberate press, since these are observations nothing regenerates and a pattern across twenty of them over two months is the whole value. No foreign key to `message`: a report outlives the mail it is about. Somebody deletes the message, the pattern it was evidence of does not go away. Verified on the stack: the control renders, a report returns 204, a hand-edited category is refused with 400, the line appears in the admin card, and clearing empties it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0a3efba33c |
Make the appearance preview a working miniature
Three rows and a still frame left most of a full-height card empty, and the setting somebody is watching is DENSITY — which is a property of a list long enough to have a rhythm, and of a reading pane you can actually put something in. EIGHT ROWS, and the list is what fills now. The two fill utilities swapped round: the list was `pane-fill-keep` — content height — with the reading half taking everything left, which on a full-height card is eight rows above a great deal of one sample message. Measured in a browser with the real stylesheet: the list now takes 230px of a 522px card and scrolls inside itself, the reading half 174, and nothing overflows sideways. `min-h-0` is the load-bearing half of that swap; without it the list pushes the card taller instead of scrolling. THE ROWS ARE BUTTONS. Pressing one shows that message in the half below. Every pane is in the markup and this toggles which is hidden — building the sample in JavaScript would put eight senders and a body in a second place and translate them a second time, which is the thing this file's own header refuses about the thread row it is a miniature of. THE HEADER DROPDOWN AND THE COMPOSER, both static, both closed to begin with. The dropdown carries the same From/To/Date the real one does; the composer is a miniature of the real window's parts and not the window, which is a Turbo frame with a live editor, a draft, an attachment queue and a controller of its own, and cannot be rendered without a message to hang it on. What the appearance settings actually change here is the sheet, the radius, the density and the type, and this carries all four. Switching rows closes the dropdown — it belongs to the message — and leaves the composer open, because somebody clicking through rows to compare densities wants it to stay put. STILL aria-hidden, and the rows being buttons does not change that. Nothing in here goes anywhere: pressing a row swaps a picture. Eight invented senders in the tab order between one real control and the next would be worse than useless, so the buttons are `tabindex="-1"` — aria-hidden and focusability disagreeing is the one combination that produces a focus stop announcing nothing. Verified in a browser rather than asserted: row 1 → 5 → 2 swaps the message, the pressed row takes the list's ground and the previous one loses it, the dropdown opens with a locale-formatted date, and the composer survives a row change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
408fcd1bed |
Run full-conversation summaries as a job, with nothing held open
A full run is silent for minutes while the model reads, and for the whole of
that silence the answer depended on a browser connection staying open. That
dependency is what has been failing, and no timeout could have fixed it:
full: true, transcript_chars: 67720, elapsed_seconds: 40, chars_so_far: 0
Forty seconds, four heartbeats already delivered, nothing written yet, and a
model host that was up for seven days either side of it. The connection was
alive and being written to and then was not. Nothing in plMail closed it — a
held-open connection has a proxy, a browser and a network in it, and any of them
may end it without telling this application. Three rounds went into tuning
timeouts before the metrics panel said `cancelled` and named the real shape.
So the full run stops needing a connection. It dispatches SummariseThreadMessage
and answers 202; a worker on the maintenance transport does the work, writes to
the store and publishes a Mercure nudge; the card shows it as queued and fills
in when it lands. The reader may close the conversation, the tab, or the laptop
and still find the summary waiting — which is what somebody just told "this
takes several minutes" would reasonably expect anyway.
maintenance rather than ingest, deliberately: this holds a consumer for minutes,
and mail arriving must not queue behind somebody's summary.
The nudge carries no text. Same shape as JobNotifier and the insights strip —
the store is the record and a missed message costs one stale render — with one
reason of its own: what it would be carrying is a paragraph about somebody's
mail, on a channel that has never held any.
TWO BUGS RETIRED ON THE WAY. The streamed path is now never full, so the
transcript it hashes for freshness is always the one the reader's page
recomputes; a full run used to store a hash that could never be reproduced,
which would have shown every full summary as permanently out of date. And the
handler hashes the ORDINARY transcript for the same reason while sending the
full one to the model — the hash answers "has this conversation changed", and
the only answer that matters is the one the reader will ask.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
cae763cc26 |
Say when it was the connection that failed, not the summary
Everything reaching the fetch's catch is a request that never got an answer, or an answer that stopped arriving part-way. That is a different fact from "the model could not write one", and the card said the second for the first — which is why four rounds of diagnosis went at the model host while the fault was between the browser and this application. The measurement that made it obvious: a full run on 67,720 characters, abandoned after 40 seconds with nothing yet written to the browser. The heartbeat had put four frames on the wire by then, so the connection was alive and being written to and then was not. Ollama was up for seven days across the whole of it. The two halves now name each other. The card says the connection was lost and that the model host is not involved; the server logs "Thread summary abandoned before it finished" with how long it lasted, because from in there a cut connection and a reader who navigated away are the same event. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d5bec20032 |
Keep the connection alive through the silence, and say how long it has been
`kind: timeout` has now been reported three times and answered nothing, because the record carried a category and no measurement. Three changes, and the last one is the reason the first two were hard to find. A TIMEOUT CHUNK IS NO LONGER THE END. Symfony yields one whenever the idle timeout elapses and lets the loop carry on; the old code gave up on the first, which made the idle timeout the total budget. That conflated two different rules — "wait ten minutes for the first token" and "give up after ten seconds of silence mid-answer" — into one number, and it meant NOTHING WAS ON THE WIRE for the whole of a long prompt evaluation. A reverse proxy between the reader and this process is entitled to close an idle connection, and most default to well under the several minutes a full conversation needs. The stream then dies with no error, no frame and no log, which reaches the reader as the blankest sentence the card has. plMail documents running behind a proxy; this is what that costs. So the client now wakes every ten seconds, counts the budget itself, and yields an empty string each time. The controller turns that into a ping frame — which keeps the connection demonstrably alive, and makes the abort check reachable during the silence instead of only once tokens start, so a reader who leaves frees the model call in seconds rather than at the end of it. THE BUDGET IS MORE GENEROUS, AND HONESTLY LABELLED. 70 prompt tokens/second came from a figure measured at the ordinary budget, and prompt evaluation does not hold its rate as the window grows — this model is a mixture-of-experts whose behaviour at 32k is not what it is at 2k. A full run timed out again at that size. It is now 25, a quarter of the measured rate, chosen to be wrong in the safe direction: waiting too long costs a reader who pressed a button marked "slower" more of the same, giving up early costs them the whole wait for nothing. Capped at fifteen minutes, because a streamed response holds a PHP worker for the duration. AND THE NUMBERS ARE LOGGED. A timeout now records what it waited against what it was allowed, which is the difference between a budget that is slightly short and one that is out by a factor of five — fixed by different numbers, and guessed at twice already for want of the measurement. Progress, as asked, and only the part that is true. Ollama sends nothing at all while it evaluates a prompt and reports prompt_eval_count only in its final frame, so there is no "message 40 of 120" to be had, and a bar drawn here would animate an assumption. The ping carries elapsed seconds and the card counts up — which answers the question somebody actually has four minutes into a silent card, and which a spinner cannot. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9926fd2e4e |
Wait long enough for the full run that was just added to finish
The full-conversation option shipped without the one constant that makes it
possible, and did not work: every use on a conversation big enough to need it
was abandoned at two minutes with `kind: timeout` while the host was still
working on it perfectly well.
OllamaClient's GENERATE_TIMEOUT is 120 seconds and it is an IDLE timeout, which
ThreadSummariser's own docblock already explained is the binding constraint —
nothing arrives from the host between the request and the first token, because
the model loads and the whole prompt is evaluated before generation starts. At
the ordinary 8,000-character budget that silence is around 42 seconds cold,
which is what 120 was chosen to cover. A full run is twelve times the budget by
design, so its silence is minutes. Sending a bigger context window without also
raising the patience to match was half a feature.
Both ceilings now scale with the transcript, and stay in the order the comments
around them promise:
8,000 chars num_ctx 4,096 idle 129 s
24,000 chars num_ctx 9,216 idle 205 s
48,000 chars num_ctx 17,408 idle 319 s
96,000 chars num_ctx 32,768 idle 548 s
Sized at 70 prompt tokens/second against the 95–107 the reference host measured,
deliberately below the floor: this divides tokens to get seconds, so a smaller
number buys MORE time, and erring the other way is exactly the failure being
fixed.
PHP's own limit moves with it. STREAM_TIME_LIMIT_SECONDS is 300 and its comment
says it sits above the upstream's bound so the upstream gives up first — true
until the upstream's bound moved past it, at which point PHP would have killed
the request while the client waited patiently. An ordinary run keeps its 300 s
untouched.
A third progress state, because both existing ones would be lies. "Waiting"
describes a cold load and promises about a minute; a full run's silence is the
model reading a conversation twelve times longer than the budget, and warm or
cold barely moves it. A progress line that under-promises by an order of
magnitude is how somebody decides the button is broken and reloads half way
through the thing they asked for.
Two more things found on the way there. `Thread summary failed` is logged at
ERROR rather than WARNING: it exists for exactly one situation — somebody
reporting a card that says the summary could not be written — and at warning it
is removable by the capture level on the admin log page, which is the setting an
operator raises to quieten a noisy install. And http_status gets its own
sentence instead of the general one: a refusal means the host answered and said
no, most often because it could not give the request what it asked for, and that
is a different errand from "we do not know".
Also, as asked: the partial notice and the full-conversation offer are cleared
when a run starts, like the staleness notice. They describe a summary that is no
longer on screen, and the offer is one the reader has just taken.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
2bfc30ad8d |
Offer to summarise the whole conversation, and send a context window with it
The card now says when a summary was written from part of a thread, and the way out sits beside the notice that explains why it is there. num_ctx IS THE WHOLE MECHANISM. This client has never sent one — four docblocks across the codebase size their budgets around "OllamaClient sends no num_ctx, so the model's default decides what survives", and that default is 4096 tokens with everything past it dropped silently. So "send the whole conversation" without also asking for somewhere to put it would send more, have it truncated at a point nobody chose, and spend a much longer wait to produce no more than before. It is sent only when a caller asks, so every existing call keeps its shape. FULL IS STILL BOUNDED, and that is the honest part rather than a caveat. A context window is memory: Ollama allocates the KV cache for the whole of it whether the prompt fills it or not, so an unbounded "send everything" lets one absurd thread ask a modest host for an allocation it cannot make — and a failed allocation costs the reader the entire wait and produces nothing, where a clipped transcript produces a summary and a sentence saying it is partial. The ceiling is 96,000 characters against a 32,768-token window, twelve times the ordinary budget. Past it the run is full AND partial, and the button withdraws rather than inviting the same wait twice. The window is SIZED to the transcript rather than fixed, since it is reserved whole either way and a two-message thread should not cost what a hundred-message one does. Sized at 3.0 characters per token, deliberately below the 3.55 measured on real German business mail: this divides to get tokens, so erring low overestimates the need and asks for slightly more room than strictly required. Erring the other way would silently truncate inside the one feature whose entire purpose is not to. thread_summary gains full_context, and it cannot be derived like the rest of the card. "Is this thread too long to send whole" is a question about the thread and comes free from the transcript the freshness check already builds. "Did THIS summary see all of it" is a question about the row, and the thread cannot answer it — a thread that is still too long is still too long after somebody presses the button and gets a summary that did see everything. Without the column that press would appear to do nothing: new text, same notice, much longer wait. A press and not a setting, deliberately. It is a fair thing to spend once on a thread somebody cares about and a poor default for every summary in a mailbox, so the cost is written into the label and nothing is remembered. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5ca9aab2ee |
Say when the summary was written from part of the conversation
The transcript is trimmed two ways — whole turns dropped from the middle, and now a single oversized turn clipped at its end — and the card said neither. A paragraph of assertions about somebody's mail, written from the first third of one message, read exactly like one written from all of it. That is the degradation the elision marker already refuses one level up, surfacing again at the level the reader actually sees. Derived rather than stored. MailController::storedSummary() already rebuilds the transcript on every thread open for the freshness hash, so isPartial() is one substring search on a string that is already in hand: no column, no second return value, and no third copy of a fact to fall out of step. It also makes the answer describe the thread AS IT IS NOW, which is the honest reading of "did the model see all of this" — a thread that has grown past the budget since the summary was written is partial today whatever it was then. A separate line from the staleness notice, and both can show at once. They are different warnings: stale means the thread moved on after this was written, partial means the model was never shown all of it. Regenerating fixes the first and cannot fix the second, so one combined sentence would offer a button that does nothing about half of what it claims — which is why the JS sets this one both ways from the run's own answer, where #stale only ever clears. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
423ff9da87 |
Make a failed summary say something, and bound the transcript
"The summary could not be written" is the sentence for three error kinds, and
none of them left a trace anywhere. A thread that failed on every attempt while
every other thread in the mailbox worked was undiagnosable from outside the
process. Four changes, in the order they were found.
THE HTTP BODY IS READ NOW. chatStream() returned the category for a non-200 and
dropped the response on the floor. Ollama's body is the diagnosis — it answers a
missing model with {"error":"model 'llama3.1:8b' not found"}, naming the model
as the host understood it — and that is how a wrong tag or a model nobody pulled
gets found. It is logged, and only logged: the ANSWER is quotable, the request
body is somebody's mail.
http_404 IS NAMED. It fell through to the general sentence on the grounds that
"the composer's panel is where a missing model is worth naming". The reader of a
thread is not always an administrator and cannot be sent to a panel they may not
be able to open, and the summary card is where this is actually met — every
time, for as long as the model stays unpulled. Search has never hidden it.
THE FAILURE KIND IS LOGGED. http_status and unexpected still render as "could
not be written", which is honest for them and a dead end for anybody trying to
find out why: the frame goes to a reader who cannot act on it, and the request
finishes 200 because the stream had already started. Now the thread id and the
kind are in the log, so the sentence has somewhere to lead.
MALFORMED UTF-8 IS REPAIRED RATHER THAN FATAL. Symfony's json option refuses it
outright — "Invalid value for json option: Malformed UTF-8 characters" — thrown
before the request is made, so no host, status or model is involved. One stray
byte is enough, and mail is full of them: a chain forwarded through a gateway
that believed it was Latin-1 carries 0xFC where an ü was meant. In chatStream()
that reached the reader as the generic sentence; in embed() it was one more
failed embedding, retried nightly and failing identically, so the message was
also permanently invisible to search by meaning. Scrubbed and logged, because
the real bug is upstream in whatever stored those bytes.
AND THE TRANSCRIPT IS ACTUALLY BOUNDED. trimmed() drops whole turns and keeps
the first and last whatever they cost, so on a two-message thread — the first
and last being the entire thread — TRANSCRIPT_BUDGET was not applied at all. Not
rarely: by construction, on most threads there are. One deeply forwarded mail is
a single turn of several hundred kilobytes and went to the model whole, and
nothing reported it, because Ollama answers an 800,000-character prompt with
HTTP 200 and silently truncates to num_ctx. The model summarised whichever end
survived. Turns are now clipped to two thirds of the budget and say so.
Also, unrelated and asked for: the thread view's datestamp carries the year. The
message list can omit it because it is sorted by recency and read top to bottom;
a thread cannot, since a forwarded chain puts a reply from 2019 directly above
one from this morning.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
ffe9457b42 |
Give the search model its query instruction and its own threshold
Search by meaning was answering "mails about food" with a page of login links
and newsletters, ranked confidently, with nothing anywhere saying it had gone
wrong. Two settings decide whether it works and plMail had neither.
THE QUERY INSTRUCTION. Embedding models are asymmetric: they are trained to be
told which side of a search they are looking at. An unlabelled query is treated
as one more document, and it lands nearest whatever is most boilerplate — in a
mailbox, login links and newsletters. Measured on qwen3-embedding:0.6b over
twelve German and English messages, five about food:
query non-food ranked above the best food mail
"mails about food" 5 (raw) 0 (instructed)
"essen" 7 (raw) 0 (instructed)
"restaurant reservation" 2 (raw) 0 (instructed)
"essen" — the least ambiguous word available — put every food mail below every
non-food mail. With the model's own instruction, perfect separation on all
three. It goes on the QUERY ONLY, so stored vectors are untouched and nothing
has to be re-indexed.
THE THRESHOLD. 0.55 was one constant for every installation, and cosine
similarity is not comparable between models — or even between the instructed
and uninstructed forms of one model. Best threshold by F1, same corpus:
qwen3-embedding:0.6b no instruction 0.20 F1 0.59 precision 0.42
qwen3-embedding:0.6b with instruction 0.42 F1 0.85 precision 1.00
nomic-embed-text no instruction 0.45 F1 0.74 precision 0.65
all-minilm no instruction 0.20 F1 0.71 precision 0.77
0.55 is above all of them. So it travels on SemanticSearch next to the model and
width it has to agree with, and it is a setting rather than a release.
Both are columns, and EmbeddingPreset holds the measured pair for each model the
release was tested against, with a line on what it is good and bad at. The model
field stays free text — a closed dropdown would go stale and refuse something
that works — and choosing a preset fills all three fields at once, because they
are one decision written in three boxes: any two from different models give a
search that is confidently wrong rather than visibly broken.
qwen3-embedding:0.6b becomes the documented default. Multilingual matters more
than any benchmark for a mailbox that is not all in one language, and it was the
only combination measured at precision 1.00.
Existing installations keep 0.55 and no instruction. Backfilling 0.42 would
apply Qwen's number to whatever model is configured, and on all-minilm (0.20)
that is twice as tight as it should be — a migration is the wrong place to
change what somebody's search returns. The panel now shows both values and sets
them on one click.
The instruction field is trim: false, which a test caught rather than a guess:
every working instruction ends in a separator, Qwen's is "…\nQuery: ", and
Symfony's default trim turned it into "Query:mails about food".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
3ad1a7a9f7 |
Make a drop on a label add it, and keep moving for the rows above them
Dragging a conversation onto "Receipts" took it out of the inbox,
because every drop was a move. That is right for the rows above LABELS
in the sidebar — Inbox, Archive, Spam, Trash, and each account's own
folders are places, and dropping into one means the mail is there and
nowhere else. It is wrong for a label, which is something a conversation
wears rather than somewhere it is.
The distinction belongs to the TARGET rather than to the label, and it
has to: the same custom label is rendered in this sidebar twice. Under
LABELS it means "everywhere" and dropping there tags; under an account
it names that account's own folder and dropping there files. Nothing
about the label can tell those apart — only the row can. So the sidebar
emits `data-dnd-folder` for one and `data-dnd-label` for the other, and
the controller reads which it landed on.
Two attributes rather than a `mode="move|label"` string because the
controller selects on them, and a selector for an attribute VALUE is one
typo from matching nothing quietly.
/status/bulk/label applyLabel(attach: true), refusing system
labels for the reason /status/{type}/{id}/label
refuses them — attaching Inbox without
detaching what the mail carries is a half-move.
_labelled.stream `leaves: false`, which is the whole difference
from a move: the rows are redrawn rather than
removed, and that is also what puts the new
chip beside the subject.
AND A REAL ONE OUT OF THE WORKER LOG, reported alongside.
WARNING ApplyImapFlagsHandler: destination not resolvable
ERROR Could not create the IMAP folder a move needed
folder: INBOX.spambucket
error: NO [ALREADYEXISTS] Mailbox already exists
ImapFolderProvisioner caught every Throwable from CREATE alike, so the
one outcome that already satisfies the request — the folder is there —
was handled as a refusal: return null, "destination not resolvable", and
the mail stays put. It happens whenever the server has a mailbox plMail
has not recorded: made in another client since the last LIST, differing
only in case, under a namespace the sync did not walk.
Already-exists is success now, and it still records the folder locally,
because the folder existing on the server and NOT in our database is
precisely why the destination could not be resolved — returning the path
without writing the row would fail the same way on the next move.
record() reuses an existing row rather than building a second on the
same full path, which that path can now reach. Every other refusal stays
a refusal.
The browser test for a move was rewritten rather than repaired: it took
`[data-dnd-folder]` first, which is Inbox, which correctly refuses drops
on the inbox view — a passing rule reading as a broken test. It archives
a conversation and drags it home instead, which is a real move in a
direction somebody makes, and uses the one system label a freshly seeded
user is guaranteed to have.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
a5366bcaee |
Carry a pill on a one-row drag, not a picture of the row
Dragging a single conversation handed the browser the row element, and the browser snapshots the source element for free — so the drag image was a thousand pixels of mail list, travelling under the pointer to a sidebar three hundred across. The folder being aimed at was behind the thing aiming at it for the whole gesture, and unlike most occlusion this one cannot be worked around by moving the mouse: the image goes where the pointer goes. Several rows already carried a pill saying how many. One row now carries the same pill saying the subject, capped at 22rem with an ellipsis — a pill that grew to fit any subject would be the row again under another name. Asserted rather than looked at, because it cannot be looked at: the drag image is drawn by the compositor, outside the page, and is absent from every screenshot Playwright takes. So the browser test hooks setDragImage and reads back what the page hands it. Nothing visible would have caught this going back. Also pins the question that has no answer in this file otherwise: that a move is SENT — ThreadMoveTest now reads the export transport and asserts an ApplyImapFlagsMessage with action `move` and the destination path. Every other assertion in that class is about local state and would hold just as well for a move that never left the building. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d0d5d017a3 |
Let the writing assistant's instructions be changed from the composer
Tuning a prompt is a loop — change a clause, generate, read it, change it back — and the only place to change one was Settings → AI. That means abandoning a half-written mail, saving, coming back and starting the draft again, so in practice nobody did it and the note stayed whatever it was on the day it was written. The assistant's panel carries the note itself now, prefilled with the saved one and editable in place, with "Try again with this" beside it. Nothing is saved. The box lives as long as the composer does, and Settings → AI is still where a lasting instruction is written: trying a sentence out and adopting it should take different gestures, or you are left with whichever experiment you happened to stop on. Absent and empty are different answers, and the plumbing keeps them apart. ComposeAssistController::note() reads presence rather than emptiness, and the controller sends the field only once the box has been opened — so a composer nobody has touched behaves exactly as it did before, and an emptied box is a person asking for this attempt to carry no standing instruction at all. That second case is the half of the feature that cannot be reached any other way: working out why a draft keeps coming out wrong means being able to take an instruction AWAY, not only to pile another one on top. It replaces the writer's own note and reaches nothing above it. The task instructions, the language rule and the administrator's own prompts belong to the application, and a box in the composer that could switch those off would be doing it in the one place nobody would think to look for them. The disclosure sits in the panel's header rather than in its action row, and that is the difference between offering it after a good draft and offering it after a bad one. The action row only exists once there is something to accept, and the moment somebody wants to change the instruction is the moment the answer came back wrong — "no answer" being the wrongest it gets. The browser test asserts on the request body, because that is the only place the claim is legible: with the model host answering nothing, a field that was sent and a field that was not look identical on screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8e05c4f557 |
Let a conversation be dragged into a folder or onto a category tab
Filing mail meant a menu: "Label as" for a folder, and for a category
nothing at all — the inbox tabs were computed from headers and there was
no way to disagree with them. Both are now a drag.
The two ends of the gesture are in different parts of the document — the
rows are in the list frame, the folders are in a sidebar that is rendered
twice, and the tab strip is a third place — so mail--dnd listens on
<body>. It uses HTML5 drag rather than pointer events, unlike the
calendar's block-dragging: this gesture has a discrete destination, so
the platform's own auto-scroll, drag image, refusal cursor and Escape
are worth more than the live preview a pointer implementation buys. The
cost is touch, which is smaller than it sounds — on a phone the sidebar
is a closed drawer while the list is on screen.
A move is deliberately not "attach the destination label". Nobody drags
something onto a folder and expects to find it in both places, so
ThreadStatusUpdater::move() detaches the location labels the mail
carries and leaves the tags alone; which labels count as a location is
the one judgement in it, and getting it wrong in the generous direction
would strip the user's own labels off. The three system destinations
delegate to archive/trash/restore, which already own their provider
semantics.
A drop posts to the bulk endpoint the toolbar's buttons post to, and for
the reason that endpoint exists: a row-shaped response cannot fix the
pager or the empty state, and a private route for drags would have been
a second implementation of what happens to the list afterwards.
MessageThread category_pinned_at, so a category chosen by hand
survives the next message in the thread — and
a run of `app:backfill category`, which is the
same overwrite applied to a whole account at
once.
LabelRole acceptsMoves(), read by the sidebar to decide
which rows offer a drop and by the controller to
refuse the rest. An interface that hides a
control while the route stays open is a habit,
not a rule.
SidebarCounts roleLabelId(), because the system rows are
anchors to routes and a route is not a thing a
conversation can be moved into.
inbox.html.twig ghost tabs for the categories nothing is filed
under yet, revealed only during a drag. Without
them the only categories you could move mail
into were the ones it might already be in.
Three things the browser found that no unit test could. The list
animates in, so a box measured on arrival belongs to whichever row was
passing through that coordinate — the drag worked perfectly and moved a
different conversation. A multi-step mouse move dispatches one drag
event per step and the last one lags, so the highlight belonged to the
tab next door. And enter/leave cannot track a target made of several
children during a native drag, where relatedTarget is routinely null;
dragover alone can, because "somewhere else" is simply the next one
arriving.
Deliberately not done: an inbox where every message is Primary has no
tab strip, so it has nowhere to drop and cannot acquire a second
category. Rendering the strip there anyway, hidden until a drag, was
built and taken out — it makes the list jump under the pointer that has
just picked a row up. The honest fix is a preference, and that is a
different change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
d8f8acf29b |
feat(ai): tunable keep-alive per model, and a warm-up button
The first "Help me write" after a quiet spell shows nothing for tens of seconds while the Ollama host reads twenty gigabytes back off disk. How long it waits before evicting a model is OLLAMA_KEEP_ALIVE — an environment variable on a machine plMail can talk to but cannot configure. The API takes the same setting per request and a per-request value wins, so this is the only lever available from inside the app. Two nullable columns on ai_settings, one per model, because the two are opposite trades: the writing model is eighteen gigabytes and asked for occasionally, the embedding model a few hundred megabytes and asked for on every search and every indexed message. Both ship at 5m, which is Ollama's own default, so an upgrade changes nothing about how a host behaves — the boxes just say out loud what was already happening. -1 is recommended for the search model in the help text and the handbook rather than shipped, because plMail cannot see how much memory that host has. Empty means send no keep_alive at all and let the host decide, which is a real selectable state and not an unfinished one. The wire representation is the part that is easy to get wrong: Ollama parses a JSON string as a Go duration, and a Go duration needs a unit, so "-1" and "3600" as strings are a 400 on every model call from a setting that reads as correct. KeepAlive::forBody() is the only thing that puts the value in a body — int for the numeric spellings, string for the durations, null to omit the field entirely, since an explicit null is read as zero and unloads immediately. One helper adds it to all four bodies so no call site can get that wrong. Validated with the same expression the body is built from, refused rather than coerced, and the message lives in the validators domain where Symfony actually looks for it. Warm up now: POST /admin/ai/warmup issues the no-prompt /api/generate, times the round trip and reports it. PHP's 30s execution limit is raised for it — a cold load is not 30 seconds — and the upstream timeout sits below that so it gives up first. A host that is unreachable and a model that is not on the host are distinct, translated answers. Also carries both columns through the configuration backup, and adds the AI feature to the README, which had never mentioned it. Fixes a search test that asserted how fast the machine was: it gave the last-resort body pass a one-millisecond budget and one row to scan, so on quick hardware the pass finished in time and the test failed. It now provokes a genuine statement_timeout cancellation with pg_sleep, which exercises the same guard without a clock in the assertion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dadab12439 |
feat(inbox): a tab says in colour when it is holding unread mail
The report, with a screenshot: "Unread only" on, Primary selected, the list reading "No messages in this tab", and the sidebar's Inbox badge reading 66 next to it. Both were true — the 66 was in Social and Updates, and nothing on the strip distinguished those tabs from the empty one being looked at. The only way to find the mail was to click every tab in turn. The reasoning that had kept unread off the tabs holds in the ordinary inbox: unread is already said by the bold rows and the sidebar badge, so a number on a tab would be a third voice saying it. Under the filter it inverts. Every row on screen is unread, so boldness separates nothing, and the badge is one total that cannot name a tab. So the strip makes the one statement nothing else can, without a number: the icon wears its category's colour when there is unread mail behind it. Drawn outside the filter too, so the strip means the same thing wherever you meet it, and never on the tab being looked at — that mail is already in the list underneath, and an identity colour dropped into the accent-coloured active tab reads as a fault rather than a signal. It was reported as one. It rides on the existing marker machinery: rendered whatever the count, carrying a data-count-key the counts endpoint now answers, so a Mercure sync re-tints in place. The tone travels in an attribute because the patcher cannot know which of the five a tab wears. Three unified-inbox counts also stopped counting conversations the list refuses to show. Binning a thread ADDS the Trash label rather than swapping it, and findForUnifiedInbox() has always excluded trashed threads while the counts beside it did not. That lit a tab for a thread the list would not show, on a mailbox whose badge said one unread and whose tabs claimed two — the Trash-badge-versus- list disagreement BadgeSemanticsTest exists for, arriving by another road. It also means countForUnifiedInbox() had been feeding the paginator a total that could offer a page that does not exist, which is the failure its own DISTINCT is written out to prevent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c37eda0748 |
feat(ai): a thread summary is three lines, and the card reads like a list
Somebody put a summary from another mail client beside ours: two lines, one saying an application was sent and one saying it was rejected. Ours, for the same conversation, was a paragraph of five sentences — three of which were courtesies. "For details I can read the emails." The shipped prompt was the whole of it. It asked, in as many words, for "a short paragraph saying what the conversation is about and where it has got to", and then for the open points after that paragraph. It was specified to be long, so no model was going to answer short. It now asks for at most three lines, one short sentence each, and two things that are really the same rule: prefer the outcome to the history — where a conversation reached a decision, say what it was and drop the steps — and leave out greetings, thanks, apologies and sign-offs entirely. Those are what a mail thread is mostly made of and never what it is about. The safety half is unchanged and deliberately so: state only what the messages actually say, never infer an outcome that is not written down, say so when something was asked and left unanswered. Editing this invalidates every stored summary, which is the point — see ThreadSummariser::promptFingerprint(). Nobody has to remember to clear anything. ── The card ──────────────────────────────────────────────────────────────── Three findings in a block of prose is three findings nobody separates while skimming, and skimming is the only way a summary is ever read. It is a real list now, with space between the items and a hanging indent, so a wrapped line aligns under its own first word rather than under a bullet. The bullets belong to the card, so the prompt stops asking for dashes. A leading dash is stripped anyway — summaries stored before this asked for one, and a model writes one unasked often enough that stripping is cheaper than trusting. Leading only: the dash in "Backend / Python – Cara Care" is part of the sentence, and a `replace` would have eaten it. The streamed copy is built as a list too, rather than as prose rewritten into one at the end — by then the reader is already reading it, and text that rearranges itself under them is worse than either shape. It is rebuilt from the whole accumulated text on every token rather than appended to: a token can land mid-word, or BE the newline that ends an item, so there is no "the current item" to push onto. Three short lines re-split a few hundred times costs nothing. Still textContent and never innerHTML. The elements are built in the controller and only their text comes from the model, so the rule that docblock exists to protect holds exactly as it did when this was one paragraph. The split now exists twice — Twig for a stored summary, JavaScript for one still arriving. Both sites say so and point at each other. ── And a test that should have existed ───────────────────────────────────── Asked whether custom prompts were used at all, neither the code nor the suite could answer. PromptLibraryTest proves an override RESOLVES; WritingAssistantTest proves the library's answer is what goes on the wire — and both of that second file's assertions compare the sent message against `new PromptLibrary(...)`, so both sides move together. On an installation silently ignoring overrides the library would return the shipped text, the assistant would send the shipped text, and the suite would agree. The join is now covered: a sentence is stored the way the admin page stores one, and the assertion is against that literal. It goes red when text() is made to ignore overrides. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2cf78c731c |
fix(mobile): the composer left a strip of mail list showing above the keyboard
A screenshot from an iPhone: the compose window ends after its action bar, and
between that and the keyboard there is a band of the mail list behind it.
The window is `position: fixed`, so it is placed against the LAYOUT viewport —
and so is the list behind it. It was sized to `visualViewport.height`, which is
right only while the layout viewport still includes the keyboard. That is no
longer a safe assumption: `interactive-widget=resizes-content` asks the browser
to shrink the layout viewport too, and which browsers honour it has moved since
that meta was written. Where it IS honoured the visual viewport stops higher
than the layout viewport does, and the difference is page nobody covered.
So the window stops asking which platform it is on:
height = innerHeight - offsetTop
padding-bottom = innerHeight - offsetTop - visualViewport.height
Filling the layout viewport is what makes a gap impossible — the list behind is
laid out to that same box, so covering it is structural rather than arithmetic.
The padding is then whatever part of the window cannot be seen, and it does not
care what is doing the covering: keyboard, accessory bar, browser chrome. One
subtraction, no thresholds, no branch per platform, and correct on all of them
for the same reason rather than three different ones.
An earlier draft of this classified the shortfall by SIZE — over 120px a
keyboard, under it an accessory bar — which is a constant standing in for a
question the browser was already answering. It is gone.
The padding also subsumes the safe-area fix from v0.2.7: `env(safe-area-inset-
bottom)` describes the layout viewport and goes on reserving the home indicator
while the keyboard sits on it, and an explicit padding replaces that inset
rather than adding to it.
NOT VERIFIED ON A DEVICE. The rule is correct by construction under either
reading of `interactive-widget`, which is why it was chosen over the version
that had to guess — but Chromium's visual viewport never shrinks, so no test
here can see the fault it fixes, and the iPhone belongs to someone else.
── Two more, found on the way ──────────────────────────────────────────────
• An instant search result opened a BLANK PAGE, at every width, and had done
for a long time. The row was a bare link, so Turbo resolved it against
`inbox-list-frame` and made the click a frame navigation — and
mail/thread.html.twig renders no list on purpose, because the route knows a
conversation and not the folder it came from. Turbo swapped the empty frame
in, the list emptied, the reading pane was never touched, and the address
bar said the right thing over nothing at all. `data-turbo-frame="_top"`
makes the click agree with the keyboard path, which went through
`Turbo.visit` and was never broken. The test for this existed and passed:
it asserted the URL, which is exactly what the bug left intact.
• The phone search panel is opaque to both screen edges. It was positioned
against the header's padding box, and the header is inset twice — the
shell's gutter and its own padding — so the list showed down either side.
A 100vw box centred on the header reaches both edges without naming either
inset, and both are settings that move.
── And one test ────────────────────────────────────────────────────────────
integrations.spec.ts opened a row and clicked inside it as two steps, with an
assertion between them to narrow the race. The gap being raced is the one AFTER
that assertion resolves, and a frame replacement re-renders every row CLOSED —
so Playwright's detached-element retry has nothing to retry onto and burns the
full timeout. Opening and clicking retry together now.
The keyboard-geometry tests assert numbers the tests themselves stage rather
than re-deriving the controller's own expression, which would have agreed just
as happily with a wrong formula, and they fail on any page or console error.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
c8846783b4 |
fix(mobile): tapping the search box zoomed the page, and the box was too small to use
Two reports about the same control on a phone.
── The zoom ────────────────────────────────────────────────────────────────
iOS zooms the page into any focused control whose COMPUTED font-size is under
16px, and does not zoom back out. There is one way to stop it — make the
computed size 16px — because the other, `maximum-scale=1, user-scalable=no`,
takes pinch-zoom away from everyone who needs it and was rejected on that
ground long ago (see the viewport meta in app.html.twig).
The rule to do it has been in app.css all along and had never once fired. It
sat in `@layer base`, where Tailwind's utilities outrank it, and `text-sm` is
on essentially every field in the app. Measured at 390px before moving it:
the search box 14px, the login email and password 14px, the compose subject
14px, the compose editor 14px, the formatting bar's selects 12px. Every one
of them zoomed.
It is unlayered now, and `[contenteditable="true"]` joins the selector list —
the compose body is a div, not a textarea, and Safari treats it identically.
`max(16px, 0.875rem)` rather than a bare `16px`, which would be a pin and not
a floor: an absolute value REPLACES the utility instead of raising it, so
below md nothing would answer to `--app-font-scale` any more, and above about
1.14 fields would come out smaller than they were before the rule started
firing — and smaller than their own labels. The slider runs to 1.25.
The compose editor keeps its own pin, and gets a second absolute value below
md rather than an exemption. What that pin exists to escape is the REM
cascade, so that the appearance scale cannot move the editor under a sender
judging their own message by eye; a second px value, chosen per width,
escapes it exactly as well. The cost is that a message written on a phone is
composed two pixels larger than one written at a desk.
── The box ─────────────────────────────────────────────────────────────────
Below md the field had about 120px of topbar between the logo and the
calendar button — too narrow to read a query back in, and the first thing a
thumb met reaching for anything else. It is a magnifying glass now, and the
field drops out from under the topbar when the button asks for it.
ONE field, moved by CSS, not a second one for phones. The box is a combobox:
it owns `aria-controls`, an id'd listbox, a live region and the recent-search
list. A duplicate would have meant two elements claiming `#search-listbox`.
Absolute rather than a second flex row, so the topbar keeps its height —
`--pane-header-h` is a published knob and the shell is laid out against it.
`focus()` is called synchronously inside the click, because iOS raises the
keyboard only for a focus in the gesture that asked for it.
── Four things a review of the first draft found ───────────────────────────
• EVERY BOUNDARY IN THIS FEATURE HAS TO BE 48rem. The button's visibility,
the shell's positioning context and the row's alignment are Tailwind `md:`
variants, compiled to `(width < 48rem)`; the panel rule was written in px.
rem in a media query answers to the browser's default-font-size
PREFERENCE, not to `html { font-size }`, so the two drift apart for anyone
who has changed it. At Chrome's "Small", `md:` lands at 576px and mail
search was simply UNREACHABLE from 576 to 768 — no button, no panel, and
no other way in. At 20px it lands at 960px and both rendered at once, with
`md:relative` unmatched, so the suggestions dropdown reparented to
<header> and painted across the whole topbar. `html` is pinned to 16px by
the appearance scale, so the page looked entirely normal while it happened.
• Escape died once the focus left the input. Both rungs it defers to are
spent BY the field, and `aria-expanded` is written from handlers bound to
the input — so a Tab into the open dropdown left it "true" for good and
every later Escape returned early. The gates now apply only while the
field holds the focus.
• Turning an iPhone upright mid-query hid the field being typed in: landscape
is 812–932px, above md, so the crossing handed the focused element
`display: none` and the keyboard dropped with nothing on screen to explain
it. A crossing that finds the field in use opens the panel instead.
• The 16px pin, above.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
dfe27b7219 |
fix(compose): the keyboard covered the window on an iPad and left a gap on an iPhone
Two reports, one misunderstanding. While the on-screen keyboard is open, iOS
goes on telling a page that the screen is its full height — only the *visual*
viewport shrinks — and a `fixed` element is placed against the layout one. So
anything anchored to the bottom of the screen sits behind the keyboard and
believes it does not. Android shrinks the layout viewport too
(`interactive-widget=resizes-content`), which is the whole reason the same
window looked right there.
The phone window already answered this: below md it sizes itself from
`visualViewport`, so the keyboard ends up below it. Above md nothing ran at
all — `_applyMobile` called `_unwatchViewport()` on that branch — so the dock
card kept its `bottom: 1rem` from an edge that was now keyboard, with the
action bar and Send underneath it and no gesture that brought them back.
The controller now tracks the viewport at every width and publishes
`--keyboard-inset` on <html>; the dock wrapper and the card's height cap read
it from app.css. The card moves up AND caps against the room left, because
lifting a 600px card on a screen with a keyboard on it otherwise pushes its
title bar off the top.
The iPhone gap was the other half of the same thing.
`env(safe-area-inset-bottom)` also describes the layout viewport, so it went on
reserving the home indicator's 34px against a window whose bottom edge had
become the top of the keyboard: clearance turned into a dead band of window
between the action bar and the keys. It is dropped while the keyboard is up.
Three things an adversarial review of the first draft found, all of them real:
• Pinch zoom read as a keyboard. `innerHeight` ignores the scale and
`visualViewport.height` does not, so zooming to 2x reported half the screen
as keyboard with none on it — and `scroll` is subscribed as well as
`resize`, so panning resized the card continuously under the user's finger.
Zoom is deliberately available here (WCAG 1.4.4), so this was reachable. No
arithmetic separates keyboard from zoom; at any scale but 1 the correction
is withheld.
• `--keyboard-inset` is one property written by every compose window, and
above md a dock window and an inline reply are open at once by design. The
first draft deleted it on any disconnect and argued in a comment that the
survivor would re-assert it — which only holds when a blur caused the
teardown. `_settleSend()` empties the inline frame from a timer eight
seconds after a send, keyboard untouched, and would have dropped a dock
window back under it mid-sentence. Cleared only by the last one out.
• The height cap had no floor and the lift no clamp. An iPhone in landscape
is 844x390 — wider than md, so it gets the dock card — and `90vh` less a
200px keyboard computes to 151px against ~210px of header and action bar. A
box shorter than its `shrink-0` children does not shrink them: it spills
them out of its bottom edge while the scroller between them, which carries
From, To, Subject and the message, collapses to nothing. Nothing fits a
composer and a keyboard into 390px, and this does not pretend otherwise —
it stops the fix from breaking the case it cannot fix.
tests/e2e/support/keyboard.ts stages what a keyboard leaves behind, since
Chromium's visual viewport never shrinks: the real VisualViewport with its
height, scale and offsetTop overridden, and a resize to announce it. Each of
the four faults above has a spec that goes red without its fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
9b75a1f15f |
fix(calendar): an event that runs over days says both of them
The details card printed the start day, the start time and the end time: "Friday 28 August · 15:00 – 16:00" for a festival running to Sunday afternoon. Every component of that is a real value, which is exactly why it read as correct — it is an hour on the wrong scale. The grid beside it drew the span properly the whole time, so the two disagreed with each other in one window. Both ends are named now. All-day events take the day before their stored end, because that end is exclusive — midnight on the day AFTER the last one — and the day a reader would name is one before it. Same conversion the toolbar already makes for its range label. The dialog heading goes with it. It said "Title", because it borrowed calendar.event.title — the label on the title FIELD in the editor — and a heading naming one field of a form sat above a card showing the event's name, its dates, its location and its calendar. It has its own key now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5f8ae26be4 |
feat(ai): the system prompts are the administrator's, and summaries follow them
Seven prompts were constants, so tuning one meant a deploy. Admin -> AI now holds all of them, with the shipped wording as the placeholder and a way back to it. EMPTY = NULL = SHIPPED, and that one rule carries the whole design. Reset is a DELETION rather than a copy, so a later release can still improve the wording instead of every installation being pinned to whatever shipped the day it first saved. It is also why the language rule can be reworded but not removed: there is no special case, an empty box simply is not an override. THE HAND-BUMPED VERSION HAD TO GO thread_summary carried PROMPT_VERSION, an integer whose docblock said "bumped whenever SYSTEM_PROMPT changes". That cannot survive an editable prompt: an administrator tunes it, nobody bumps anything, and every stored summary keeps the old prompt's output for ever while the page calls it fresh. It is now a digest of the EFFECTIVE prompt, which is the same move the transcript hash already made — edit a prompt, edit the language rule, clear either, or take an upgrade that improves the shipped text, and each is a different string on the wire, so a different digest, so the stale rows stop matching by construction. WritingTask::systemPrompt() is deleted rather than kept. A method by that name returning something that is not the system prompt on any installation that edited one is precisely the drift this repository keeps collapsing. The cap is 1200 and not AiPreferences' 600, because the summary prompt we ship is 587 characters: a cap the shipped text already fills only ever bites the person trying to improve it. Verified against the real host — an override changed the answer and clearing the box changed it back. Worth recording the negative result too: rewriting the language rule to demand English did NOT make this model answer a German mail in English. The rule is a nudge it already agrees with; the bug it was written for was the ABSENCE of any rule, which is now unreachable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
67c3b52376 |
fix(compose): a cancelled send no longer reopens a window you closed
In the held send shape the composer deliberately stays on screen while a send is out, so every second of that is a second the close button is live. Cancel the send, then press Save draft and close, and the undo response — which answers with the composer PUT BACK, because that is its job — replaces the frame you just emptied. The window returns by itself and nothing takes it away again. Reproduced deterministically by holding the undo response and closing underneath it, rather than waiting for a full-suite run to be slow enough. Fixed on the client because it cannot be fixed anywhere else: the close is entirely client-side in this state — saveDraft() returns early while sending — so the server never learns the window is gone, and the response is already being generated. A cancelled send now drops only the stream aimed at this window's own frame; the inline variant still takes the sent mail back out of the conversation, which is on screen and genuinely wrong until it runs. Nothing is lost by dropping the reopen: the send has already been called off server-side and the message is a draft again, which is what the reader asked for. The optimistic path is untouched on purpose — there the composer is already closed and the toast's Undo SHOULD bring it back. Also: chromium-exclusive now actually runs on one worker, which its own comment has claimed since it was written. Without the override it inherited four, and `fullyParallel: false` means parallel across FILES — so every spec in a project that exists because its members own install-wide state was running concurrently with the others. It held by luck until two of them wanted the same singleton. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
be97fbeedf |
fix(mail): a message's menu is drawn above the message below it
Reported twice as a popover being clipped. It is not clipped; it is covered. The menu has been z-30 since it was written and that never helped, because a z-index only orders siblings within one stacking context. The menu is painted inside a message header, `sticky` makes that header a context of its own, and the NEXT message's header is also sticky, also z-10, and later in the document — so it outranks the entire context the menu lives in. Raising the menu could not have worked. The context had to be raised, and only while there is something in it worth raising. :has() rather than a class the controller toggles, because the state is already in the DOM. Written as plain CSS rather than a Tailwind arbitrary variant because that selector does not survive the generator: the has-[...] form produced no rule at all, silently, which is the worst way for a style to be missing — and cost a run to notice, since tailwind:build alone does not reach the browser without asset-map:compile. Asserted on the two headers' computed z-index rather than a screenshot: the bug is a number and the fix is a number, and a baseline image would need re-approving every time the thread view is restyled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
661d3bbf25 |
feat(ai): summarise a conversation, when somebody asks
On demand, and that is the design rather than a first step. A summary spends the 20.3 GiB writing model for about a minute cold, on one GPU already shared with the indexer and the composer. Summarising every arriving thread would spend days of it on conversations nobody opens. FRESHNESS IS DERIVED, NOT MAINTAINED The stored row carries a SHA-256 of the exact transcript that was sent, plus the model tag and a prompt version, and freshness is that hash matching what the thread would send today. Nothing has to remember to invalidate anything. lastMessageAt was the obvious key and is the wrong one: it only ever moves FORWARD, so a deleted message leaves a summary describing mail that is no longer there, silently and for ever. The hash also makes a prompt change or a budget change invalidate every stored summary by itself, which is correct and free. CASCADE, following message_embedding rather than MailInsight. The two precedents point opposite ways and both are right — an insight outlives its message because "a deleted shipping confirmation does not un-ship the parcel", an embedding does not because it describes one. A summary describes a thread. THE BUDGET IS MEASURED, NOT PICKED 8000 characters, against two ceilings and the smaller one is silent. The visible one is the wait before the first token — load plus prompt eval, about 42s at this budget, a third of the client's idle timeout. The quiet one is the context window: OllamaClient sends no num_ctx and Ollama's default is 4096 tokens, so 12000 characters sits exactly on the line, and an install running the default would drop the HEAD of the transcript — the part the reader most wants and the part the transcript is assembled forwards to protect. No error, no notice. The fourth AiFeature case landed LAST, after every arm that reads it. Eight exhaustive matches key on that enum and none of them fails at compile time; AiPermissions::anyAdminEnabled() loops the cases and the settings nav calls it on every settings page, so the case arriving early would have taken the whole settings area down rather than the AI section. Verified against the real host, not a mock: a German thread returns a German summary, a reopened thread renders the stored row with no new model call, a reply greys it and offers to run again, and changing the chat model hides it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
cdbe2d05bb |
feat(ai): let each person configure the assistant for themselves
The administrator decides what an installation offers; this decides what one person uses of it. Settings → Assistant: three feature switches, two notes the model is given before it writes, and how much of a conversation it reads. THE CEILING AND THE FLOOR AiSettings stays a ceiling and no user value can get past a false in it. The AND is written in exactly one place, AiPermissions, and every one of the four things in src/ that builds content for a model now takes the user in its SIGNATURE — so it is not possible to build a prompt without having named whose mail it is. A worker that cannot work out whose mail it is holding REFUSES: two of them run unattended over somebody's correspondence, and "we could not tell" is not a reason to send it to a model. That reaches into the transport. EmbedMessagesMessage now carries the user id, which for one deployment window means already-queued batches are refused and picked up by the nightly sweep instead. HOW MUCH IT READS An enum, not a switch: Nothing / The message / The conversation. Whole-thread is new capability rather than a restriction — the model stops answering a conversation it has only seen the last turn of. An oversized thread loses its OLDEST turns and always keeps the message being replied to. There is no privacy control here and the copy does not pretend otherwise. The model runs on the operator's own machine; what more context costs is time, and the hint says so. A "do not send the subject" switch was designed and dropped for the same reason — its only honest label would have been "make my search worse". WHAT COULD NOT BE ENFORCED, SAID PLAINLY A persona can talk a model out of the app's own rules; models have no reliable precedence inside one system message. So the hint says "Preferences, not instructions — the app's own rules come first", which is a statement of intent rather than a promise. What IS mechanical: placement, a length clamped on write AND re-clamped on read (hooks are skipped on hydration), a blast radius of writing help alone, and provenance from the entity rather than the POST body. Switching search off stops indexing; it does not forget. The copy says stops. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3471b23c63 |
fix(ui): centre the collapsed sidebar's icons
In a 56px rail every icon centred on 28 except Inbox and Starred, which sat on 18. Measured, because ten pixels is exactly the amount that looks like nothing in isolation and wrong in a column. Two causes, both structural. Those two are the rows that carry an unread badge, so they are a <div class="nav-item"> wrapping the link rather than a bare <a> — and the rail rules target `nav > a`, so they never reached them. The `justify-between` that spaces a label from its badge then put the icon at the START of the row once .rail-hide had taken the badge away. The new-mail dot was the other half: a flex ITEM sitting after the label, so hiding the label left it holding a slot and a gap. Lifted out of the flow and pinned to the icon's corner rather than hidden — a collapsed rail is precisely where "something arrived in here" is the only signal left, since the count badge has already gone. Hiding it would have centred the icon by removing the one thing the row still had to say. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2d1586cb68 |
fix(compose): a draft that died at thirty seconds, under the quote, in silence
Three faults in the writing help, all of them things that only appear once it is
actually being used.
PHP's max_execution_time is 30 seconds for the web SAPI while the model call is
allowed 120, so the limit always belonged to the wrong side of the request. A
writing model that has fallen out of the keep-alive window needs about thirteen
seconds to be read off disk BEFORE the first token exists, and a reply worth
reading is another twenty or thirty after it — so the limit fired mid-generation
and the reader watched the text stop halfway:
MaxExecutionTimeError: Maximum execution time of 30 seconds exceeded
at CurlResponse.php line 370
The worst possible place for it to land, too: inside the HTTP client's read, so
the unwinding that cancels the model call and records the row never happened and
Ollama carried on generating for a reader who had already been shown an error.
The bound now comes from the pieces that can actually enforce it — the upstream
timeout, and connection_aborted() on the next write.
The answer went in at the end of the editor, which in a reply is UNDERNEATH the
quoted original: an answer nobody scrolls to, sitting below the message it was
answering. It goes above the quote now, which is where a person's cursor already
is when they start typing a reply. Plain text too, where the boundary is the
first `>` line less its attribution line — inserting between "On … wrote:" and
the quotation splits it in half.
And the wait had nothing moving in it. There is nothing honest to put in a
progress bar, because the host reports no progress at all while it loads, so a
still sentence was all there was and it read as a window that had hung. Three
dots that breathe say "working" and claim nothing about how far along it is,
which is exactly as much as we know. They stop at the first token, where
arriving text says it better, and they hold still but stay visible under
prefers-reduced-motion.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
377d6026b8 |
feat(ai): make the language model observable, and the composer honest
"I click the AI button in the composer and nothing happens" turned out not to be about the model at all. #editor() queried data-compose--toolbar-target while the composer renders data-compose--compose-toolbar-target, so run() returned at its first guard: no request, no status, no error. The feature had never worked once. #subject() was wrong the same way and quieter — every prompt went out with an empty subject. And the status element lived INSIDE the dropdown that the same click closes, so even once working it would have written into a hidden subtree. The rest of this is what makes the next such thing findable. RECORDING One row per model call, tagged by the workload that caused it, and nothing about what was in it — no prompt, no completion, no message id, no subject, no address. error_kind is a category from a closed set for the same reason: an HTTP client's exception message routinely quotes the request body back, and here that is somebody's mail. OllamaClient was discarding Ollama's whole timing block; it now returns it, and AiAssistant — the only place that knows both the feature and the model — writes the row. DBAL and deliberately no entity, following EmbeddingStore: BackfillEmbeddings clears the EntityManager after every chunk, so an unflushed ORM row would be detached and silently dropped, fifty at a time, for as long as a backfill runs — which is exactly the window the panel exists to show. AiFeature could not carry this. A four-character search query somebody is waiting on and two thousand characters of mail body written unattended a hundred thousand times both went through embed() with no discriminator, and averaged together the second buries the first — the panel would report backfill throughput under the heading "search". AiCallFeature splits them. THE PANEL Live state from /api/ps, which is passive and safe to poll. Six states that look different, and the one that justifies the work is "partly on the CPU": size_vram < size means layers spilled, throughput collapses, and the model still answers correctly — invisible everywhere else, so everything is simply slow forever. THE COMPOSER Tokens stream into a provisional surface and commit in one operation, because hundreds of per-token insertions destroy the undo stack and Ctrl+Z is the first thing anyone tries. Waiting, generating, done and failed are distinct states with reasons: outside the keep-alive window the first thirteen seconds produce nothing while 20 GiB loads, and a featureless spinner for that IS the bug. Cancellation propagates to the host — an abandoned draft otherwise holds the GPU and everything else queues behind it. SEARCH Silent degradation to keyword-only is replaced by the reason it degraded, and rows found by meaning say so. The semantic arm and its join are now matched on model AND width: vectors from different models are not comparable, and after somebody changes the search model every existing vector is dead weight. On a pgvector install comparing different widths raises an error — a 500 on /mail/search caused by an admin dropdown — and on the shipped loop it silently compares the overlap, which is worse. Coverage is stated where results are shown, because semantic search over an 8%-indexed mailbox is a trap. BACKFILL Progress is counted from the database, never accumulated, so a killed worker resumes. It stands aside while anyone is using the AI and comes back on its own: both compete for one integrated GPU, and a batch in flight when somebody presses the button puts them straight back to "nothing happens". Also carries the cron line for app:jobs:reap from the commit before this one. Verified against the real host: reachable, both configured models installed, timings parsed, 1024-dimension embeddings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6c33316f33 |
feat(settings): a second opacity slider, for windows and menus
One slider drove every translucent surface, and translucency multiplies: a composer at 0.7 over a pane at 0.7 left about a tenth of the wallpaper landing in the middle of the text being read. So the knob splits. Structural surfaces — sidebar, top bar, main and calendar panes — keep --pane-alpha. Floating ones — the compose window, modals, confirm dialogs, toasts and twelve menus — take --popover-alpha, and both floating utilities re-point --pane-alpha to it for their own subtree, so an inset strip inside the composer belongs to the composer rather than to the sidebar behind it. The guarantee is a floor, not just a default: 0.5, so a popover can never be made as see-through as a pane. Boxed spends the gap at 0.9 over its panes' 0.7 — about 3% of the background survives both layers instead of 10%. The old stored value is deliberately NOT copied across. Twelve of the sixteen floating surfaces never honoured it: `popover` has been hardcoded opaque since it was written, for exactly the illegibility reason being reported. Backfilling 0.7 would have introduced the fault in twelve places to preserve it in four. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b275265d07 |
fix(csp): thirteen controls rendered fine and did nothing when used
Reported against the log level toggle added in v0.1.44, and it turned out
to be twelve older ones plus that.
Executing inline event handler violates the following Content Security
Policy directive 'script-src 'self' 'nonce-…'
`script-src` is enforced in production with a nonce, and the spec is
explicit that NEITHER a nonce NOR a hash authorises an inline event
handler without `unsafe-hashes`. So every `onchange="this.form.
requestSubmit()"` in this application was refused: the log browser's two
filters, the clock picker, the timezone picker, two compose defaults, two
compose-behaviour radios, the three push delivery filters, a copy field's
onclick, and the new log level select.
WHY IT LASTED
An inline handler violates nothing when the page LOADS. csp.spec.ts walks
every surface asserting a clean load and it was clean — the pages were
fine, the controls were fine, and nothing happened when you touched one.
Silent unless a console was open, and a control that does nothing reads
as "this setting is broken" rather than as a policy refusal.
Tom Select is what surfaced it: it re-dispatches a real `change` on the
hidden original element, which is exactly what fired the handler the
policy then refused — so the console names tom-select as the culprit and
it is nothing of the sort.
THE FIX, AND WHERE THE CONTROLLER GOES
`ui--auto-submit`, served from a file the policy already allows.
`data-controller` on the FORM and `data-action` on the input, because
Stimulus resolves an action against the element or an ancestor — on the
input alone it silently never fires, which is the same failure again.
requestSubmit(), not submit(): it fires the submit event so Turbo sees it
and validation runs, and on the two forms here that opt out with
data-turbo="false" it behaves exactly as submit() did.
`ui--broken-image` for the last one, which was doing something else
entirely: an attachment thumbnail's `onerror="this.remove()"`. Thumbnails
may legitimately not exist — a PDF, a format GD cannot read, one that has
been pruned — and the chip is fine without one, but a broken-image icon
in the middle of it is a defect rather than a fallback. That icon has
been there the whole time.
THE TEST IS STATIC, DELIBERATELY
A browser test would have to drive thirteen widgets, several of them Tom
Select with a hidden original, to state one rule. And the load-time walk
already exists and already passed. So the rule is asserted directly:
no template carries an inline event handler, with the message pointing at
the controller to use instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|