Commit Graph
127 Commits
Author SHA1 Message Date
Paul LütznerandClaude Opus 5.5 71e2176fd1 Let the inbox be one list, without category tabs
Some people do not want their inbox divided. A third control on the
sorting card, Inbox tabs, switches the tabs off per person; on by
default and true on every existing row, so nothing changes by
upgrading. One column, user.category_tabs.

Off changes what is drawn and nothing about what is stored. Mail is
still filed under a category as it arrives, so switching back shows
sorted tabs at once and nothing has to be re-filed in either
direction, which is also why the control queues no ResortMailbox.
That includes the assistant: somebody who leaves it sorting into tabs
they have switched off has decided that, and the card says so when
both are true rather than preventing it.

The inbox query takes a null category for the whole inbox, through
one helper shared by the list and its count. ListViewResolver decides
from the person's setting and not from the value the page posts, so
"select all in this view" is the list the server would have drawn: an
untabbed page posts an empty value, and reading that as Primary would
have acted on part of a selection.

What only exists for tabs goes with them: the strip and its drop
targets, the per-category keys in the counts payload and the grouped
reads behind them, and the panel in message details that explains a
filing and takes "wrong tab" reports.

The JMAP session publishes the choice as inboxTabs beside the
appearance hint. Thread.category is served either way, so a client
drawing tabs from it had no other way to learn not to.

Closes #29

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:11:10 +02:00
Paul LütznerandClaude Opus 5.5 98ea3948f4 Only let mail change the calendar when it comes from where the event came from
Reported in #34, and both findings held up: eight tests written from
the report's scenarios fail on the code as it was.

An invitation was found by its UID and then judged by SEQUENCE and
arrival time alone. Nothing asked who had sent the update, and a UID
is not a secret — everyone invited holds it, and so does anyone a
copy was forwarded to. Any of them could cancel the meeting or move
it and swap its link, and the reader's earlier "accepted" was carried
over on top. A meeting mirrored from a connected calendar was
rewritable the same way, which the report did not mention.

A booking read out of a mail's markup was keyed on the From line's
domain, which nobody had checked, and drawn at once.

One rule closes both, in EventReconciler::mayApplyUnasked(). It asks
about the sender of the MAIL, never the ORGANIZER line, which a
forger copies along with the rest of the file:

  an invitation's update   from an address the event's content has
                           already come from, or from the organiser
                           it names. "Already come from" rather than
                           "is the organiser" because a calendar
                           service sends the invitation and its
                           updates from one address of its own.
  a booking                only when the receiving server vouches
                           for the From domain.
  a From that failed       nobody, the reader's own address included.

A refused claim is held, not dropped: filed unapplied with a reason,
shown on the message's card, and applied when the reader says so.
The rule is a judgment about a sender and the reader may know better.

SenderAuthentication reads the Authentication-Results header and
checks nothing itself; its job is deciding whose header to believe,
since a sender can write one too. mx.google.com for Gmail, the single
nameless header for Microsoft, and for IMAP a server on the same
registrable domain as the account's IMAP host — learning the name
from the mailbox or asking for it in a setting were both considered
and turned down in the issue.

Mail in Spam or the bin is no longer read for events at all, and a
message taken back out is read then.

What it does not catch: on an account whose server reports nothing, a
forged From is indistinguishable from the real sender, and holding
every such update would hold every update a self-hosted mailbox
receives. There the address is taken at its word. The handbook says
so.

Two things turned up on the way. A copy of a meeting on a second
calendar has no source links of its own, so the first version of the
rule held every update on the copy while its sibling took it —
caught by SharedEventUpdateTest, and the senders are now asked across
every copy under the UID. And there is no HTTP-level test of the new
"apply" action; its logic is covered through the reconciler.

One new nullable column, event_source_link.hold_reason. Nothing
already on a calendar changes.

Refs #34

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:01:03 +02:00
Paul LütznerandClaude Opus 5.5 09600802db Let a message be written once and inserted as a template
Settings → Templates is a new section: a tree on the left, one editor
beside it. The tree opens with one folder per mail account, and those
are not stored — an account folder is the account, drawn as a folder,
so the mirror cannot drift. Folders the user makes go inside an
account, inside one another or beside the accounts. Removing an
account or a folder keeps the templates in it and moves them up.

A template holds variables: the recipient's first name, name and
address, the sender's name and address, a signature, and dates with an
offset and a format of their own. They are stored as text tokens in
the body, because the body goes through the inbound-mail sanitiser and
a marked element would not survive it; the editor draws them as chips
and turns them back before it posts.

The compose window gets a button beside the signature one. Dates,
sender and signature are filled in by the server when a template is
inserted. The recipient is not: a template is usually picked before
anyone is addressed, so recipient variables arrive as markers and the
window fills them when To has somebody in it, and asks before sending
with one still open. A template's signature replaces the window's
rather than joining it, and one the template names is pinned so a
change of From leaves it alone.

  TemplateTokens     the notation, read the same way in PHP and in
                     assets/compose/template_variables.js
  TemplateRenderer   what each variable becomes, and who fills what
  TemplateLibrary    the tree, and the only writer of where a
                     template or folder sits

One bug fell out of running it in a browser. Chromium will not put a
caret between a non-editable element and the edge of its block, so a
template ending in a variable could not be given its full stop — the
keystrokes were dropped. The editor keeps a zero-width space beside
each chip and strips them before saving.

Two new tables, mail_template and template_folder. Web only so far:
nothing is published over JMAP yet.

Refs #26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:27:17 +02:00
Paul LütznerandClaude Opus 5.5 ec938bca99 Count the sessions a public demo hands out, without keeping an address
Admin → Demo visitors, in demo mode only: sessions started in the last
hour, 24 hours, 7 days and 30 days, the different visitors behind them,
the total and how many have a mailbox now. The users cannot be counted
because the reaper deletes them, so /demo writes a row of its own.

That row holds a keyed hash of the address with its last part cut off
(a /24 or a /48), never the address, and the reaper removes the hash
after 30 days. The demo's privacy notice describes it.

Outside demo mode the section is not in the nav, its route answers 404
and nothing is recorded; the table exists and stays empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:23:27 +02:00
Paul LütznerandClaude Opus 5.5 3a6f4db0f1 Measure mail arrival from when the provider accepted it, and show the slowest
Admin → Performance measured "how late mail arrives" from the date a message
carries, which is the sender's clock. A newsletter dated half an hour before
it was sent, or mail held back by greylisting, was reported as plMail being
that late.

The delay is now taken from the newest Received: header. Each message is
stamped as it is first stored with when the provider accepted it, what
started the sync that fetched it (push, IDLE, schedule, sync button) and
where it was filed. Inbox mail and the rest are counted apart, since
providers only announce the inbox, and the slowest messages of the period
are listed in a section that starts collapsed.

An upgrade task gives the last week of mail its accept time from the stored
headers. What fetched older mail cannot be recovered and shows as a dash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:38:39 +02:00
Paul LütznerandClaude Opus 5.5 2dfc9b3b9b Give the admin panel a page for how long things take
The line under the hold switch in Admin → AI said "typically 6 s, 95%
within 13 s": enough to see that one message took twice as long as the
rest, and nothing about why. System says whether plMail is working;
nothing said how long anything took.

