Commit Graph
139 Commits
Author SHA1 Message Date
Paul LütznerandClaude Opus 5.5 be3b27cf32 Let an administrator change the public address after setup
Setup asked for it once and nothing could answer it again. An install
restored from another machine's backup therefore kept the old machine's
address: browsers were sent there for live updates and the stream
indicator stayed red, with an environment variable in the compose file
as the only remedy.

Admin → Address edits the same value setup writes. The change is saved
to the generated file and takes a restart to apply. Making it live was
left out on purpose: the entrypoint exports that file into every
container's environment at start, and telling a stale export from a
deliberate pin needs more machinery than a restart costs. The page shows
the address in use beside the saved one whenever they differ, and offers
the web container's restart after every save.

One thing fell out. A hub address on file that is only the previous
public address with the hub's path on it is dropped on save, so it is
derived from the new address again; a hub address of its own is kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:25:56 +02:00
Paul LütznerandClaude Opus 5.5 0507c1e716 fix(truenas): chown the Postgres directory to the uid the alpine image runs as
postgres:18-alpine runs as uid 70; 999 belongs to the Debian image. The
install worked anyway because the Postgres entrypoint re-chowns PGDATA as
root, but the chown, the setfacl fallback and the docs named the wrong user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:11:55 +02:00
Paul LütznerandClaude Opus 5.5 e6868482c1 feat(calendar): answering an invitation shows the meeting in the calendar
Yes or Maybe redrew the card and put a toast in the corner, and the
calendar pane beside it went on showing the week without the meeting
until a reload: the response replaced the card and nothing told the
pane.

calendar--invite makes the calendar part of the answer, in three layers
that each work without the next:

- Preview. Hovering or focusing Yes or Maybe outlines the slot the
  meeting would take, dashed, in its calendar's colour, at every motion
  level. The slot is worked out in the browser from the card's UTC
  instants on the grid's own clock (data-grid-zone), full width; the
  real block takes its lane when it arrives.
- Flight. At Full motion the pressed answer arcs to that slot and turns
  into the meeting on the way, or shrinks into a dot on the calendar
  button when the pane is closed. It reads --motion-travel, a new
  MotionLevel token: 580ms at Full, zero at Minimal and None and under
  prefers-reduced-motion. DESIGN.md names it the second exception to the
  260ms ceiling, beside new mail.
- Redraw. Once the server has answered, the pane is fetched and morphed
  in place with Idiomorph, since a pane embedded at page load has no src
  to reload, and the real block is lit where the placeholder stood. A
  screen reader hears where it went.

Nothing waits on the iTIP reply the server sends first: the button takes
its answer at once and the flight lands on a placeholder the real block
replaces. A failed request takes all of it back.

A meeting answered Maybe is striped in its calendar's colour with a
dashed edge, in every view, and says "Maybe" in its accessible name.
Nothing drew a tentative meeting differently before. Only the reader's
own answer stripes it, not an event's STATUS.