Admin → Performance, directly below System:

  How late mail arrives     per account, provider-received to stored.
                            Seconds is push working; minutes is the
                            quarter-hourly poll, which "works" and so
                            showed up nowhere. Imports and plMail's own
                            sent copies are not arrivals.
  What each worker has      the queue numbers by the process that owns
  waiting                   them, a worker's queues in the order it
                            looks at them. Live; the transport keeps no
                            history.
  New mail waiting for      one row per held message, its wait split
  the assistant             three ways: queued, the model call, and how
                            much of the call was the model loading.
  Model host                moved from Admin → AI, unchanged.

To split a wait, the three parts have to be recorded with the message:
ai_queue_ms, ai_call_ms, ai_load_ms. ai_call_metric has the same
numbers and no idea which message they were for, and its timestamps are
the database's clock where the message's are PHP's, so a join by time
is not one to rely on. AiAssistant::ask() returns the whole result for
that; chat() is ask()?->content.

The model host's card and the search index backfill were one fragment
polled by one endpoint. They are about different things — a measurement
and a job with Start and Pause on it — so the fragment renders either
half, each page names the one it wants, and a caller that names neither
gets both, as before.

BackgroundProcesses::consumers() is the worker-to-queue map, read by
all() to start the processes and by this page to label them. They were
two facts that had to agree.

Rows name accounts. Nothing about any mail's content is shown.

Not looked at in a browser; the E2E card-inset spec covers the section
and has been given its frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:07:20 +02:00
Paul LütznerandClaude Opus 5.5 88b1744385 Leave the ingest worker to fetch mail, and nothing else
Adding an account put new mail behind hours of follow-up work. What
plMail does to a message once it has it — a model call where the
assistant sorts, event extraction, insight cards, receipts — was queued
on `ingest`, beside the syncs, for every message of the import, oldest
first. One consumer serves that queue. 988 envelopes pending and the
oldest an hour old was a mailbox being asked about newsletters from
2019 while mail on the server waited to be fetched.

`ingest` is now what talks to a mail provider. The rest moved:

  enrich_live     worker-live      follow-up work for mail that has just
                                   arrived on an account whose import is
                                   over; never behind an import
  enrich          worker-enrich    the same for an import's recent mail,
                                   and search indexing
  enrich_backlog  worker-enrich    old mail for the assistant, consumed
                                   only when `enrich` is empty
  release         worker-release   ends a hold (below); nothing else

Which queue is decided per batch, not per message class: the pipeline
asks InitialImportState once, carries the answer on PostIngestResult,
and EnrichmentRouter turns it into a stamp.

Only mail from the last 30 days (ENRICH_RECENT_DAYS) gets follow-up
work on arrival. Older mail is stored, threaded and filed by the rules.
The assistant is asked about it afterwards — newest first, a bounded
handful per run, and not until every account of that person has
finished importing (app:ai:classify-backlog, ClassificationCatchUp).
For the other steps `app:backfill` remains the way to read history.

ExtractInsightsStep and ProposeEventsStep ran their work inside the
sync and argued for it; both dispatch now.

New mail no longer appears in one tab and moves to another. For
somebody sorting by assistant the rules' category is a guess about to
be replaced, so where the verdict could still change it the message is
held: stamped before threading, its new thread given no category, which
every tab query already reads as "in no tab". The answer releases it.
Failing that a timed release does, after AiSettings::holdMaxSeconds —
60 by default — and the mail is shown where the rules put it; the model
call carries on and moves the mail when it answers. A message is asked
about once. Admin → AI has the switch, the ceiling, and the measured
delay over the last week beside them.

Three things about the release that were each wrong once first:

  - It ran on the live worker, so it could not fire while that worker
    was inside the call that was taking too long. It was moved to
    export, which mixed mail appearing with mail leaving. It has a
    queue and a worker of its own.
  - The call was cut off at the ceiling and the message asked about
    again later. The hold and the question are separate; only the hold
    has a deadline.
  - A late verdict was stored and moved nothing. It moves the mail.

Both workers still end at one model host, so bulk classification looks
at the live queue before each call and stands aside (LiveMailPriority).

One migration: two columns on `message`, two on `ai_settings`.

Not verified against a real import or a live model host: the tests pin
the queues, the hold and the release against in-memory transports and a
mocked host. Known and left: IMAP still imports oldest first; a reply
into an existing thread cannot be hidden; a Gmail account counts as
importing for up to an hour after its last message lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:18:44 +02:00
Paul LütznerandClaude Opus 5.5 f0380c47fa Make the wax seal the icon an account starts on
The default icon was the pl mark. It is the wax seal now, in its own
design: the mark pressed into wax is still recognisably plMail, and it
is a picture rather than two letters, which is what an icon on a
catalogue page or a home screen has to be.

Three things move together:

  LogoMotif::DEFAULT         the seal, for new accounts and for pages
                             with nobody signed in
  Appearance::$logoOriginal  true by default, so the seal arrives the
                             way a picked motif does and not in the
                             theme's colourway
  public/icons/              favicon.svg and the four PNGs redrawn as
                             the seal on its tile

A migration moves the column defaults and every account that never
chose an icon: the pl mark, still in berry, still linked to the theme.
That cannot tell an untouched account from one that deliberately stayed
on the mark; those get the seal too and can pick the mark again.

The static files are drawn by hand from the motif's geometry, so a test
now holds favicon.svg to the default icon's own colours.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:41:08 +02:00
Paul LütznerandClaude Opus 5.5 6edc6c324c fix(deploy): the cache table exists before any worker reads it
A fresh install logged a warning about once a second from the worker,
until an administrator happened to press "Restart workers":

  Failed to fetch key "workers.restart_requested_timestamp": ...
  relation "cache_items" does not exist

The restart signal and the notice throttle live in cache pools on
Symfony's DBAL adapter, which creates cache_items on its first write.
Every Messenger consumer reads the signal on every loop, long before
anything writes it, and each failed read is logged. The migrations
leave the table out on purpose, because the adapter owns its DDL.

app:db:migrate now has each DBAL adapter create its table when it is
missing, after the migrations and inside the advisory lock, so php and
the worker cannot race each other to it. The adapter still owns the
DDL: it names its table through configureSchema() and creates it with
createTable(). Best effort, like the statement statistics: a failure is
a warning, not a container that will not start. Existing installs get
the table on their next boot.

The adapters are collected by class in a compiler pass rather than
injected by pool id. In dev and test the profiler wraps every pool in a
TraceableAdapter, which has no createTable(). It is the lookup
DoctrineBundle uses for its schema listener, and it covers any DBAL
pool added later.

Checked on two fresh compose.yaml + compose.demo.yaml stacks: 95
warnings in 75 seconds without this, none with it, and Restart workers
still reaches every consumer through the table created at boot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 16:45:47 +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 24d7bdfc07 feat(appearance): the logo is an icon, then its paint
Appearance gains logoMotif (default pl) and logoOriginal (default false),
with Version20260923223000 adding both columns so every account keeps the
mark it has. effectiveLogoPaint() says what the chosen icon wears: for the
pl mark it is effectiveLogoStyle(), unchanged; a motif wears its own design
while logoOriginal holds, then the theme's namesake colourway while linked
and the chosen one when not. A linked motif on a classic theme falls back to
its original rather than to Berry. Export, import and the settings save
carry both fields; an unknown motif keeps the current one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:53:20 +02:00
Paul LütznerandClaude Opus 5.5 cf7b971d0d perf(db): index message_thread for badges, list order and snooze sweep
The dozen sidebar/list aggregates per page and the per-minute snooze sweep
had only account_id to work with. Adds partial indexes for unread, starred
and snoozed threads and (account_id, last_message_at DESC, id DESC) for the
list ORDER BY. Applied against the test database in a rolled-back
transaction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:25:12 +02:00
Paul LütznerandClaude Opus 5.5 12842bb3be fix(imap): index unread rows per folder for the post-sync recount
Every folder sync ends with countUnseenForMailbox(), `mailbox_id = ? AND
seen_at IS NULL`, which visited every row of the folder to test seen_at. A
partial index on message(mailbox_id) WHERE seen_at IS NULL holds only the
unread rows. Migration-only, like the table's other partial indexes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:14:14 +02:00
Paul LütznerandClaude Opus 5.5 b9238ab019 fix(imap): stop re-probing genuine multi-folder copies every sync
repairRelocated() probed every row whose Message-ID was also filed in another
folder. A message that genuinely lives in two folders survived every probe,
collapse() returned 0, and it was back on the work list next sync, costing a
round trip per copy per poll and filling the 100-row slice so real ghosts
behind it waited. Confirmed copies now get message.copies_confirmed_at (new
migration) and are left out of findMessageIdsAlsoFiledElsewhere() for 30 days.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:14:14 +02:00
Paul LütznerandClaude Opus 5.5 07b6724e8c fix(imap): a message that fails to build is retried instead of lost
The catch around buildMessage() logged and moved on without holding the
high-water mark, so lastSeenUid passed the failed UID and it was never fetched
again. The clamp that did exist for empty fetches was per batch and got
overwritten by the next batch's higher UIDs. Now the lowest skipped UID is
tracked for the whole run, both failure paths hold the mark below it, and a
per-mailbox counter (mailbox.failed_uid / failed_uid_attempts, new migration)
lets one message that always fails go after five syncs so it cannot block the
folder.

A present but unparseable Date header made webklex throw while building the
message, failing the fetch for the whole folder. The client now configures a
fallback date (the epoch), and MessageSyncer::dateFromHeader() reads that, or
any throw, as "no date" and uses the arrival time.

Attachment parts are persisted only after the message is fully built; before,
a second attachment that threw left the first part managed and pointing at an
unpersisted message, and the batch flush failed on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:14:14 +02:00
Paul LütznerandClaude Opus 5.5 63b236b134 fix(imap): a folder missing from one LIST no longer deletes its mail
MailboxSyncer removed every stored folder the server's LIST did not name, and
message.mailbox_id cascades, so an empty or partial LIST, or a rename done in
another client, deleted every message of those folders in one poll.

Now an empty listing is refused outright, a listing that would leave out most
of the stored folders is refused like VanishedMessageReconciler's bulk guard,
and a folder that is really gone is only marked (mailbox.missing_since and
missing_syncs, new migration) and skipped by the sync loop. It is removed only
after three consecutive listings and 24 hours, and its messages go through
MessageEraser so JMAP clients get a destroyed change for each. A new folder
with the same special use or the same UIDVALIDITY as exactly one missing
folder is treated as a rename: the row keeps its mail and takes the new path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:14:14 +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 bdcce85acb fix(jmap): state tokens follow commit order per account
The change log's identity value is the state token and is drawn at
INSERT, so two writers of one account could commit out of order and a
client holding the later token never saw the earlier change. A BEFORE
INSERT trigger now takes a per-account advisory xact lock and draws the
number after it. A trigger because Doctrine fires no event inside its
flush transaction before the inserts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:12:24 +02:00
Paul LütznerandClaude Opus 5 5a620f4be7 Run one-time repairs once per installation, and repair the lost bodies
The previous commit stops bodies being filed as attachments. It does nothing
for the mail already in the mailbox, which stays blank — and there was nowhere
for that kind of work to live.

WHY NOT THE FOUR PLACES IT COULD HAVE GONE. A migration runs exactly once on
update and is the right home for schema, but this repair needs the attachment
store and the body sanitizer, and a migration reaching into the container
breaks the day one of them moves — halfway through an update, with the schema
half applied. The entrypoint is downtime: it runs before the server accepts a
request, so a walk over every message put there means an install that updates
at eight in the evening is unreachable until it finishes. BackfillTaskInterface
is deliberately manual, which is right for "regenerate the categories because
the classifier changed" and wrong for damage nobody should read a changelog to
discover. BackgroundJob is per-user, and this is not owned by a user.

So: a ledger, in the same shape as Doctrine's own migration_versions and
readable the same way when the application will not start. A task claims its
row BEFORE running, so a container killed mid-walk leaves evidence rather than
looking like one that never started; three attempts and it stops rather than
taking the worker down every ten minutes forever. The runner re-reads the row
afterwards because tasks clear the EntityManager between batches and the
claimed instance is detached by then — writing through it would flush nothing
and the task would run again for ever.

Scheduled every ten minutes, and what runs every ten minutes is the QUESTION,
not the work: one indexed lookup per registered task against a table with
single-digit rows. What it buys is the gap between an update landing and the
repair happening.

The repair is the first task and implements both interfaces — automatic for
everybody, and app:backfill misfiled-bodies for anyone who wants it now. It
needs no mail server: persistAttachment() stored the bytes on the way past, so
the body has been on disk the whole time it was missing from the screen. It
does lose the declared charset, which MessagePart does not keep, so
CharsetHelper works it out from the bytes — exact for UTF-8 and a reasoned
guess otherwise, against a round trip per message to a server with nothing else
to say.

EXISTS rather than a join in the lookup, and not as a preference: a
multipart/alternative can have both halves misfiled, and SELECT DISTINCT cannot
run at all here — Message carries json columns and Postgres has no equality
operator for json. The repository test caught that on its first run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 11:28:20 +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
Claude 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>
2026-09-04 15:21:11 +02:00
Claude 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>
2026-09-04 14:42:46 +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 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 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
Paul LütznerandClaude Opus 5 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>
2026-09-03 11:42:28 +02:00
Paul LütznerandClaude Opus 5 0e7741b4de Let a Gmailified message reach the Gmail account that carries it
Google can fetch another mailbox into Gmail. With both connected here
every message arrives twice, and SyncGmailMessageBatchHandler already
does the right thing with that: it merges rather than skipping, so the
IMAP row keeps its location and its flags and gains what the Gmail copy
knows — the gmailId, the label ids, the translated labels. Its own
comment calls it "Gmailify dedup: merge, don't skip".

The result is a row that belongs to the IMAP account and carries a Gmail
identity, and every push to Gmail then asked the wrong account. It read
`accountOf($message)->isGmail()`, which for a merged row is the IMAP
account, so the answer was always no — and the message's usable gmailId
went unused with a Gmail account sitting beside it. Archive, trash,
restore, star, mark-read and every label change: applied locally, applied
on IMAP, never anywhere Google could see.

Reported as a mail dragged out of spam that came back. Hetzner obeyed;
Gmail was never told, and the next sync restored Gmail's opinion.

The account that CAN act is the carrier — the Gmail account whose sync
recognised the message — and nothing recorded which one it was. It also
cannot be re-derived in general: a gmailId is issued by one Gmail
mailbox and means nothing to another, so an install with two Google
accounts has no answer to reconstruct. So it is stored, by the one place
that knows, at the moment it stamps the id on.

  message.gmail_carrier_account_id   nullable, ON DELETE SET NULL —
                                     disconnecting the Gmail account must
                                     not take the IMAP mailbox's mail with
                                     it, and a row that stops being pushed
                                     to Gmail afterwards is correct.
  enrichExisting()                   writes it, and only when the carrier
                                     is somebody else: a native Gmail row's
                                     own account is already the one that
                                     can act.
  gmailAccountFor()                  the message's own account when that is
                                     a Gmail one, else the carrier — and a
                                     carrier that has since been
                                     re-authenticated as something else is
                                     refused rather than used.