app:test:seed-invite hangs an unanswered invitation on seed-mail's
"E2E Read Me". calendar-invite.spec.ts answers it at Full (Yes flies,
no reload) and under reduced motion (Maybe outlines a striped slot,
nothing flies, the block is striped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:27:37 +02:00
Paul LütznerandClaude Opus 5.5 9c25eae1bf fix(secrets): the JWT keypair lives beside the generated secrets
.env named var/secrets/jwt outright, which is beside the generated
secrets only while APP_SECRETS_FILE keeps its default. The TrueNAS
layouts point it into their data dataset, so there the keypair landed on
each container's own throwaway filesystem: every service generated one
on every start ("Generated the JWT keypair." in the log), and a token
one of them signed would have been refused by the others and by the
next restart. Nothing issues JWTs yet - the Android app pairs into an
app password - so nothing broke, but the promise in the comments that
every service verifies what the others sign did not hold.

- config/bootstrap_generated_secrets.php derives both paths from the
  directory APP_SECRETS_FILE is in, for every PHP process including a
  `docker exec` console. A real environment variable still wins; the
  .env defaults are empty, as for the other derived values. On the
  standard compose the paths are the ones they always were.
- app:secrets:init generates under a lock, rechecked once held, as
  GeneratedSecretsFile::ensure() does. lexik's --skip-if-exists is a
  check followed by two writes, so services starting together could
  leave one's private key beside another's public key - a narrow window
  that seven concurrent runs did not hit in five rounds, and closed now
  rather than left rare: with the lock every round generated exactly
  once and left a matching pair.
- A reset that rotates secrets removes the two key files where they
  are, instead of emptying a fixed var/secrets/jwt.
- The security.yaml note on "the future first-party app" is corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:07:39 +02:00
Paul LütznerandClaude Opus 5.5 c3a3c5fcbe feat(mail): dropping a conversation on Starred stars it
Starred takes a drop the way Unread does: the conversation is starred
and stays where it was. It was not a drop target at all, and there was
no way to star several conversations at once - the row's own star
button is the only star control.

- A new explicit-ids-only bulk action, `star`, beside `read`. Drop-only
  like move, label and category, so a whole-view selection is refused.
- ThreadStatusUpdater::starThreads() switches stars on and never off.
  The row button's star() toggles, reading the answer off the first
  message; a drop is a destination, so a conversation that is already
  starred - by the thread's starredAt, which the Starred view lists by -
  is left starred. The write is star()'s: the first message carries the
  flag, the thread the timestamp, the whole conversation is pushed.
- mail--dnd: a data-dnd-star target, lit like every other target.

BulkStarTest drops a starred and an unstarred conversation together and
finds both starred; dnd.spec drags a row onto Starred with a real
pointer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:22:51 +02:00
Paul LütznerandClaude Opus 5.5 ad0c61bd25 feat(mail): an opt-in Unread view gathers every unread conversation
A new system label, Unread, off until it is switched on in Settings ->
Labels. Its sidebar row sits under Inbox and opens every unread
conversation in one list with no category tabs - archived and labelled
mail included, the bin, spam and snoozed mail left out. Dropping a
conversation on the row marks it unread and leaves it where it is.

- The label is never bound to an account (LabelResolver::
  userSystemLabel()), so it is not a JMAP Mailbox and no provider push
  can reach it. The labels settings page creates it, the one place it
  can be switched on.
- The list is read off the unread counts (MessageThreadRepository::
  findForUnread()) rather than off the label, which nothing attaches.
  Its sidebar badge counts the same rows.
- A drop posts the bulk "read" action with read: false through a new
  data-dnd-unread target in mail--dnd - the toolbar's Mark as unread,
  by pointer. Moves into it are refused (LabelRole::acceptsMoves()).
- The list refreshes on a sync of any folder, as "*" does, since it
  gathers mail from all of them.

Web only for now: the Android app does not list it.

UnreadViewTest pins which mail the list holds and that the row is
opt-in; dnd.spec drags a read conversation onto the row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:42:24 +02:00
Paul LütznerandClaude Opus 5.5 906352ee3b fix(labels): hiding a system label in settings takes it out of the sidebar
The eye beside Inbox, Sent, Drafts, Trash or Archive in Settings ->
Labels saved the change, greyed the label out in the list, and left the
row in the sidebar. The sidebar only asked about Spam; every other
system row was drawn unconditionally. Even Spam changed only after a
full page load, because the toggle's answer redrew the custom label tree
and the settings list and nothing above them.

- Each system row now asks SidebarCounts::isRoleHidden(), filled by the
  role-label read the sidebar already makes. A role with no label yet
  counts as shown - system labels are created lazily, and a fresh
  install must not lose its Inbox for that. "More" is not drawn when
  everything in it is switched off.
- Toggling a system label answers with both sidebar copies re-rendered,
  for the tab that clicked only: the toggle lives on the settings page
  alone, and another tab sitting in Archive would have its "More"
  snapped shut by a re-render.
- Archive is created visible. It was created hidden from when it was
  IMAP bookkeeping, which is why 6edf33fe stopped the sidebar asking.

Upgrading: Archive labels that already exist keep the hidden flag they
were created with, so Archive leaves the sidebar until it is switched on
in Settings -> Labels - which is what that list has been showing all
along.

testHidingTrashTakesItsRowAwayAndShowingItBringsItBack fails against the
old sidebar and stream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:42:01 +02:00
Paul LütznerandClaude Opus 5.5 fe42240bd4 docs: the README's calendar draws the two-day birthday as one bar
Retaken after the all-day band learned to span days: the shot still
showed "Jonas - birthday" as a chip on each of its two days.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:31:51 +02:00
Paul LütznerandClaude Opus 5.5 2b8261eebc docs: the README's calendar shows 7 days, from today
Retaken after 66b9701b: the picture still showed a Monday-to-Sunday week
labelled Week. The spec's own events move two days on, because
app:test:seed-demo already fills the first days from today - on top of
it they took the same slots, a "Standup" beside its "Stand-up" at nine,
and left the last two columns empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:24:52 +02:00
Paul LütznerandClaude Opus 5.5 66b9701bab feat(calendar): the week view starts today, and is called 7 days
The week ran Monday to Sunday, so by Friday the docked pane spent four of its
columns on days that were over and pushed today towards its right edge, where
seven columns do not fit and the rest scrolls out of sight. It is now the
seven days from the anchor: today on arrival and after Today, the day you
moved to otherwise, and Previous and Next still step seven days. That is no
longer a calendar week, so it is labelled "7 days" ("7 Tage"); the URL segment
and the stored preference stay `week`, so bookmarks and remembered views keep
working.

Month keeps its Monday, as a grid of weeks. The public shared page keeps a
real Monday-to-Sunday week and the label "Week": it builds its own range, and
the toolbar now takes per-view label keys so the two can differ.

The grid fixtures' daily series starts today rather than on Monday, so its
five occurrences stay in view, and the README screenshot lays its events out
from today for the same reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:05:30 +02:00
Paul LütznerandClaude Opus 5.5 13385fd976 fix(ai): Stop on a summary stops the model and keeps nothing
Since a summary whose connection drops is finished and stored anyway
(932c3f1f), Stop did neither thing it says. The abort it sends looks, from
the server, exactly like a dropped connection, so the model ran to the end
and the summary was stored under a card reading "Stopped. Nothing was saved."

So Stop is now also said. The card names each run with a random id, and Stop
posts that id to /mail/thread/{id}/summary/stop, with the same per-action
CSRF token and ownership check as the run. The flag lives in a DBAL cache
pool, because the stop request need not land on the container streaming the
run. The run looks for it before it starts, on every heartbeat and at most
once a second while words arrive, when it finds its reader gone, and once
more before it stores. Finding it returns from the loop, which frees the
generator and drops the call to the model host; the AI panel records it as
cancelled.

Only the button says no. Opening another thread or closing the tab still
counts as leaving, and that summary is still finished and waiting. A page
loaded before this sends no run id, and its run is finished the old way
rather than refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:52:40 +02:00
Paul LütznerandClaude Opus 5.5 a8364e34d2 feat(deploy): one worker container runs every background process
The stack was nine long-running containers: the web server, the
database, the Mercure hub, and six of plMail's own image, one per
background process. It is three now: php, worker and database, with
secrets-init still running once and exiting.

The worker runs `app:work`, a small supervisor keeping seven processes
alive the way Docker's restart policy kept seven containers alive: the
IMAP supervisor, one consumer per queue, the scheduler and the hub. A
clean exit (the hourly recycle, the restart signal) comes straight back;
a crash backs off from one second to thirty. Output passes through a
whole line at a time, so two processes' JSON records never interleave,
and SIGTERM reaches every child at once with 25s to finish. Each queue
keeps a process of its own, which is the part the split was for: a
consumer inside a long handler cannot take a send. And each process
keeps its container's old name as APP_CONTAINER_NAME, so heartbeats,
/healthz and the log filters are unchanged.

The hub is the official Mercure 1.x binary, copied into the image and
run as one of those processes. Not FrankenPHP's built-in hub, which is
0.24 and refuses the RFC 9068 tokens plMail has minted since v0.2.31.
The worker answers to `mercure` on the network, so the Caddyfile proxy
and MERCURE_URL reach it where they reached the hub container, and older
compose files keep working with this image unchanged. It is configured
from the application's own settings, including MERCURE_COOKIE_NAME, so a
plain-HTTP install names the cookie once for both halves. A 5s shutdown
grace stops it waiting on open subscriptions, which made every stop take
the whole 25s.

The dev override loses its per-worker blocks, one of which (worker-bulk)
had been missing, so that queue ran the published image's code in
development. The demo overlay runs app:work --without=imap-supervisor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 16:20:31 +02:00
Paul LütznerandClaude Opus 5.5 463931507d docs: the README says plMail announces newer builds; the admin shot has Updates
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 14:57:14 +02:00
Paul LütznerandClaude Opus 5.5 4cbdb4f511 feat(admin): a version checker, with a channel to follow
Admin → Updates: an administrator picks which builds count as an update
(Releases, the `latest` image; Main, the `main` image; or Off), and
every hour app:updates:check asks whether that channel has a newer one.
Until somebody picks, an installation follows the tag its own build
came from, so a `main` image follows main and an unbuilt checkout asks
nothing of anyone.

The registry decides what exists: the channel's image is read the way
`docker pull` finds it (OCI distribution API, anonymous token from the
401's challenge, the index entry for this architecture, the config's
labels), so a commit that builds no image is never announced. GitHub's
compare decides which way two builds differ, so a main build following
releases is not told to go back to an older release, and its commit
list is the page's "what's new". An image from anywhere but GitHub falls
back to what the version numbers alone can say.

Each new build is announced once: a push to every administrator's
registered devices (a new UpdateAvailable payload the service worker
shows), a badge beside the build chip in the admin header, and a dot on
the nav entry. A check that gets no answer keeps the last one and says
why, and the hourly command exits 0 for it: an installation behind a
firewall is not a fault.

Images are stamped with where they are published (APP_IMAGE), so a fork
that publishes its own checks its own. The channel travels in config
backups; what the last check found does not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 14:57:14 +02:00
Paul LütznerandClaude Opus 5.5 3a965b47dc docs: the README's inbox shows the calendar beside the mail
The first picture now shows what a desktop opens on, and what the
README's first line promises. The fixture user stays on the mail alone
for the regression suite, so the capture asks for split and restores
the mail afterwards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 13:30:45 +02:00
Paul LütznerandClaude Opus 5.5 d9eb6f2863 feat(calendar): the pane starts open beside the mail on a desktop
Anyone who has not moved the switch now gets Split: mail and calendar
side by side, which is what plMail is for and what a demo visitor, who
never chooses anything, should see. A pane shut under the old boolean
setting stays shut; only a missing key means nobody chose.

Phones keep opening on the mail. ui--split already demoted every
remembered position there on connect, but the first paint came before
it: the server rendered the stored mode, the rule for split below lg
hid the mail, and the pane itself is hidden below lg, so the frame was
blank until JavaScript put the mail back. With Split the default that
would have been every phone on every page load. The shell now carries
data-calendar-restored until connect, and below lg a restored position
does not step the mail aside.

The E2E users stay on the mail alone (app:test:seed-user, as the mail
seeder already did), since the suite was written against a list with the
whole row. The list query budget closes the pane for the same reason:
an open pane embeds its body, a fixed few queries that are not the
list's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 13:30:45 +02:00
Paul LütznerandClaude Opus 5.5 9ec01b4a93 fix(push): only inbox changes count against Gmail push, and re-registering clears the card
The watch asks Gmail to announce changes to INBOX alone, and Gmail
pushes only for changes relating to it. The health check read the
unfiltered history feed and took any record in it as a change push
should have announced: the copy of a sent message, an archived thread,
mail a filter kept out of the inbox, spam. So a working push was
reported as broken whenever one of those came more than fifteen minutes
after the last inbox mail. The next inbox push cleared the card and the
next reply raised it again.

gmailHistoryAdvancedAt is now written only for a record whose message is
in the inbox after the change, the label taken from one constant on
GmailWatchService so the watch and the evidence cannot drift. Mail moved
out of the inbox is left out on purpose: whether Gmail announces that is
not knowable here, and leaving it out only means a dead push is noticed
at the next inbox mail.

A change recorded before the current watch was registered no longer
counts against it. "Turn instant delivery back on" re-registers, and the
card used to outlive that repair until the next push arrived.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 13:30:45 +02:00
Paul LütznerandClaude Opus 5.5 3cbf346b9f docs: every screenshot is retaken with the bare top-bar logo
The two icon captures now show the horn in the top bar without its
tile, and the rest show the pl mark at its new size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:23:27 +02:00
Paul LütznerandClaude Opus 5.5 a3336fbf6a fix(branding): the tab icon draws what its URL names; the top bar's logo stands bare
The tab icon was drawn from the session, and the appearance pane points
the tab at a new choice the moment it is clicked, before the save lands.
So it drew the choice from before the click, and the browser kept that
drawing under the new URL for a week: every tab icon was the previous
pick, except Classic, whose URL the page had already loaded correctly.
/branding/favicon/{motif}/{paint}.svg now draws what its path says, like
the icon tiles, and is public and cached the same way.

The top bar's logo is the glyph without its tile, from
/branding/logo/{motif}/{paint}.svg: the tile is the phone's app icon,
and in a web page's corner it read as one. The @-horn keeps its tile,
because its cream @ is nothing without the field behind it. Every
top-bar logo, the pl mark included, sits in one 36px box, up from 32.

On a dark bar only the parts that would vanish change: the pl mark and
the horn read the colourway's dark-chrome strokes, and the mailbox post
turns grey. The page renders a light and a dark image and the stylesheet
shows one, so a theme that follows the system is right as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:23:27 +02:00
Paul LütznerandClaude Opus 5.5 00e6cd90fb docs: the README shows the icon picker, and every screenshot is retaken
Two new captures — an icon chosen in its own colours, and the same icon open
on the colourway grid — with the topbar in both frames, since where the icon
ends up is the point of choosing it. The rest are retaken against the demo
mailbox as usual before a tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 23:14:13 +02:00
Paul LütznerandClaude Opus 5.5 2f25d00544 docs: the logo's two steps, and logoMotif/logoPaint for clients; hashes
The appearance page describes choosing an icon and then its colour, with
Original as the motif's default, what a colourway leaves alone, and two new
traps (Follow the theme on a classic theme; picking a motif resets to
Original). The client guide documents the two read-only JMAP properties,
logoMotifs, and the paint table behind app:branding:export-paints. German
prose follows both; translated-from hashes recomputed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:53:47 +02:00
Paul LütznerandClaude Opus 5.5 040986e344 docs: German client guide carries both of today's changes; hash
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:38:08 +02:00
Paul LütznerandClaude Opus 5.5 1aadc53218 fix(security): prune finished jobs and expired trusted devices nightly
Both tables grew forever: TrustedDeviceRepository::pruneExpired() and
BackgroundJobRepository::findFinishedBefore() had no caller. The nightly
app:monitoring:prune now deletes expired trusted devices and background
jobs finished more than 30 days ago (--job-days). The job lookup became a
bulk DELETE, since hydrating would scale with the history and trip over any
row whose kind the enum no longer lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:37:29 +02:00
Paul LütznerandClaude Opus 5.5 ebeaf8cffa fix(security): add a per-username login limit
Symfony's default login throttle counts per username+IP and per IP, so a
guesser on many addresses got five tries per address at the same account,
while the comment in security.yaml claimed it was per username. A custom
LoginRateLimiter keeps both and adds a username-only limit (20 per 15
minutes, set above what a mistyping person reaches since it can also be
spent by someone else). Comment and handbook corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:37:15 +02:00
Paul LütznerandClaude Opus 5.5 dee4257d3f fix(security): check every hop of user-supplied URLs against private networks
The Nextcloud, Paperless and Immich drivers followed up to twenty redirects
unexamined, the URL validator let any hostname through without resolving it
(container names, nip.io, 127.1, 2130706433, mapped IPv6), and push endpoints
were not checked at all. Requests to user-supplied URLs (the three drivers,
CalDAV, ICS feeds, Web Push) now go through UserUrlHttpClient, which wraps
NoPrivateNetworkHttpClient and re-checks every hop. The validator resolves
the host. LAN integrations keep working through the existing
INTEGRATIONS_ALLOWED_HOSTS and admin-pinned server addresses; push endpoints
get their own PUSH_ALLOWED_HOSTS for the bundled ntfy, and never redirect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:37:15 +02:00
Paul LütznerandClaude Opus 5.5 c1a6d12556 fix(jmap): the change log is pruned to sixty days
jmap_change_log grew for the life of the install; pruneOlderThan() had
no caller. app:jmap:prune-changes now runs nightly from the maintenance
schedule. It keeps the newest row of every account and object type so a
quiet type's state token never falls back to 0; clients below the new
floor already get cannotCalculateChanges and resync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:12:24 +02:00
Paul LütznerandClaude Opus 5.5 9b5d795aab docs(jmap): German client guide follows the calculateTotal change
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:12:24 +02:00
Paul LütznerandClaude Opus 5.5 7a7673c0e8 perf(jmap): Email/query pages in SQL instead of reading the mailbox
Every call read and sorted every matching message of the account for PHP
to slice a page from, and received_at had no index. OFFSET/LIMIT now go
into the query; a collapsed list is collapsed first with DISTINCT ON so
position still counts threads; total is a separate COUNT, skipped when
the client sends calculateTotal: false. New index on
(account_id, received_at DESC NULLS LAST, id DESC) for the default sort.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:12:24 +02:00
Paul LütznerandClaude Opus 5.5 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>
2026-09-23 18:22:21 +02:00
Paul LütznerandClaude Opus 5.5 3cfcf5e5c9 fix: config backups carry the clock placement; handbook hash
The clock-placement setting (v0.2.39) was neither in a config backup
nor excluded on the record, so a restore quietly dropped it back to the
default. It rides with the clock format now. The German appearance page
was updated with the English one; its source hash was not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 17:51:00 +02:00
Paul LütznerandClaude Opus 5.5 85ef0e9ee4 fix(e2e): capture inbox-dark in a dark theme
The screenshot emulated prefers-color-scheme: dark, which the server-side
theme never sees, so the README's dark-theme picture was light. Set the
user's appearance to the dark theme for that capture through the settings
route, and post the exported appearance back afterwards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 14:45:51 +02:00
Paul LütznerandClaude Opus 5.5 e9c5f2940b Release v0.2.39
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 14:11:23 +02:00
Paul LütznerandClaude Opus 5.5 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>
2026-09-23 14:06:43 +02:00
Paul LütznerandClaude Opus 5 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>
2026-09-22 17:18:08 +02:00
Paul LütznerandClaude Opus 5 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>
2026-09-15 15:52:00 +02:00
Paul LütznerandClaude Opus 5 376cf4ae72 Add Paperless-ngx as an attachment source and a save destination
Connect it with an address and an API token and it appears beside the other
services; nothing else in the UI had to change, because the picker, the
destination chooser and the connect dialog are all driven by the provider
taxonomy and the driver interfaces.

TAGS ARE THE CONTAINER LAYER. A document archive has no directory tree —
Paperless replaced folders with tags and expects the rest to be found by
searching — so the picker's "descend into a container, pick a file" has
nothing to descend into. The root lists every tag as a folder with the newest
documents beneath it, and opening a tag lists that tag's documents.
Correspondents and document types would serve equally well and are
deliberately not offered: three sideways slices of one archive is a navigation
problem, and search already crosses all three.

Search is Paperless's own full-text index, so it reads the OCR layer as well
as the metadata and a scanned invoice is findable by a line item nobody typed.
That is the feature: there is no longer a reason to open Paperless, find a
document and download it before writing the mail.

Upload is asynchronous — post_document answers with a task id, not a document
id — so upload() returns it prefixed. Ids are opaque outside the driver, which
is what makes the prefix safe, and download() refuses a task id with a
sentence a person can act on rather than letting it 404 later.

ShareLink is not declared. Minting a Paperless share link is a permissioned
feature with its own expiry semantics, not something to do silently. The
taxonomy test that assumed only photo services lack share links had a false
premise; it now states the real rule rather than being relaxed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 21:51:58 +02:00
Paul LütznerandClaude Opus 5 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>
2026-09-14 20:18:57 +02:00
Paul LütznerandClaude Opus 5 fb97a5a6b4 Recognise Google's weekly Testing expiry, and warn before the next one
A Gmail account that stops every few days is almost always one thing: Google
expires refresh tokens issued by an OAuth app whose consent screen is still in
"Testing" publishing status, after about a week. That is the state nearly every
self-hosted install is in, because leaving it there is the documented way to
avoid Google's verification review for scopes of Gmail's reach. So the account
works, stops seven days later, gets reconnected, and stops again. Forever.

Google gives no clue which case you are in. What arrives is `invalid_grant` —
the same code it returns for a revoked consent, a changed password, and a token
that aged out. Four causes, one error, and only one of them is fixed by changing
a setting rather than by signing in again every week.

THE ONLY THING THAT SEPARATES THEM IS THE INTERVAL, and nothing was recording
it. Two columns now do: when the current grant was issued, and how many hours
the previous one survived. The second is kept ACROSS the reconnect on purpose —
it is the only evidence that survives one, and the moment somebody has just
fixed it is the one moment they are looking at the account and could act on the
cause.

Two cards come out of it. A sign-in that died after about a week gets one naming
that cause and the real fix instead of the generic "sign in again" for the fifth
time. And once the pattern has been seen once, a warning appears a day or two
BEFORE the next one runs out, while nothing is broken yet.

The inference needs both halves and says so. Without the history it would nag
every healthy Google account weekly; without the age it would fire the moment
somebody reconnected, and a warning visible five days out of seven is furniture.
The window is 4 to 9½ days: wide enough for a death noticed a day late on the
next sync, narrow enough to exclude a grant that ran for three months and was
then revoked — which is the claim that must never be made, and is pinned in
WeeklyGrantExpiryTest in both directions. An account connected before this was
recorded is told nothing at all.

Verified on a running stack in all three states: the warning on a live six-day
grant with a weekly history, the diagnostic card on a dead one, and the plain
card with no mention of Testing on a grant that lasted ninety days. The last
found a real bug — the check was wired into inspectAccount(), which the settings
page never calls, so the card rendered nowhere. It also must not register as a
CAUSE, or every calendar on the account would grey out and blame a card whose
first sentence says mail is still arriving.

The handbook said "publishing and going through verification is what removes
that", which conflates two separate settings and is what makes people accept the
weekly reconnect as the price of not being verified. Publishing status and
verification status are different fields; an app published without verification
works, with a warning screen the first time. Corrected in English and German,
with a new section in features/health.md describing both cards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 09:34:54 +02:00
Paul LütznerandClaude Opus 5 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>
2026-09-04 16:02:42 +02:00
Paul LütznerandClaude Opus 5 41362290bb Release v0.2.20
Twenty-six commits since v0.2.19, of which the changelog had one entry — the
rest written up here.

The handbook needed two corrections rather than additions. features/mail.md
still said a sender you have written to is pulled into Primary "regardless of
any bulk-mail header", which stopped being true when the mailing limit went in
and directly contradicted the paragraph two sections above it. features/admin.md
described Reported mail as one pile of missed insights with a scope drop-down,
which is now two kinds in one list with a selection. Both German pages follow,
and both markers are re-hashed.

README gains the sorting choice and says the summary can take a whole
conversation. Screenshots are untouched: nothing in the seven views they show
changed this cycle — the new interface is in Settings and in Admin → Reported
mail, neither of which is photographed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:42:52 +02:00
Paul LütznerandClaude Opus 5 87eb10beed A mailing beats having written to the sender
The correspondence override says: you have written to this address, so this
belongs in Primary. That is the right rescue for a person whose mail happens to
carry a bulk-ish header, and in practice it was doing something else — one
enquiry to a shop's info@ about a broken part pinned every anniversary sale that
shop ever sent into Primary, for ever, and nothing on the page explained why.

The distinction it was missing: a person you correspond with does not send you
mail with an unsubscribe link in it. Their marketing platform does, from the
same address, and the address was all the rule could see.

So the rescue now applies to mail that is not a MAILING, which is the case it
was written for. A list header, a list id or an explicit bulk precedence wins
over it; everything weaker — a feedback-id, an automation marker, an unfamiliar
sending domain — still loses to somebody the reader has written to. That is the
same line drawn one commit ago for what the model is shown, and for the same
reason: marketing is legally obliged to offer an unsubscribe mechanism, which
makes it the signal that actually means "sent to many".

The forum and promotion signals move above the override so it can consult them,
and are computed once rather than twice.

THREE EXISTING TESTS ASSERTED THE OLD RULE and are updated rather than deleted,
including one named "whatever the headers say" — which was the bug, stated as a
guarantee. Each keeps its point and now uses a weak marker: what they were
pinning is that the rescue works, and a mailing header would make them tests of
the new rule instead of the one they are about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:19:50 +02:00
Paul LütznerandClaude Opus 5 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>
2026-09-04 13:14:27 +02:00
Paul LütznerandClaude Opus 5 b54cdc0c85 Show the re-sort run in the jobs indicator
Fire-and-forget answered a request for a button with a button whose only
feedback was the page reloading. There is no way to tell whether it is running,
how far it has got, or whether it failed — on work that is minutes of model
calls and is invisible by construction.

So it takes a BackgroundJob row and reports through the indicator that already
exists: the topbar shows it while it runs, counts it off in batches, draws the
bar, and says so if it fails. Mercure carries the nudges, the row is the record,
and closing the settings page changes nothing.

JobKind::Reclassify is the odd one out and is admitted as such rather than
disguised — every other kind is a bulk action over a mail view, which is why
action() and readFlag() exist on that enum. It is there anyway because what a
reader needs from a run of model calls is exactly what the indicator already
gives, and a second progress mechanism beside a working one would be two things
to keep in step and one more thing to learn to read. action() throws for it:
RunBulkStatusHandler is its only caller and only ever holds a kind it was
dispatched for, so throwing beats inventing a string that some switch would
silently accept and act on somebody's mail with.

Progress is published per BATCH rather than per message — twenty model calls a
minute would be twenty Mercure publishes saying almost the same thing, and the
batch is already the unit the flush and the thread re-file happen on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 12:44:03 +02:00
Paul LütznerandClaude Opus 5 39dd7e5857 Ask the model again about recent mail, and let a verdict reach the tab
Two things, and the button is useless without the first.

A VERDICT NEVER REACHED A TAB. Nothing recomputed a message's category after
the classifier stored one: ingest files a message the moment it arrives, the
classifier runs afterwards and asynchronously, and it wrote the answer and
stopped. That was invisible while sorting was rules-only — the verdict was a
tie-break nobody could see — and it stopped being invisible the day sorting
became a setting. Somebody chooses "the assistant", new mail keeps arriving
into whatever the rules said, and the choice appears to do nothing at all.

ClassifyMailHandler now re-files what it just asked about, using each owner's
own preference, and resolves only the threads it touched — recomputing per
ACCOUNT would scan every message the account has, after every batch of arriving
mail. recomputeCategoriesForThreads() is that statement with the threads named,
pinned conversations still excluded.

AND THE ANSWER GOES STALE WHILE THE MAIL DOES NOT. A message is asked about
once, which is right. What changes is the QUESTION: the prompt is editable, the
model is a setting, and what plMail sends alongside a message changed one commit
ago — every verdict stored before the bulk-header line was reached without
evidence the model now gets, and demonstrably differs because of it.

So: a button, bounded to the newest 100, 200 or 500 messages, clamped server
side because the field is a select and anything else arrived by hand. Nothing
re-asks on its own and nothing should — a model call per message is the most
expensive thing here, and spending an afternoon of somebody else's GPU because
an administrator edited a prompt is not a decision to take for them.

ClassifyMailMessage gains a `force` flag, defaulted and last so envelopes
already on the transport still deserialise. The stamp means "already asked",
which is the right reason to skip on ingest and the wrong one when the answer is
what has gone stale.

The re-run INVOKES the classify handler rather than dispatching to it, and the
transport is why: ClassifyMailMessage routes to `ingest`, correct for a message
being classified as it arrives and wrong for several hundred at once — arriving
mail would queue behind a button press for the best part of an hour. Called
directly, the work stays on `maintenance` and reuses the asking, the batching,
the per-message permission check and the re-filing rather than growing a second
copy of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 12:32:53 +02:00
Paul LütznerandClaude Opus 5 e3a4b78d92 Re-file the mailbox when the sorting setting changes
The previous commit shipped the setting and told people to go and find an
administrator if they wanted their existing mail re-sorted. That was wrong, and
the argument against it is the one that should have been made for it.

MessageCategorizer reads only PERSISTED data — stored headers, the provider's
labels, the sender, the model's stored verdict — which is the entire design of
the class. So a re-sort asks no other machine anything: no IMAP, no model, no
network. It is a scan of rows the database already has, a PHP cascade over each,
and a column write, followed by one statement per account that resolves each
thread from its messages.

The caution behind the old answer was real — a hundred thousand things do not
belong inside an HTTP request — but the fix for work too big for a request is a
worker, and this application has four. A summary job was written one commit
earlier on exactly that reasoning and it was not applied here.

Leaving it undone was the worse option, not the safe one. Mail arriving after
the change would be sorted the new way and everything already there the old way,
so one inbox would be filed two ways depending on when each message landed — an
incoherent state nobody chose, produced by a setting they did, with no symptom
that leads back to the radio button that caused it.

Dispatched only when an answer actually CHANGED. The card submits on change and
a browser will happily post the option already selected; re-filing for that is
minutes of a worker writing the answers back unchanged.

Threads pinned to a tab by hand are untouched — recomputeCategoriesForAccount
has always skipped `category_pinned_at IS NOT NULL`, and it is worth saying out
loud on a feature that re-files somebody's inbox.

Safe to run twice: the categoriser is a pure function of persisted state, so a
retried envelope writes the same answers over the same rows. maintenance rather
than ingest, so arriving mail never queues behind somebody's settings change.

The command stays and is unchanged — an operator re-sorting every mailbox after
a rules change wants exactly this work, for everyone at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 11:29:52 +02:00
Paul LütznerandClaude Opus 5 ed4a483ac3 Let each person choose what sorts their mail into tabs
Two decisions, and they were both taken for everybody by a constant.

WHAT DECIDES. The rules read headers — a mailing-list header, an unsubscribe
link, a sender you have written to — and answer the same way every time for the
same mail, on any installation, with nothing switched on. The assistant reads
the message and decides what it is for: better at mail that does not announce
itself, and occasionally confidently wrong. Which is preferable is genuinely a
matter of taste, which is why it is now a setting rather than something argued
about once.

Choosing the assistant puts its verdict ABOVE the header cascade — which is what
choosing it has to mean. Read only as a tie-break, a newsletter carrying
List-Unsubscribe is Promotions whatever the model made of it. Below the
correspondent rule, though, and that placement is the one to keep: "you have
written to this person" is a fact about the reader rather than a guess about the
mail, and a model above it files a colleague under Promotions on one bad guess —
the only mistake in this cascade anybody would actually notice.

WHETHER IT OUTRANKS GMAIL. A Gmail account arrives already sorted, and plMail
has always treated those labels as authoritative — its own rules never even ran.
That is the right default and it is also exactly what somebody who dislikes
Google's sorting could not escape, on the account where they most wanted to.

Per person, not per installation, for AiPreferences' reason: an administrator
decides what is available and a reader decides what happens to their own mail.
Its own embeddable rather than two more fields in the AI bag, because only half
of it is about the assistant — the provider half works on an installation with
no model at all, and folding it in would file a setting where nobody would look
for it.

ONE BEHAVIOUR CHANGE, NAMED RATHER THAN DISCOVERED. The model's verdict used to
be consulted as a silent tie-break: no setting turned it on, none turned it off,
and nothing on screen said it had happened. It is now what Assistant means, and
Rules means what it says — so an installation with categorisation on loses that
tie-break until somebody chooses. The alternative is a model quietly sorting
mail for people who never asked, which is the arrangement this ends. Said in the
migration, in the handbook, and in an upgrade note.

Nothing is re-sorted. The category is recomputed from stored data, so a mailbox
keeps what it has until `app:backfill category` runs — an operator's call, not a
settings toggle's, and the card says so.

Verified on the stack rather than asserted: the card renders, the assistant
option is disabled with its reason when categorisation is off, and a posted
change lands in the column. The four combinations of the two settings pick three
different categories for one fixture, which is what makes them worth having.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 11:16:01 +02:00
Paul LütznerandClaude Opus 5 932c3f1f6f Finish a summary whose reader has gone, and store it
The controller broke out of the loop when connection_aborted() fired and stored
nothing, on the grounds that what exists is half a summary and half a summary
reads as a finished one. That is true of the TOKENS and false of the answer:
draining the generator to the end produces the whole summary, and throwing it
away made somebody whose connection dropped at ninety per cent pay the entire
cost again.

Connections drop here for reasons plMail cannot see — a proxy, a network, a
laptop closing — and on this installation they demonstrably do, on ordinary
summaries as well as long ones. Finishing turns that from "you lost the work"
into "reload and it is there".

The frames stop at the abort, because there is nobody to send them to; the loop
does not. The store happens either way, and only the `done` frame is skipped.

The price, stated where it is paid: a reader who genuinely navigated away keeps
the model busy until it finishes, where it used to be released at once. That is
the trade, taken deliberately.

Two things fall out of it. The generator is now always exhausted by the end, so
AiAssistant::recorded() files what actually happened instead of stamping the
call `cancelled` — a word that was never true of a run the model completed. And
the abandonment path keeps its log line, reworded: it says the reader was lost
and the summary is being finished anyway, with the elapsed seconds and how much
had been delivered, which is still what distinguishes a proxy giving up from a
person navigating away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:44:25 +02:00
Paul LütznerandClaude Opus 5 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>
2026-09-03 14:28:25 +02:00
Paul LütznerandClaude Opus 5 822a17bfef Name the last silent path, and document the proxy it points at
The performance panel said it: "Last call: Summaries · cancelled". That row is
only written when the generator is ABANDONED — AiAssistant::recorded() records
ERROR_CANCELLED when its consumer never reached the end — so the summary was not
timing out at all. It was the controller's abort path, which breaks out of the
loop when connection_aborted() fires and returns without a frame and without a
word, because losing the reader is a normal thing that needs no explaining.

Except it is only normal when the reader left. From inside this process, a proxy
closing the stream looks exactly the same, and one of those is a fault. Silent,
the two were indistinguishable: a card reading "the summary could not be
written", nothing in the log, and a `cancelled` row that reads as somebody
changing their mind. Four rounds of this were spent on timeouts because the one
record that named the real shape was in a panel nobody thought to read.

It logs now, with the elapsed seconds and how much of the answer had reached the
browser. Those two tell the cases apart: somebody who gives up does it at a
human moment and usually mid-answer, while a proxy does it at its configured one
and during the silence before the first token — and the same round number every
time is the whole diagnosis.

AND THE DOCUMENTATION HAD HALF OF THIS ALREADY. reverse-proxy.md turns buffering
off and raises the read timeout to 24h — for /.well-known/mercure, and nothing
else. plMail serves two long-lived streams and only one of them was ever
mentioned; the assistant's endpoints got nginx's default 60-second read timeout,
which is shorter than a summary of a long conversation legitimately takes. That
is now written down, in both languages, with the failure mode spelled out next
to it.

No behaviour change beyond the log line. The proxy block is the fix, and it is
not one plMail can apply to somebody else's proxy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:01:14 +02:00
Paul LütznerandClaude Opus 5 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>
2026-09-03 12:43:18 +02:00