The backfill takes the unambiguous half on purpose. One Google account
on the owner means there is only one account the id could have come
from; two or more means a guess, so those are left null and filled the
next time the Gmail sync sees the message.

Both tests were run against the unfixed propagator first: the Gmailify
one fails with "actual size 0 matches expected size 1", which is the bug
exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 13:15:43 +02:00
Paul LütznerandClaude Opus 5 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>
2026-09-01 19:45:10 +02:00
Paul LütznerandClaude Opus 5 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>
2026-09-01 12:54:09 +02:00
Paul LütznerandClaude Opus 5 39dec36066 feat(calendar): the calendars themselves have a state too, and ifInState works
Calendar/get was the last method answering with the constant "fixed", left
that way when CalendarEvent/changes landed on the argument that the gap was
small: a user has a handful of calendars, Calendar/get returns all of them by
default, so re-running it is cheap. True about cost, and beside the point. A
state that never moves is a promise that nothing changed, and a client is
entitled to believe it — so renaming a calendar left the old name in the
sidebar until something unrelated forced a reload.

calendar_change_log now records the collections as well. event_id and
event_uid become nullable and their absence is what marks a row as being
about the calendar itself — one discriminator, because an explicit target
column beside a nullable id is a second thing to keep true and the two would
eventually disagree in silence. A sibling table was the alternative and was
rejected for costing a second sequence, a second repository and a second copy
of the token arithmetic, which is the part with the subtle guards in it and
therefore the last part worth having two of.

Every reader filters, and that filter is load-bearing. sync-collection and
CalendarEvent/changes take the rows with an event; Calendar/changes takes the
rest. A reader that forgot would report a renamed calendar as a changed event
and send a CalDAV client after an href built from a null UID, so both
directions are asserted rather than only the happy one.

App\Jmap\Calendar\CalendarState is deleted. Nothing answers with a constant
any more, so the class explaining why anything did has no subject; the
history it carried is folded into CalendarChangeLog and the listener, where
someone meeting the design will actually be standing.

ifInState is honoured rather than refused, and that reversal is the point
rather than a side effect. The refusal was correct while the state was a
constant — a guard that can never fail is worse than no guard, because a
client relies on it — and with a real token the promise is one the server can
keep. A client that read at one state and writes expecting it to still hold
now gets stateMismatch instead of being allowed to overwrite what it never
saw. Same protection If-Match gives a CalDAV client, from the same number.

One bug fell out, and it is the event tombstone's mirror image. queueCollection
deferred the calendar id to postFlush for every kind of change, which is right
for an insert that has not got one yet and wrong for a delete: Doctrine clears
the identifier once the row is gone, so the row recording a removal was the
one silently dropped. Taken eagerly now wherever there is one.

Also updates src/Jmap/README.md — 29 methods, and a note on why calendars
count in their own log.

No deployment change. One migration, relaxing two columns to nullable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 23:12:41 +02:00
Paul LütznerandClaude Opus 5 2790c13a82 feat(calendar): a client can ask what changed, and be told the truth
CalendarState was the string "fixed", and its docblock explained that this
was a confession rather than a design: events are written from the sync
engine, from extraction, from the web editor and from CalendarEvent/set,
and a log recording a quarter of the writes "is not a weaker version of
it, it is a lie with a number on it". So canCalculateChanges was false,
there was no CalendarEvent/changes, and every client re-ran its query.

calendar_change_log closes that. The rows are written by a Doctrine
listener rather than by a recorder the writers call, and that is the whole
point — CalendarEvent exposes public mutable properties, so fields move
wherever somebody assigns one and lets the flush carry it. An RSVP in
InviteResponder, a SEQUENCE bump in EventReconciler, the recurrence flags
in RecurrenceMaterialiser: none pass through CalendarEventWriter, so a
recorder wired into write() would have missed all three and every deletion
besides. Doctrine already knows what changed; asking it is the only way to
be complete without asking every future caller to remember.

  CalendarChangeLog     the log. Its own table rather than new
                        JmapObjectType cases on jmap_change_log, which
                        CalendarState suggested: that table is keyed by
                        account_id, and CalendarAccountResolver says a
                        user can delete their last account and keep a
                        calendar. CalDAV also counts per collection, and
                        there is nowhere to put the collection.
  the listener          onFlush collects, postFlush writes. An ignore
                        list, not an allow list, so a field added later
                        defaults to being logged — over-recording costs a
                        wasted conditional GET, under-recording is the
                        silent failure.
  CalendarChangeReader  tokens and deltas, protocol-neutral, in both
                        scopes: per user for JMAP, per calendar for the
                        CalDAV server this is the groundwork for.

Two bugs fell out while building it.

The first was mine and is why postFlush writes SQL rather than persisting
entities. Flushing from inside postFlush re-runs the whole unit of work,
and the second pass sees what the first left behind: EventDismisser
removes an event while an EventSourceLink still points at it, which is
legal once and throws "a new entity was found through the relationship"
twice. It broke every calendar deletion in the suite and five demo-flow
tests. A plain INSERT stays out of the UnitOfWork entirely, so this
listener cannot make somebody else's flush fail.

The second is subtler: a flush that throws never reaches postFlush, so the
queued rows survived to be written by the next flush that succeeded —
logging changes the database had rolled back. onFlush clears the queue as
it starts. A worker whose EntityManager is reset and carries on is exactly
where that would have bitten.

CalendarEvent/get, /query and /set now return real sequences. Calendar/get
still does not: the log records events, not the collections holding them,
so a rename or a recolour moves nothing. That is a smaller gap than it
sounds — a user has a handful of calendars, Calendar/get returns all of
them by default, and CalDAV discovers the collection set with a PROPFIND
that holds no token. CalendarState now says so instead of describing a
world that no longer exists.

Also marks config/reference.php optional in phpstan.dist.neon. It is
gitignored and generated, every sibling entry in that exclude list already
carries (?), and without it PHPStan refuses to start on a clean checkout.

No deployment change. One new table, created on boot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 22:16:15 +02:00
Paul LütznerandClaude Opus 5 e7f4513574 fix(backup): the config backup had stopped carrying what it promises
A restore looked like it worked. That is the whole shape of this: nothing threw,
the review page listed a clean plan, and the parts that had gone missing produced
no row to warn about, because a section that does not exist has nothing to say.

── A removed user came back to life ────────────────────────────────────────
The restorer's own docblock and the handbook both say a soft-deleted user counts
as existing, "because they hold the email address against a unique index".
They had stopped holding it. Removal frees the address on purpose — the admin
delete action rewrites it to `deleted-<id>@invalid` — so existingId() answered
null for every removed user, the plan called them Absent, and the restore created
them afresh and LIVE: old password hash, TOTP secret, recovery codes, every
mailbox credential. Listed as an ordinary creation, indistinguishable from a new
colleague. The handbook steers operators straight into it.

The rewrite destroys the only copy of the address, so nothing could match it
back; `email_at_deletion` is that copy, written at the same moment. Rows
tombstoned before the migration stay unrecognisable, which is stated and held by
a test rather than papered over.

The regression test that was supposed to catch this passed throughout, because it
wrote `deleted_at` and left the email in place — a row the application cannot
produce. It now tombstones the way the delete action does.

Its sibling: a violated unique index escaped as an uncaught 500 with no message.
api_token.token_hash is unique across the WHOLE install and a soft delete
cascades nothing, so a removed user's tokens still hold those values. Translated
into a stated failure with a sentence attached. Not demoted like other failed
writes — a violated constraint aborts the transaction, so there is no "rest" to
let through, and stopping with a reason is the honest shape.

── Four things a person configured never reached the file ──────────────────
The assistant configuration in full: model host, both models, the reverse-proxy
token that exists in plaintext nowhere else, the feature switches, and every
prompt an administrator rewrote. The per-user half had been carried since the day
it shipped; the instance-level half it depends on had not, so a restore returned
everyone's preferences pointing at a host that was gone.

Account signatures and read-receipt defaults — and on the instance this was found
on, `compose.signature` was the ONLY account setting in use, so every export it
produced held an empty settings bag for every account. The per-alias overrides
key themselves by alias row id, which names somebody else's alias on the target,
so they now travel inside their alias and are rewritten under the new id after
the flush, the way `calendar.target_id` already was.

Five user settings, the chosen log level, and the OAuth scopes that describe what
the provider granted to the refresh token the file already carries.

── Why none of it was caught, which is the part worth keeping ──────────────
Every guard pointed the wrong way. The env test builds its expectation by calling
the constant under test. The users test is fourteen assertArrayNotHasKey calls —
it fails if somebody ADDS a field, never if one goes missing. The version
assertion compares a constant against its own echo. A backup's characteristic
defect is omission, and nothing could fail in that direction.

Two tests now compare the backup against the application rather than against
itself, and fail closed: every User and Account SETTING_ constant must be carried
or excluded on the record with its reason, and the database section must carry
every admin-configured table. The second would have gone red the day AiSettings
landed. A round-trip test pins the set of things a restore drops to exactly two,
each deliberate, so the set can only change on purpose.

── Also ───────────────────────────────────────────────────────────────────
The KDF had a ceiling and no floor, so `memlimit: 1` passed every check and took
a SodiumException out onto a blank error page — through the anonymous
/install/restore among others. Both memzero paths now run under try/finally, the
abnormal case they exist for. App passwords keep the date they were created
rather than the date they were restored.

And the admin handbook said the file contains "no user accounts". It contains
every one, with their secrets — published in two languages. Corrected, along with
the inventories, which now list what actually travels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 17:33:22 +02:00
Paul LütznerandClaude Opus 5 755f3cf257 feat(users): an administrator can run the whole life of an account, and you can change your own password
Managing users was reachable and worked — list, search, create, promote, demote,
remove — but three things around it did not, and one of them was load-bearing.

── You could not change your own password ──────────────────────────────────
Nowhere. Not in Settings, not by mail, not at all; only an administrator setting
one for you. Settings → Security now leads with a Password card that asks for
the current password (an unmapped UserPassword constraint, so it is checked
against the signed-in token rather than against anything the form was handed)
and takes the new one twice. The route carries no id and the form has no
data_class, so it structurally cannot become the admin form by accident.

The floor those passwords are held to was three literal 12s in three files that
happened to agree. It is now User::PASSWORD_MIN_LENGTH, read by all three.

── An account could only be removed, never switched off ────────────────────
Nothing sat between "leave it alone" and "destroy it", so the answer to "this
person has left, keep their mail" was to delete them. There is a deactivated_at
now, and a UserChecker registered on BOTH firewalls — the JMAP one included,
because an app password outlives the browser session it was minted from and a
check only on `main` would shut somebody out of the web UI while every mail
client they had already connected kept syncing.

The refusal is in checkPostAuth rather than checkPreAuth on purpose. checkPreAuth
runs before the password is verified, so refusing there would answer "is this
address a suspended account here?" to anyone who types an address — the question
hide_user_not_found exists to keep unanswerable. After verification, the only
person told is the one who could otherwise have signed in.

Symfony runs user checkers at authentication only, so a session already open
would have carried on until it happened to end. UserProvider::refreshUser()
refuses the same two states on every request. Suspension also travels through
config backup, which would otherwise have handed a suspended account its sign-in
back on restore.

── Two smaller lies ────────────────────────────────────────────────────────
A search under three characters silently returned the unfiltered list, which a
QueryBuilder has no way to complain about, so the floor was dropped rather than
kept as something that could only mislead. And an admin declining to demote
themselves got a success toast for a change that never happened; the refusal now
says so, while the rest of the form still saves.

── Restoring a removed user is deliberately absent ─────────────────────────
Removal overwrites the address, the name and the hash specifically to free the
unique address for reuse, so a restore would have to invent all three and the
original address may by then belong to somebody else. Doing it properly means
snapshot columns and a guided flow that asks for a fresh address — a different
change, which alters what removal means. Documented as absent on the screen an
administrator would look for the button on, because switching an account off is
the answer to the question people are actually asking.

Also fixed en route: the modal form theme dropped help_translation_parameters,
so the first field to use one rendered "at least %count% characters" on screen.
And GermanIsInformalTest only ever globbed messages.de.yaml, leaving validators
and the new security catalogue — exactly where a translator reaches for "Sie" —
outside the test that exists to catch it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 16:02:12 +02:00
Paul LütznerandClaude Opus 5 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>
2026-08-27 17:16:36 +02:00
Paul LütznerandClaude Opus 5 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>
2026-08-27 14:43:59 +02:00
Paul LütznerandClaude Opus 5 2348b75621 feat(db): pgvector as an opt-in image, built without changing libc
The original rejection of pgvector was tested rather than reasoned, and it
stands: pgvector/pgvector:pg18 means musl to glibc on an existing data volume,
and 44 collation-dependent indexes — 18 of them UNIQUE — are then walked with
the wrong comparator, silently, because musl records no collation version so
Postgres's own mismatch check never fires.

So the extension is compiled INTO the alpine image instead. libc never changes,
and there is nothing to rebuild. Verified end to end rather than argued: an
existing volume migrated on stock alpine, stopped, restarted on the pgvector
image, then bt_index_check(heapallindexed => true) across 175 btree indexes —
zero errors.

Two build flags carry more weight than they look. OPTFLAGS="" because
pgvector's Makefile defaults to -march=native, which bakes the build host's
instruction set into vector.so and gives SIGILL mid-query on a host without it,
with an empty log. with_llvm=no because a missing .bc file makes the JIT skip
inlining silently, and inlining does not apply to index scans anyway.

Pinned at v0.8.6, not the earliest that works. v0.8.0 does not compile against
Postgres 18 at all (vacuum_delay_point gained a parameter); v0.8.1 is the first
that does. But 0.8.3 fixed possible index corruption with HNSW vacuuming and
0.8.4 fixed an unrepaired HNSW graph, and message_embedding is ON DELETE CASCADE
from message on a system that deletes mail in bulk — so vacuum is a hot path
here and shipping the earliest that merely compiles would ship two known
corruption bugs.

THE HANDBOOK'S 24x WAS WRONG AND IS CORRECTED TO 1.75x. The SIMD dot product is
microseconds; what dominates is that embedding is real[], so every row pays a
4KB detoast and a fresh real[] to vector conversion. 74,000 rows: 7.70s becomes
4.51s.

NO INDEX IS CREATED, and that is the finding rather than an omission. An HNSW
index answers ORDER BY ... LIMIT k and the search asks distance <= 0.45, which
no ANN index serves. The expression form needed to build one at all is
(embedding::vector(1024)), which Postgres treats as a DIFFERENT expression from
the inlined function's (embedding::vector) — so the index exists, the planner
ignores it, and nothing says so. And it costs 7m40s and 578MB to build at the
maintenance_work_mem this stack ships, behind the one advisory lock every
entrypoint waits on: an eight-minute dead stack, not a slow migration. The
measured route to an index is written down in ai-assist.md rather than half
taken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:48:35 +02:00
Paul LütznerandClaude Opus 5 037219e307 perf(search): compute the vector distance once, on candidates only
Semantic search measured 12.9s mean and 47s max in production, and it was
getting worse as indexing progressed — 61.9% indexed then, projecting to ~19.6s
at 100%. The arithmetic was exact: plmail_embed_distance costs 0.132ms a call
at 1024 dimensions, and one search made about 92,000 of them.

Two faults multiplied.

The semantic ARM was correctly capped at SEMANTIC_CANDIDATES. The outer
`LEFT JOIN message_embedding sem` was not capped at all, so the rank expression
scored every embedded message the query matched — including rows only a keyword
had found.

And it scored them TWICE. The comment above $semanticRank said Postgres matches
identical aggregate calls and evaluates the argument once per row, which is
true, and the code carefully built one string to get it. DBAL expands the same
named :queryVector into a separate positional parameter at each occurrence, so
what reached the server were two textually different expressions. Production's
own pg_stat_statements showed it: $7(...$3 AS real[]) beside $10(...$4 AS
real[]).

Both go away by construction rather than by care. The distance moves into a
materialised CTE over the candidates, the arm reads from it, the outer query
joins it, and plmail_embed_distance and :queryVector each now appear exactly
once in the whole statement. Measured on 20,000 rows: 12,616 calls and 3,781ms
become 2,000 calls and 602ms.

MATERIALIZED is load-bearing, not decoration — without it the planner inlines
the CTE at both references and 3.6x of that goes back. Two CTEs rather than one
for a neighbouring reason: with the threshold applied inside a derived table the
subquery is flattened, similarity lands in both the filter and the target list,
and every row that PASSES is scored twice.

The function's cost is corrected too, and derived rather than guessed:
calibrating two index-only scans against their planner costs gives ~1.5us per
cost unit, so 132us is about 33,000 of them. Rounded up to 50,000, because
underestimating is the failure being fixed. Only the plpgsql body — the pgvector
body inlines before costing.

The cheap pass now runs under a statement_timeout and drops the vector for the
rest of the search when it expires, so a pathological plan is paid once rather
than up to four times.

ONE BEHAVIOUR CHANGE, deliberate. Ranking moves for 3,966 of 5,308 rows in the
benchmark, every one a keyword row: the old unbounded join had no threshold and
no recency bound, so GREATEST(ts_rank, 1 - distance) took the vector's number on
rows the vector had never found. 80.5% of keyword rows were ranked by a
below-threshold similarity averaging 0.308 against a true ts_rank of 0.067. All
308 semantic-only rows keep their rank exactly, and EXCEPT ALL in both
directions on (thread_id, semantic_only) is empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:48:14 +02:00
Paul LütznerandClaude Opus 5 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>
2026-08-27 11:27:08 +02:00
Paul LütznerandClaude Opus 5 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>
2026-08-27 00:38:43 +02:00
Paul LütznerandClaude Opus 5 92004ee576 fix(jobs): background work would not open, and finished work never left
Two faults in the same indicator, reported together as "it fades in and out but
clicking does nothing" plus three "Marking as read" jobs stuck at partial
progress that had stopped weeks earlier.

The menu was hidden by Tailwind's `hidden` CLASS while ui--dropdown opens a menu
by clearing the `hidden` PROPERTY and never touches classes. So the panel was
display:none for the whole life of the page — and because the attribute was
absent, the first click read the menu as already open and CLOSED it. No error,
no console warning, nothing to search for. Every other dropdown in the app uses
the attribute; this was the one that did not, and a static test now says so.

The stuck jobs were a different fault with the same face. findVisibleForUser
matched `state IN (queued, running)` with no time bound of any kind, so "still
going" and "abandoned" were literally the same row and no amount of waiting
would ever change it. A job that fails properly calls finish() and clears after
five minutes; these never reached that line — a killed worker, a restart
mid-deploy, a fatal that bypassed the failure path.

The entity had no progress timestamp, so a long legitimate job could not be told
from an abandoned one by time. It has one now, stamped by begin() and advance()
so the counter and the stamp cannot come apart, the query is bounded by it, and
app:jobs:reap marks the stragglers failed so they are seen once and then go.

The migration backfills last_progress_at from created_at, which is the only
value that gets both directions right: a job stranded weeks ago is instantly
outside the window and swept, while a bulk action started two minutes before the
deploy is inside it and left alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 00:38:16 +02:00
Paul LütznerandClaude Opus 5 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>
2026-08-27 00:38:02 +02:00
Paul LütznerandClaude Opus 5 1b6c7d9916 feat(ai): somewhere to keep a vector, and one way to compare two
Storage for semantic search. Nothing searches yet — this is the part the
search has to stand on, and most of its design was settled by measurement
rather than by preference.

NO pgvector, AND THAT IS THE RECOMMENDATION NOT THE COMPROMISE

The shipped image is postgres:18-alpine, which has no vector extension.
Moving to pgvector/pgvector:pg18 means musl -> glibc on an existing data
volume, and that was tested rather than reasoned about: the swap returns
WRONG ROWS from an index scan with an empty log. musl records no
collation version, so datcollversion is NULL, so Postgres's mismatch
check never fires at all. 44 collation-dependent indexes, 18 of them
UNIQUE, silently walked with the wrong comparator.

On a stack whose upgrade doc says "Starting the new image IS running it",
that is not a hazard to accept for a feature nobody has asked to be
mandatory.

ONE FUNCTION NAME, TWO POSSIBLE BODIES

The application only ever writes plmail_embed_distance(a, b). The
MIGRATION picks the body: a plpgsql loop normally, and a one-line SQL
cast to `vector` if the extension happens to exist — which Postgres
inlines, so even an HNSW expression index over the same real[] column is
reachable. Detection lives in SQL, once. No PHP branch, no second query
builder, and the same tests exercise whichever body is installed.

Measured: 0.11ms per 768-dimension row, so 56ms over 500 candidates —
cheaper than the full-text pass that produces them. Over 100,000 rows the
same loop is 11 seconds, which is worse than the 9-10s pathology the
search UNION was rewritten to escape. So it is only ever allowed to run
over a bounded candidate set, and that is a rule the search integration
has to keep rather than a hope.

ONE ROW PER MESSAGE, IN A TABLE OF ITS OWN

Per chunk would multiply every (thread, matching message) row before the
GROUP BY collapsed it again — precisely the row multiplication removed
from the label joins in v0.1.40, whose defining property is that the
answers stay correct and only the clock shows it.

A table rather than a column because a 768-dim float4 array is 3,092
bytes, past the TOAST threshold, so every vector is stored out of line —
391 MB for 100,000 messages, measured. That does not belong on `message`.

NORMALISED ON THE WAY IN

Unit length at write time is what lets the distance function be a dot
product with no square root per row, and the arithmetic dominates I/O
about six to one in that loop. The test reads the value back OUT of
Postgres to check the length, because a vector that came back
denormalised would make every distance subtly wrong in a way nothing
reports — the search would simply rank badly.

A zero-length or non-finite vector is refused rather than stored: one NaN
in that column poisons every ORDER BY that touches it, and it would be
blamed on the search rather than on the model that produced it.

DBAL, no entity, on purpose. real[] is not a type Doctrine can name, and
teaching it one means a mapping_types entry so the comparator stops
asking to rewrite the column on every diff — the workaround already
carried for tsrange. Nothing here needs an object.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 18:08:22 +02:00
Paul LütznerandClaude Opus 5 ac5b5360fe feat(ai): a second opinion on mail no rule recognised
Categorisation, built so that the model can only ever break a tie.

WHERE IT IS ALLOWED TO COUNT

At the very bottom of the cascade, after every rule has declined. At that
point the honest answer is "we do not know, so Primary", and that is the
one place another opinion helps without doing harm.

Anywhere above it, the model could only overrule something that actually
matched a header — which would make a tab's contents depend on which
model happened to be installed the week the mail arrived, with nothing on
the row to say so, decided unattended on every message that arrives.
MailClassificationTest asserts it cannot: a matched header, a
correspondent and a Gmail label each outrank a verdict that disagrees.

STORED BESIDE THE CATEGORY, NEVER IN IT

`category` is what the deterministic rules produced and what every list
query filters on, and it has to stay explicable without reaching for a
machine that may since have been switched off. So the verdict lives in
its own column and the cascade reads it only in the branch it would
otherwise have answered "default" from.

Which makes switching the feature off sufficient on its own: nothing
clears the column, nothing needs re-processing, and a stale verdict from
a model removed months ago is inert as long as a rule recognises the
mail. There is a test for exactly that.

NO CALL ON ANY READ PATH

MessageCategorizer never talks to anything — it reads a verdict stored
asynchronously. explain() is called from the details panel, from ingest
and behind list rendering; an HTTP round trip in there would put another
machine on the path of drawing a page.

The one request lives in a Messenger handler, on a worker, after the
transaction, over a batch, for a subset. The step that dispatches it
checks the feature is on before enqueueing anything, and the handler
checks again — not redundantly: almost every install has this off, and
without the first check every one of them would queue a job per batch for
a handler whose whole body is `return`; without the second, a job already
queued when somebody switches it off would still run.

WHAT IT REFUSES TO ASK ABOUT

Anything a rule recognised — the answer is known, explicable, and would
be discarded anyway. That test needs the correspondent set, which is a
query, so it happens in the handler rather than in the step that runs
inside a sync holding a mailbox connection.

The attempt is stamped even when the answer is unusable, so a backfill
can tell "asked, got nothing" from "never asked" and does not retry the
same unanswerable message forever. An ambiguous answer — two category
names in one reply — is discarded rather than resolved by first match:
the model has not answered the question, and leaving the mail where the
rules put it is the honest result.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 18:01:42 +02:00
Paul LütznerandClaude Opus 5 64ac8f3704 feat(ai): the foundation — a model host, a gateway, and an admin section
First slice of the optional AI feature set. Nothing user-facing does
anything yet; this is the part everything else stands on, and it is
built so that an installation which never touches it cannot tell the
difference.

OFF IS A REAL STATE, NOT AN UNCONFIGURED ONE

Every column starts off or empty and plMail is a complete mail client in
that state: no degraded search, no empty panels, no nagging. That is the
state every existing install is in and the state most will stay in, so
the expensive thing to get wrong is not a bad completion — it is a
request being made at all by somebody who never asked for this. The tests
that matter most assert what did NOT happen: no HTTP call, no exception,
a null the caller already handles because the host can equally be off at
the wall.

THREE SWITCHES, NOT ONE

Writing help is asked for once, deliberately, by somebody looking at the
result. Categorisation runs on every message that arrives, unattended,
and a confidently wrong model quietly misfiles mail. Embedding runs over
the whole mailbox and then on everything new. Wanting the first and not
the third is reasonable, and a single master switch would make that
choice for people. The master switch overrules all three, so everything
can be stopped in one action without losing the configuration under it —
the difference FcmConfig draws between "not using this" and "lost the
key".

WHY OllamaClient IS NOT ImageProxyFetcher

That class exists to REFUSE private addresses: it takes a URL out of a
message somebody was sent, so a link to 10.0.0.5 is an attacker asking
this server to fetch something only this server can reach. Here the
private address IS the feature — it is typed by an administrator into a
form only an administrator can open and stored as configuration.

So OllamaClient performs no address validation at all, and the safety is
entirely in where the URL is allowed to come from. The two must never
share a policy and must never share a code path: a helper both called
would eventually be relaxed for one and quietly weaken the other. Said
in both docblocks, because that is the comment somebody will be tempted
to delete.

DIALECT

/api/embed with a fallback to /api/embeddings — a host set up a year ago
speaks the older one, and refusing to work against it would be reported
as broken rather than as out of date. Chat is unstreamed: everything
asked for here is short, wanted whole, and inserted into a form.
Components are cast to float because whole numbers decode from JSON as
int, and a mixed list is a valid embedding and an invalid list<float>.

The admin section is registered in all four of the hand-maintained lists
that make one exist — the allow-list, the nav, the refresh-hint
suppression and the section body. Three of four renders a broken page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:49:44 +02:00
Paul LütznerandClaude Opus 5 5f648864ab feat(admin): set the log level from the page that shows the logs
Asked for directly: "let the admin panel set the log level. i do not want
to mess with env vars."

APP_DB_LOG_LEVEL meant editing a file on the host and restarting the
stack, and the moment it is actually wanted is the moment that is most
awkward — something is wrong, the answer is one level further down, and
restarting throws away whatever was in flight. So the level gets turned
down, forgotten, and left down.

WHAT IT IS NOT

Not another control in the filter row. That row narrows what is SHOWN out
of what was stored; this decides what gets STORED. They look alike and
mean opposite things, and side by side they would invite an admin to
change the wrong one and conclude the log was broken. Its own row, its own
form, its own wording.

NULL IS NOT "TODAY'S VALUE"

A stored level of null means "follow the environment", which is
deliberately different from copying APP_DB_LOG_LEVEL's current value into
the row. An install that has never chosen keeps following its
configuration including later changes to it, and there is a way back to
that after a level has been set — which storing the resolved value would
have removed, silently, on the day the env var next changed.

PER RECORD, NOT PER PROCESS

Monolog fixes a handler's level at construction. That is right for one
configured in a file and useless for one an administrator can turn down
from the page they are reading, so DoctrineLogHandler decides in
isHandling() instead, and the constructor's level is now only the answer
when no resolver was given — which keeps directly-constructed handlers,
and every existing test, behaving as they did.

LogLevelResolver caches for ten seconds. A query per log line would put a
database round-trip inside the thing that runs when the database is in
trouble; a value cached for the life of the process would leave every
long-lived worker ignoring the change until the next deploy, and the
worker logs are usually the ones it was lowered for. It uses DBAL, not the
ORM, for the same reason the handler does, and every failure — no table
yet, no database, a name that is not a level — falls back to the
environment rather than throwing. A logger that throws while deciding
whether to log is worse than one briefly out of date.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:21:00 +02:00
Paul LütznerandClaude Opus 5 561fc3f372 feat(mail): a message that bounced now says so, on the message that bounced
plMail could tell you a message had been READ. It could not tell you the
opposite. A bounce arrived in the Inbox as a machine-generated mail
titled "Undelivered Mail Returned to Sender" whose body is an SMTP
transcript, and the message it was about went on sitting in Sent saying
"Sent" — which at that point is a lie. The one delivery outcome a person
has to act on was the one outcome attached to nothing.

The structure was already here. A DSN is ReadReceiptCorrelator's twin:
same multipart/report envelope, same Original-Message-ID lookup, same
"stamp the sent row" payoff. BounceCorrelator is a separate service
anyway, because the two disagree on every judgement that matters.

ONLY `Action: failed`

A DSN reports four things and three of them are not bounces. `delayed`
in particular is a message STILL IN FLIGHT — Postfix emits one after
four hours and keeps trying for five days — and stamping it as
undelivered would tell the user their mail failed at the exact moment it
is being retried, with nothing to clear the stamp when it went through.
`delivered` and `relayed` are good news and set nothing.

THE BOUNCE IS NOT FILED AWAY

A read receipt is noise once correlated and gets marked read and taken
off the Inbox. A bounce stays exactly where it landed, unread. Its body
is routinely the only readable statement of what went wrong, and a
failure the user may have to act on is not something to tidy up on their
behalf.

NOTHING IS CLASSIFIED

The status is stored as the reporting MTA wrote it — "5.1.1", "4.4.7" —
and never reduced to a boolean "permanent". No address is disabled,
nothing is removed from a contact list, no resend is attempted. plMail
records what one server said about one delivery attempt. Deciding an
address is dead on that basis is a conclusion this codebase has been
wrong about before, and the sweep that led here said so repeatedly.

A BOUNCE WAS BEING READ AS A READ RECEIPT

Found by the first fixture written against this, and reachable before
it. Both are multipart/report and both carry Original-Message-ID, so a
DSN satisfied ReadReceiptCorrelator's body fallback, got correlated
there, and put "Read at …" on a message that was never delivered — not
merely missing information but the precise opposite of the truth.

The two field blocks have mutually exclusive discriminators, so that is
what tells them apart now: `Disposition:` is an MDN, `Action:` is a DSN.
Both arms of the detection refuse a delivery-status report first.

ON THE DEMO

The demo seeds a reply sent to a typo'd domain and the MAILER-DAEMON
answer four minutes later, correlated by the real BounceCorrelator
rather than by writing the columns directly. A demo that stamps the
result itself would stay green while the feature broke; this way the red
panel disappearing from the demo IS the alarm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 00:52:32 +02:00
Paul LütznerandClaude Opus 5 91551b2bd2 feat(health): count the times an account had to list its whole mailbox again
Both providers hand out a sync cursor that expires, and the answer to
either is the same: throw the cursor away and re-enumerate everything.
v0.1.32 made that recovery correct. Nothing recorded that it happened.

Once in a while it is housekeeping — a mailbox that sat untouched past
the provider's retention window. Repeatedly it means something else: the
account is not being synced inside that window at all, or a worker is
dying mid-batch and never getting far enough to store a new cursor. The
symptom either way is a mailbox that is intermittently behind, which
from outside looks like nothing whatsoever.

So the account now carries fullResyncCount and lastFullResyncAt, written
where the two syncers already handle expiry — Gmail's 404/410 on a stale
historyId, Graph's resyncRequired. The health page attaches it as a FACT
on the existing "sync failing" card rather than as a card of its own,
because on its own it is not a problem and a card would make it read as
one.

HealthFact gained labelParams for it. The inspector still emits keys and
parameters and never a translated string, which is the rule that keeps
the health page translatable and testable in one language.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 00:52:12 +02:00
Paul LütznerandClaude Opus 5 b939fdae83 feat(mail): acting on a whole view runs as a job, with an indicator that says so
Selecting 5,455 conversations and marking them read died at `Maximum
execution time of 30 seconds exceeded`. The request resolved the view,
hydrated every thread AND every message, checked ownership per thread,
then wrote — all inside a request with thirty seconds to live. The user
got a broken page and no way to tell how much had happened.

Chunking the Gmail call in v0.1.30 fixed the API refusing the batch. It
did not make the work fit in a web request, because nothing could.

A whole-view action now creates a job and returns. A list of ids stays
inline, deliberately: it is bounded by the rows on screen, finishes in
milliseconds, and answering it with "started" would make every ordinary
archive feel slower than it is.

THE JOB IS A ROW

Same reasoning as MailRule's run state: a job over a large mailbox takes
minutes and the person who started it closes the tab. The row answers
"is it done?" afterwards, on any device. Mercure only nudges a page that
is still open, and carries no state — a missed nudge costs one stale
render, never a wrong number.

The handler chunks for progress rather than speed: the counter is
flushed per chunk so the indicator moves, a worker killed mid-run leaves
what it did done and recorded, and the EntityManager is cleared each
time, which is what stops a run over thousands of threads being killed
for memory instead of time.

THE INDICATOR EXISTS ONLY WHEN IT SHOULD

Nothing in the topbar when there are no jobs — a permanent fixture is
one more thing to learn to ignore. A job that SUCCEEDED does not linger
either: it has already changed the list the user is looking at, and an
indicator appearing and disappearing on its own is a topbar that reflows
for no reason. A failure stays for five minutes, because nothing else
will say so.

It renders from a Twig function rather than a frame with a src. A frame
that fetches itself costs a request per page load for every user to
answer "nothing is happening" — and the appearance preview's assertion
that it loads no mail caught exactly that, correctly.

WHERE THE TEST LIVES

The offload contract is a controller test, not an E2E one. It was an E2E
test first and it was a bad citizen: a view selection spans every account
by construction, so acting on one in the shared fixture mailbox reaches
every other spec's mail, and the job's timing belongs to the environment
rather than the spec. That cost two suites' worth of unrelated failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 23:22:51 +02:00
Paul LütznerandClaude Opus 5 25d2ec3817 feat(health): partial grants on connected services, the account behind a failed job, and what the mail server said
Three of the discarded signals from the sweep, all the same shape: the
app had the fact in hand and threw it away.

CONNECTED SERVICES HAD THE MAIL ACCOUNTS' OLD BLINDNESS

A file store or photo library can grant part of what was asked for and
return a token that works perfectly for the part it granted. Google
Photos is the case that makes it concrete: an unverified app is
routinely refused `photoslibrary.readonly` while being given
`appendonly`, so saving to a library works, browsing one is empty, and
nothing connects that to a consent screen clicked through last week.

Recorded and compared now, with `offline_access` excluded — it asks for
a refresh token rather than access to anything, and Microsoft does not
echo it, which is the same false positive the mail side had to unpick.

A 403 from Google Drive or Photos is also no longer reported as a
missing permission when it is a rate limit. messageForStatus() read the
status and never the body, so `userRateLimitExceeded` — a burst of
saves, or a share-link action issuing two calls per file — told the user
to re-grant a permission they already had, on a connection that would
work again in a minute.

"50 BACKGROUND JOBS WERE GIVEN UP ON" NOW SAYS WHOSE

Nine of the nineteen message classes carry a public accountId, and it
was discarded with the rest of the envelope. The card could not say that
all fifty belonged to one mailbox whose grant could not write — which
was the entire answer on the install that prompted this, sitting in the
envelopes the whole time. Resolved in one query, not one per row.

THE MAIL SERVER CAN FINALLY SAY SOMETHING

RFC 3501 §7.1: the text of an `* OK [ALERT]` response "MUST be presented
to the user". plMail read those lines off the socket in its IDLE loop
and dropped them, which made it a MUST-violating client and lost the one
channel a server has for saying what nothing else will.

Quota is why it matters, and plMail's own preset list is full of German
consumer ISPs with small free tiers. An over-quota mailbox connects
fine and syncs fine; it just stops receiving, while every other signal
in the app looks healthy.

Shown verbatim — the server wrote a sentence for a person to read — and
with no repair, because plMail cannot empty somebody's mailbox and a
button that did nothing would be worse than none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 22:04:23 +02:00