525 Commits
Author SHA1 Message Date
3c7a03b843 Talk to an OpenAI-compatible endpoint as well as Ollama
The language model had to be an Ollama container. Admin → AI now has a
Provider field, and the second choice is the request format OpenAI
published and nearly everything else has copied: llama.cpp, vLLM, LM
Studio, LiteLLM, the hosted providers, and Ollama itself under /v1.

OpenAiClient speaks three paths, /chat/completions, /embeddings and
/models, and answers in the value objects and error categories
OllamaClient already uses. AiAssistant reads the connection for the
feature being served and picks the client; nothing above it knows there
are two. The body carries only what the format defines, because several
servers answer 400 to a field they do not know. The stream is read a line
at a time, skips comment lines, does not require [DONE], and fails on an
error object delivered inside a 200. Redirects are never followed.

What only Ollama can be asked is not asked of the other dialect:
no keep-alive is sent, the composer is not told to expect a cold start,
Warm up refuses with its own reason, and the panel reads no /api/ps. The
page puts those controls away while the other provider is chosen and
keeps their values.

A stored token is dropped when the provider changes, as it is when the
address does. One column on ai_settings, defaulting to ollama; the
migration is reversible.

The SSE handling, the strict reading of an embedding and the rule that a
key stays with its endpoint come from #58.

Co-authored-by: shady <shady2k@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:54:49 +02:00
Paul LütznerandClaude Opus 5.5 d6cd145951 Fill the placeholders in a failed model host test
A failed Test or Warm up in Admin → AI names its reason by key and hands
the details to the translation as parameters. The parameters were keyed
`status` and `error`, the translations say `%status%` and `%error%`, and
Symfony replaces by exact key. It found the word inside the placeholder and
left the percent signs standing: "HTTP %403%".

AiProbe and AiWarmUp now hand the template their parameters wrapped. The
bare keys stay as they are for the callers that read them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:38:21 +02:00
Paul LütznerandClaude Opus 5.5 896f30eb02 Import an account's history without holding up new mail
A first sync used to be the import: every IMAP folder from its oldest
message to its newest, every Gmail message listed before one was fetched,
every Microsoft folder enumerated to its end, all in one job on the queue
new mail arrives on. A large account showed an empty Inbox for most of an
hour and every other account's mail waited behind it (#42).

The sync now only arranges for new mail to be seen. History is
MailImporter's, a page at a time on a new `ingest_backlog` queue with a
worker of its own, newest mail first and the Inbox before anything else;
Spam and the bin come last. Accounts take turns, a page that fails costs
itself, and every sync restarts an import that has gone quiet.

- IMAP walks each folder down from the top in pages of fifty.
- Gmail asks for the newest 200 of the Inbox, then pages the listing.
- Microsoft reads each folder's first delta round a page at a time; a
  folder being read is asked only what it received since. Removals that
  cannot be judged while a folder is unread are kept and judged later.
- Sync requests a later sync has already covered are skipped.
- A chip beside the search box shows that an import is running and how
  far it has got. The indicator never updated on mail pages, because its
  controller was not mounted there.

Also from #42: an account can have a separate password for sending,
behind a button under the SMTP fields. One field by default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:43:03 +02:00
9ed0440bc7 Read parcels from French carriers and Amazon.fr (#50)
The radar's parcel extractor only knew German carriers and German and
English wording, so parcel mail from French carriers was refused at the
subject gate or read under the wrong carrier.

  Carriers   Colissimo, Chronopost, Mondial Relay, Colis Privé, DPD
             France and Cainiao by sender domain; Amazon.fr as a
             merchant, with its button opening amazon.fr.
  Numbers    Colissimo's and Cainiao's own shapes. Mondial Relay's
             eight bare digits only from Mondial Relay and only after
             "colis". Colis Privé's seventeen digits out of the links
             of the html part, where alone they appear.
  Wording    French stage phrases, whole phrases only, with the
             negated forms kept from reading as delivered; French
             months, weekdays and relative days; day-first slash dates
             after a French word for the promise.
  Stage      ready_for_pickup, for a parcel waiting at a relay point
             or locker.
  Surveys    a carrier's satisfaction survey is refused, since the
             newest mail overwrites a card's status.

A competitor's name is context only when no other carrier sent the
mail, and a French S10 number from an unknown shop reads as La Poste.

Not read yet: GLS France and the Pickup relay network, and the
"returned" and "attempt failed" stages. The French tracking links
were built from public URL shapes and not checked against real mail.
No migration.

Review kept three of the French rules from firing on other mail: slash
dates, numbers anywhere in the html part, and "expérience" anywhere in
a subject.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Paul Lützner <mail@pluetzner.de>
2026-10-10 20:48:13 +02:00
24d3c19b1d Choose which IMAP folders to sync (#52)
Mailbox::$isSyncEnabled already decides whether a folder is polled, held
with IDLE and counted by the remote-deletion check, but nothing let a
person change it. Settings → Accounts has a button on every IMAP
account's row that opens the account's folders, each with a switch.
Switching one off stops plMail fetching it; mail already stored stays,
and switching it back on resumes from where it stopped.

The inbox is listed with its switch disabled, and the controller refuses
it too. Gmail and Microsoft accounts sync over their provider's API and
have no folder rows, so neither route exists for them. The path carries
two ids: the account is checked for ownership, then the folder against
that account, then a per-folder CSRF token.

Review added, in the same change:

  Tree          the list is drawn as the tree the paths describe, with
                connector lines, each folder under its nearest listed
                ancestor.
  Placeholders  a \Noselect folder is greyed out with no switch to
                press. Mailbox::$isSelectable records what the server
                said; one migration adds the column.
  Dialog        a narrow modal preset and striped rows.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Paul Lützner <mail@pluetzner.de>
2026-10-10 20:39:26 +02:00
Walid ManiandClaude Sonnet 5.5 d919d02bee Show the open message beside the list, as a per-user option (#49)
A per-user Reading pane setting, Off or Right. Right shows the message
list and the open message side by side when the mail card is at least
52rem wide, with a divider that is dragged, moved with the arrow keys or
reset with a double-click. The message's share of the card is stored as
a percentage, 25 to 75, so it keeps its meaning when the calendar pane
opens or is resized. Off is the default and changes nothing.

The mode and the share live in the user's settings bag, not in the
Appearance embeddable: where a message is drawn is not part of a theme
somebody might export. A config backup carries both. No migration.

A message opened by its own address has no list behind it, so beside
the list it is shown next to the Inbox.

Review added, in the same change:

  Divider      one pixel of layout with the grip laid over it, so no
               strip of bare card sits between the list and the line.
  Toolbars     the list's and the open message's are the same height.
  Selection    Archive, Delete and Move to stay above a selection; the
               other six actions are under More.
  Shortcuts    a key acts on ticked rows wherever the list is on
               screen, and ticking a row with the mouse no longer
               leaves every key after it dead.
  Settings     a Features group with a page each for the reading pane
               and the keyboard shortcuts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:13:08 +02:00
Walid ManiandClaude Sonnet 5.5 cb34fa8e28 Let the category tabs scroll instead of overlapping on a phone (#53)
The tab strip let every tab shrink (min-w-0) while keeping its label on one
line, so on a narrow card the labels ran underneath the next tab's icon.
Tabs now keep their own width and the strip scrolls inside itself, with
tighter padding below the list's @xl breakpoint. A small Stimulus controller
puts the current tab in view, since the strip is re-rendered on every tab
change and would otherwise reset to the left edge.

The strip has no visible scrollbar and a wheel only reports vertical
movement, so with a mouse on a narrow window the tabs past the edge could
not be reached (touch and a trackpad's sideways swipe already worked).
A vertical wheel over the strip now scrolls it sideways, and hands the
wheel back to the page once the strip has run out in that direction.

Covered by a Pixel 5 e2e spec: no tab is squeezed, the last tab is reachable
by scrolling, the current tab is in view on load, and the wheel scrolls the
strip until it runs out.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:45:28 +02:00
Walid ManiandClaude Sonnet 5.5 a42ab3b762 Leave Live Photo motion clips out of the Immich picker (#47)
Immich stores the video half of a Live Photo as an asset of its own with
visibility "hidden", never shows it in its timeline and never makes a
preview for it. A metadata search lists every visibility but "locked"
unless asked for one, so those clips came back as grey placeholder tiles
that could only be attached as a three-second video.

They are dropped when an entry is built rather than filtered in the
request: the request can name one visibility only, and "timeline" would
take archived photos away with the clips. A server that predates the
field sends none, and such an asset is kept. Library, search, album and
person views all share the mapping, and a page of nothing but clips
still carries its cursor so the scroll goes on.

A page can now arrive with no entries and a cursor. The picker said
"empty" for any entry-less listing and rendered no results container,
which is what the next page is appended into, so an all-clips first page
would have swallowed every page after it. "Empty" is said only when
there is no cursor either, with two render tests for the two sides.

Fixes #46

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:44:07 +02:00
Paul LütznerandClaude Opus 5.5 1bae047d77 Snooze the open conversation from its toolbar, and with b
Every row offered a snooze menu and the conversation itself did not,
so "not now" after reading a mail meant going back to the list to
find its row. It was also the last key in #36 with nothing to press:
b did nothing in an open conversation.

The toolbar gets the clock button and the same menu, handled by
mail--message-actions. Snoozing closes the pane, as archiving does,
since the conversation has left the list it was opened from; clearing
a snooze leaves the reader where they are. Offered where the row
offers it, not for mail in the bin or in Spam.

The menu opens from the button's left edge there. It opened from the
right everywhere, which suits a button at the end of a row and would
run off the pane from one at the start of a toolbar.

Refs #36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:40:06 +02:00
Paul LütznerandClaude Opus 5.5 7d54acf4d2 Star a selection from its toolbar, and with s
Every shortcut was tried in every context after a turned out to press
a link that is not always there. One more had the same shape: s stars
a row through the row's own star, and the toolbar above a selection
had no star at all, so with rows ticked the key found nothing.

The toolbar gets a Star button, posting the bulk star a drop on
Starred already uses. It stars and does not toggle, since a selection
is a mix of both, and is not offered to a whole-view selection, which
that endpoint refuses.

Refs #36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:34:45 +02:00
Paul LütznerandClaude Opus 5.5 a7b4928bfd Put the list of shortcuts on the mail list's bar
`?` opens the list of keyboard shortcuts, and a key nobody has told
you about is not a way in. A keyboard button on the list toolbar opens
the same dialog, beside the sort menu, on the bar the keys act on.

Only for somebody who has the shortcuts on: switched off it would
advertise keys that do nothing, and the list is still in Settings for
deciding to switch them on. Not drawn at phone widths.

Refs #36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 09:17:10 +02:00
Paul LütznerandClaude Opus 5.5 c6f0f42357 Add Gmail's keyboard shortcuts
j and k move a cursor through a list, o opens, u goes back, x
selects, e archives, # deletes, ! is spam, s stars, Shift+i and
Shift+u mark read and unread, l v b open label, move and snooze,
r a f reply, c composes, / searches, g then a letter goes to a list,
z presses Undo and ? lists all of it.

A key presses the control the page already has and does nothing else.
ui--shortcuts archives nothing itself: it finds the toolbar's Archive,
the row's star, the Reply link, and clicks it. The request, the Undo
toast and the refusal where an action is not offered are the
button's, so a shortcut cannot drift from the button it stands for.

What it acts on, first that exists: the selection through the list
toolbar, the open conversation through its own, the row under the
cursor through the row's buttons. Label, move and snooze have no
button on a row, so the row is ticked and the toolbar's menu opened.

The cursor is an attribute repainted from a remembered id, not focus:
rows are replaced by every refresh, and focus would be dropped to
body each time.

Keys are left alone while something is being typed into, while a
dialog or a menu is open, and with Ctrl, Alt or the Command key held.
A message body is a sandboxed frame with a document of its own, so
its script relays the facts of a keydown to the parent; otherwise the
shortcuts stopped the moment a mail was clicked on.

On for everybody, off per person under Settings, General. Off is the
controller not being mounted, so there is no flag for the script to
forget to read. Stored in the settings bag as a false only, so no
migration, and carried by config backups.

Closes #36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:57:01 +02:00
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 8f7d2cb073 Work through the first-impression feedback in #37
Eleven of the twelve items. The twelfth, a deleted label coming back
after the next sync on IMAP, is left for an issue of its own: there a
label is a folder, and what deleting one should do to the mail in it
needs deciding first.

A selection survives the pager. It was nothing but the ticked
checkboxes, so "next page" replaced the rows and the selection with
them. The rows that are not on screen are now remembered in
assets/mail_selection.js, per list, and the toolbar, the label menu,
the move menu and a drag all read the selection from there. The
checkbox stays the truth for anything visible.

The whole view can be selected from the select menu. It was only
offered by a banner that appears once a page is fully ticked. And a
whole-view selection no longer survives a row being unticked: the
action still ran on everything.

A sync no longer logs a stored message as one it could not build.
MessageSyncer reads the UIDs a folder holds once, before it fetches,
and a move made meanwhile files rows under UIDs that list does not
have. The INSERT was refused on (mailbox, uid), rightly, and each
message was then held back as a failure. The list is read again when
a batch is refused.

A pairing code and the JMAP address under App passwords carry the
configured public address, and the browser's only when none is set.
This reverses the earlier choice in DevicePairingController, which
handed a phone paired at home a LAN address.

Hiding quoted text gives the height back. scrollHeight cannot report
less than the frame it is measured in, so the collapse reports the
root element's box instead and holds other reports until the parent
has resized.

Mail in Sent shows its images without asking. Read off the Sent label
and not the From line, which a forged mail fills in as it likes. Spam
still wins.

Smaller: placeholders are lighter than typed text, the actions on a
connected integration are buttons on one baseline, Save signature and
four composer buttons no longer hover to white on white, the Spam
badge loses the accent, and German says "vor 12s".

Refs #37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:51:39 +02:00
Paul LütznerandClaude Opus 5.5 219fc9e283 Keep a mail's styles from drawing over the app around it
Inline styles survive the sanitiser on purpose: in mail they are the
design. In the reading pane that costs nothing, because the message
is in a sandboxed frame. The quote under a reply and the print page
are the sender's HTML in the app's own document, and there
`position: fixed` on a full-size element covered the composer — or
the whole window — with a page of the sender's choosing the moment
somebody pressed Reply. Reported in #34.

Two layers, since either alone has a hole:

  the sanitiser   drops `position: fixed` and `sticky` from every
                  body, and `absolute` and `z-index` too from one
                  being composed. Absolute stays for mail that is
                  only read: real newsletters use it, and in the
                  frame it can only rearrange the message.
  the stylesheet  the composer's editor and the print page's body
                  are containing blocks that clip. This is what
                  holds for a body stored before the sanitiser
                  changed, or a declaration written some way the
                  pattern does not anticipate.

Refs #34

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:03:51 +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 808b2bff53 Give a demo mailbox templates to pick from
A visitor who opened Settings → Templates on a fresh demo found one
empty folder and a button, and the compose window's picker said there
were none. Both true, neither showing what the feature does. The
provisioner now seeds six: a quote follow-up, an invoice reminder, a
payment receipt, a meeting follow-up, an out-of-office and a short
acknowledgement, filed at the top level, under the demo account and in
a folder inside it. Between them they use every variable, and the date
one with offsets in both directions and three formats.

app:test:seed-demo seeds the same six, for the reason it seeds the
same mailbox: a screenshot of the picker should be a picture of the
demo.

They are strings with tokens in them, which nothing checks — a
misspelt variable is braces left in the text — so DemoTemplatesTest
renders every one for a recipient and fails on an open variable or a
brace. It also erases a visitor with nested folders, since the eraser
removes rows one by one and folders point at each other.

The demo account is not given a signature. The signature variable is
in two of the templates and renders as nothing until the visitor
writes one, rather than a signature appearing in every new message
and in the README's compose screenshots to make one variable busier.

One layout bug fell out of looking at it. A long folder path —
"you@example.com / Quotes and invoices" — pushed the editor's Folder
dropdown out past the card's edge; the control may now shrink and the
path is cut with an ellipsis.

Refs #26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:47:02 +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 4656f8874e Add a spam button that can also filter the sender or their domain
A button beside Delete — on a list row, in an open conversation and
above a selection. On one conversation it opens a menu: move it to
Spam, or move it and create a filter for the sender, or for the
sender's whole domain. The domain filter asks once more first, and for
a mail provider's domain it sits behind "More options". The filter is
an ordinary rule under Settings → Filters, a second press on the same
sender makes no second one, and Undo takes back the move and a filter
the same click created.

The filters match on two new conditions, fromAddress and fromDomain,
which compare the whole address or the whole domain. `from` is a
substring over address and display name, so a filter for a@x.de built
on it also caught ba@x.de. Both are in the filter editor and in JMAP
Email/query.

Refs #31

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:20:26 +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 fd4b1742f8 Stop a message growing without end when it is sized by the viewport
A mail whose wrapper is `min-height: 100vh` with a footer under it never
stopped growing. In the message frame the viewport is the frame, and the
frame is as tall as the content last reported from inside it: wrapper
plus footer is measured, the frame grows to fit, the wrapper is a
viewport tall again, and the message runs down the page a footer at a
time.

Viewport units in inline styles are removed when the frame script starts,
and again after "show remote content" puts the sender's styles back.

A stylesheet rule is not reached by that, so the resize handler is a
backstop: when the frame grew, its width did not change, and the content
grew by exactly as much as the frame did, the new height is not reported.
The loop stops after one step; such a message can lose its last few
pixels, which is the price of not growing for ever.

"By exactly as much" matters. The first draft of this, left uncommitted
in the working tree, took any height found after a resize as read. Run
side by side in a browser it cut a message off whenever content arrived
between the parent's resize and the handler — five of seven timings of a
late block — which is what an image settling does. An image adds its own
height; only a viewport-sized element adds the frame's. With the narrower
test none of the seven is cut off, and the runaway is still stopped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:49:02 +02:00
Paul LütznerandClaude Opus 5.5 1fc6adb146 Offer a calendar file that names nobody here, instead of adding it
Reported as "no insight": an interview invitation from a recruiting
system with the appointment attached as termineinladung.ics, declared as
application/octet-stream, METHOD:PUBLISH, no organiser and no attendee.

Nothing read it. Calendar data was recognised by content type alone, in
three places that each carried their own copy of the test. They now share
CalendarAttachment, which also accepts a part named *.ics — the runner,
the proposer that stands aside for a real event, and the query that picks
stored mail to re-read.

Read under the old rule it would have gone straight onto the calendar:
an event that lists none of the mailbox's addresses resolved to "not an
invitation to us", and that meant "draw it". Anybody could put things on
a calendar by sending mail. Calendar data mailed in by somebody else that
names nobody here is now held as unanswered, like an invitation, and the
card above the message asks: Add to calendar, or No thanks. No reply is
sent, and there is no Maybe. This covers a forwarded or list-addressed
invitation too.

Unchanged: invitations addressed to the mailbox, bookings read from
markup, and a file the owner sent. An event of this shape that is already
drawn with nothing recorded against it stays drawn, so re-reading stored
mail takes nothing off a calendar.

`app:backfill events` picks up mail that arrived before this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:56:59 +02:00
Paul LütznerandClaude Opus 5.5 cf9410ab76 Read insights from HTML-only mail, and an Amazon order from its confirmation
Two mails reported as "no insight", and what each showed.

A shop's shipping notice with no plain-text part. Every insight extractor
read bodyText and nothing else, so it was handed the empty string while
the tracking number sat in a table cell. ReadableBody gives all seven the
plain part where there is one and otherwise the text the HTML shows:
without stylesheets, Outlook's conditional comments or the addresses
behind links, and with cells and paragraphs as lines. The report snapshot
takes the same text, so a report about such a mail no longer arrives with
an empty body.

In that mail "Voraussichtliche Übergabe an den Versanddienstleister" is
followed by a date, which is the day the shop hands the parcel over. It
is no longer taken for the delivery date.

An Amazon order confirmation. "Bestellt: …" was refused at the gate
because it names no shipping word, although it states the order number
and the promised day. A subject that opens with "Bestellt" or "Ordered",
from a merchant sender, now makes the parcel card with a new first stage,
`ordered`. The stage comes from the subject only: the body is the
progress bar with every later stage on it. The card's key is the one the
dispatch mail carries, so that mail moves it on. "Zustellung: Freitag"
gives the date.

Stored mail is not re-read on its own; `app:backfill insights` does it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:56:59 +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 6ef9c07986 Stop a background request deciding where signing in takes you
Three things a test install of main turned up. None is from the ingest
change; all three were there before it.

The two-factor form could hang on "Checking…". The code had been
accepted — a reload showed the inbox. While the code is outstanding the
form's own layout still fetches things, the two-factor bundle turns each
one away, and its handler remembers every GET it turns away as the
destination, the last one winning. LoginFormAuthenticator has filtered
that since the avatar report; the bundle's handler never did. When the
remembered URL was /mercure/auth, which answers 204, the browser stayed
on the form. It showed on an install whose hub was down, because a
failing connection is what makes the page keep asking for that cookie.

  DocumentNavigation   the test both places now share, and a Turbo
                       Frame is refused first: it asks for text/html
                       and is not a page
  TwoFactorRequired    the bundle's handler with that one condition

A fresh install had no live updates until the container next restarted.
The public address is saved by the install form, the hub's browser
address is derived from it, and the web server is one kernel in worker
mode that booted before either existed. Every page was told to connect
to https://localhost, which its own Content-Security-Policy refuses.
The install now ends by restarting the web process, on the page Admin →
Restart uses. That page waited a fixed six seconds; it asks /healthz
until something answers.

A provider still rate-limiting after five retries was a CRITICAL with
two stack traces and a parked job, whatever the job. For
SyncAccountMessage that is noise — the next sync resumes from a cursor
that did not move. For the message batches it hid a real loss: they are
queued after the cursor has moved, nothing lists those messages again,
and the parked job was the only copy. GiveUpQuietlyOnThrottling drops
the first with a warning and puts the second back for a quarter of an
hour with a fresh set of retries, ten times, before it may fail.

Not verified: the restart at the end of install runs only where the web
process is PID 1, so it needs the built image. The hub failing on the
test box was a root-owned volume left by an older install, not a bug.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:28:07 +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 464df70473 Move a conversation to a label, and take it out of the list it was in
Issue #25. Filing mail the way Gmail does took two actions here: the
label button adds a label and leaves the mail where it is, and a drop on
a folder needs a pointer and a sidebar in reach. "Move to" is one click,
beside the label button, in the list toolbar and in the reading pane:
the target goes on and the label of the list being looked at comes off.

The client names the view; the server decides what that removes. A body
that lists labels to strip is not an instruction the route has.

  Inbox            target on, archived (Archive on, Inbox off) — or
                   into the folder, where the target is one
  a label          that label swapped for the target
  Trash, Spam      target on, out of the bin, not into the inbox
  anywhere else    target on, Inbox off where it was on
  to Inbox         restore(), then the view's label off
  to Trash, Spam   the existing trash and spam paths, not a label swap

MoveToService composes ThreadStatusUpdater's operations rather than
writing labels itself, inside one transaction, because each of those
carries provider behaviour that took a bug report to get right. Only
the messages that carry the leaving label lose it: a sent reply in an
inbox thread gains the target and is not archived.

The issue asks for "tag(s)", plural. This is Gmail's single target;
several labels are still what the label button is for.

Undo came with it, for archive and labels as the issue asks, and for
the drag onto a folder since the mechanism was there. It remembers each
message's labels before the action, in the session, and puts exactly
those back — not the inverse of the action, which is wrong for a mixed
selection. A whole-view action runs in a worker and has none.

Three things fell out:

  - The toolbar's label menu posted once per ticked row. With a toast
    and an Undo on the answer that was five of each, so it posts the
    selection once now, to /status/bulk/label, which learned `attach`.
  - Inbox, Spam and Trash are asked for by role. Their labels are made
    lazily, so a picker that carried ids had no Trash row for anybody
    who had never deleted anything.
  - Font Awesome keeps its folder-with-arrow in Pro, so the button is
    an inline SVG.

Not verified against a live Gmail or Exchange account: the tests pin
local label state and the IMAP jobs that are queued.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:56:35 +02:00
Paul LütznerandClaude Opus 5.5 be3b27cf32 Let an administrator change the public address after setup
Setup asked for it once and nothing could answer it again. An install
restored from another machine's backup therefore kept the old machine's
address: browsers were sent there for live updates and the stream
indicator stayed red, with an environment variable in the compose file
as the only remedy.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:27:37 +02:00
Paul LütznerandClaude Opus 5.5 8ad3e83c52 fix(ui): the last hard-coded colours in the templates use the theme's tokens
Four kinds, each of which ignored the theme it was drawn in:

- Checkboxes and radios asked for the accent through `text-accent`. A
  native box is coloured by accent-color; `color` reaches it only
  through @tailwindcss/forms, which this app does not load, so every
  one of them was ticked in the browser's own blue. They use
  `accent-accent`, and modal_form_theme says why.
- Icons and icon buttons in admin, settings and the thread row's hover
  actions were Tailwind's zinc-400 and gray-400, with dark: variants and
  grey hover washes. They are ink-faint, with hover:text-ink and
  hover:bg-hover, or hover:bg-danger-soft for the destructive ones.
- The state pills on bg-raised wrote ink-faint, a tier held to 4.5:1 on
  the surface, which the raised tint under them takes as low as 3.85:1
  (14 of 39 themes under 4.5). They use ink-muted.
- Accent buttons wrote text-white, which a light accent cannot carry.
  They use the accent's own ink.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:29:34 +02:00
Paul LütznerandClaude Opus 5.5 1ad2a46689 fix(calendar): the topbar switch is drawn in its remembered position
The mailbox shell renders its layout in the remembered position, but
the topbar's calendar switch went out as `mail` whatever the preference
said, and ui--split corrected it on connect. By then the page had been
styled, and the switch carries transition-colors for its hover, so
every desktop load and every Turbo visit with a remembered split or
calendar painted the mail icon, swapped in the right one and faded the
accent in over 150ms. 994d0a9c fixed the same fade on the sidebar rows
and named this one as left over.

The server cannot know the width, and below lg ui--split opens every
position on the mail, so the shell's topbar now draws both answers and
lets the breakpoint pick: the mail icon `lg:hidden`, the remembered
one `hidden lg:flex`, and the switch `lg:bg-accent-soft lg:text-accent`
over its muted ink. On connect, once it has settled the position for
this window, ui--split takes those classes off again, leaving the
`hidden` attribute and the plain classes it always wrote. Each icon has
a wrapper, because Font Awesome's display outranks a utility on the <i>
itself. Pages outside the shell include the bar without
`calendarRestored` and still draw `mail`.

CalendarSwitchFirstPaintTest reads the switch out of the raw response
for every remembered mode, at both widths the way the stylesheet does,
and holds its classes to the lists ui--split removes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:29:11 +02:00
Paul LütznerandClaude Opus 5.5 994d0a9c0d fix(sidebar): the open row no longer fades in from black on every load
Every sidebar row went out with no colour class, and ui--sidebar gave
the open one bg-accent-soft and text-accent (and every other row
text-ink-muted) on connect. By then the browser had already styled the
page, and the rows carry transition-colors for their hover, so on every
full load and every Turbo visit the open row faded in over 150ms from
the inherited black and a transparent pill, and the rest faded from
black to their muted ink. Measured in headless Chromium against the
test stack: app.css loaded at ~155ms, first paint at ~200ms with the
rows black, the controller's pass at ~250ms, transitionrun on every
visible row in the next frame. The stylesheet order was never the
cause, and neither was the per-frame sampler: plain transitionrun
listeners see the same thing.

The server now renders the state. SidebarRowStateExtension restates
ui--sidebar#_matches against the main request, and every link row in
the sidebar, the account list and an expanded account's folders takes
its classes from sidebar_row_state(). The controller's pass is then a
no-op on everything the server drew, the first style the browser
resolves is the final one, and the hover transitions are untouched
(still ~130ms in and out). The pass stays for frame navigations and
for rows that arrive later.

After: the open row's first sample is already its final colour, and no
sidebar row runs a transition on load, on a Turbo visit or in the
mobile drawer. Two transitions remain at load and neither is a sidebar
row: the Mercure dot, which is neutral until the stream reports, by
design, and the topbar calendar toggle, which is still rendered as
"mail" and corrected on connect.

SidebarRowStateTest pins the rendered state, the matching rule, and
that the class lists agree with the controller's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:37:33 +02:00
Paul LütznerandClaude Opus 5.5 ca2498f6e9 fix(settings): the accounts row's status icons use the status tokens
_push_control drew its failed, degraded and on states in literal amber
and emerald, each with a dark: override, and its focus ring in indigo.
The account toggle beside it was green-500, so one row showed two
different greens. The states now use text-warning and text-success,
with a /10 wash of the same token on hover. The ring uses ring-accent,
as it does across the app. That follows CODESTYLE rule 32, and the
toggle and push-on now share one green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:32:19 +02:00
Paul LütznerandClaude Opus 5.5 add73ebbca fix(mail): the unsignable-PDF strip gets its warning wash
The strip that says why a PDF cannot be signed had bg-warning-soft, and
there is no warning-soft token (danger and info have one, warning does
not). Tailwind generates nothing for a utility it cannot resolve, so
the strip has painted no tint since the signing feature shipped.

It takes bg-warning/10, the wash the sender-mismatch warning already
uses, rather than a new token for one strip. Painted on pane-flat, it
is a visible tint in Paper and in Dark, with ink-soft above 6.5:1 on it
in both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:18:25 +02:00
Paul LütznerandClaude Opus 5.5 27429f77fa chore(dead-code): delete the push card nothing includes
settings/accounts/_push_toggle.html.twig had no includer: the accounts
list renders the compact _push_control, and so does the controller's
Turbo response. The card was also drawn entirely in literal palette
colours, against CODESTYLE rule 32.

What only it read goes with it: PushHealth::badgeClasses(), literal
colours again; PushStatus::$expiresAt; and the renews and reregister
keys under settings.accounts.push, in all three catalogues. The
managers' expiresAt() stays, since AccountHealthInspector reads it. The
two comments that pointed at the card now say the row control is the
only one.

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

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

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

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

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

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

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

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

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

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

testHidingTrashTakesItsRowAwayAndShowingItBringsItBack fails against the
old sidebar and stream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:42:01 +02:00
Paul LütznerandClaude Opus 5.5 7824821c0a feat(calendar): an event of a day or more is one bar, its hours shaded in the grid
A trip from Friday 17:00 to Sunday 21:00 was a block down each of its
three days, and pointing at one lifted that day alone: Saturday's,
titled at a midnight nobody had scrolled to, rose as a grey column with
nothing on it. The blocks also took a lane from every other meeting of
the weekend.

A timed event of 24 hours or more - the line Google and Outlook draw -
now leaves the hours for the all-day band, as one bar across the days
it covers ("17:00 Bodensee ... until Sun 21:00"), and the hours it takes
are shaded under each day's blocks, which stay full width on top of
them. The bar and every day's shading carry one key, and
span_link_controller lights all of them when the pointer is on any. An
overnight meeting under a day stays in the grid, and its two halves
light together too.

- DayGridLayout::band() lays the band out once over the range, in
  lanes; DayGrid::$shaded carries the hours. The shared page's builder
  lays out the same band, so its grid draws the same bars.
- The band draws bars across columns, so an all-day event over several
  days is one bar too, and every bar is tinted like a block in the grid:
  bare, a bar's start and end sat days apart with nothing between.
- A bar prints its times on the full clock. The band has no axis, so a
  compact "5:00" was two times on a twelve-hour clock.
- A stacked chip aligns its rows to the top. A half-hour meeting in a
  96px column shrank its rows below their text, and a centred title
  spilled up over the time above it.

calendar-multiday.spec.ts drives one through the dialog and hovers it
from both ends; DayGridLayoutTest pins the placement, the lanes and the
24-hour line from both sides.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:52:40 +02:00
Paul LütznerandClaude Opus 5.5 13a175a118 fix(mail): long addresses stay inside the details panel, and replies reach GitHub
A GitHub notification's details panel ran its Reply-To out past the
panel's edge. The value column was a bare 1fr, which never goes narrower
than its longest unbreakable word, so the reply address widened the
whole column, every row with it, to 831px inside a 446px panel, and
break-words never got to wrap anything.

  _message_details    minmax(0,1fr), and min-w-0 on the address text,
                      which is a flex item with the same floor; the
                      comma between two addresses rides with the first,
                      so it no longer wraps onto a line of its own
  message_details_    the panel moves left, and narrows only if it must,
  controller          to stay inside the reading pane: beside the docked
                      calendar it ran 60px past it and was clipped
  AddressHelper       isValidEmail() holds a local part past 64
                      characters to the dot-atom instead of refusing it

That last one is a bug found on the same message. filter_var() enforces
RFC 5321's 64-character local part, and GitHub's reply addresses run to
about ninety. Refused, the reply fell back to the From address,
notifications@github.com, which accepts nothing; with the parser fixed
alone, the address still could not become the contact the To field
needs, and the reply opened with nobody to send to. Every caller asks
the same question, so the one rule changed. Reply-To in the panel is
now parsed like the rows around it, with a copy button.

The new E2E case fails on the old front end (60px past the pane, 382px
past the panel) and passes now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 00:13:55 +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 a11ce99007 fix(calendar): the docked pane's date fits beside its buttons
In the pane's agenda and day views the title had about seventy pixels
and read "Sep 24, 20…", now on the surface every desktop opens on. The
pane drops the year while it is this one and keeps it for any other.
Week and month widen the pane to fit them, and the full page has room.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:23:27 +02:00
Paul LütznerandClaude Opus 5.5 7e1c40cb0d refactor(branding): one paint vocabulary, and an edge for the topbar tile
LogoMotif::paintWires() is now what both the icon route's requirement and
the export walk, so `original` plus the colourways is spelled once. The
export's output is unchanged, byte for byte.

The topbar's tile gets the settings tiles' hairline: a pale tile on pale
chrome had no edge and read as a glyph that had lost its square.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:57:45 +02:00
Paul LütznerandClaude Opus 5.5 872cdbd73d feat(settings): pick the logo in two steps, icon then colour
Settings → Appearance → Logo shows the ten icons first, each in its own
design, then the colour: Original (offered only off the pl mark, and what
picking a motif selects), Follow the theme, or Choose independently from a
grid showing the chosen icon in all thirty-two colourways. Picking a motif
swaps the grid's pictures in place; every click repaints the topbar and the
tab icon the way effectiveLogoPaint() resolves them, and saves through the
existing debounced save(). Tiles are real buttons with aria-pressed and the
theme tiles' ring. EN, DE and pirate labels; the long German compounds carry
soft hyphens so they break inside a narrow tile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:53:34 +02:00
Paul LütznerandClaude Opus 5.5 21216f3159 feat(branding): serve every icon as a tile, in the topbar and the tab
GET /branding/icon/{motif}/{paint}.svg draws one icon on its rounded tile,
in Android's adaptive-icon geometry; both requirements are read off the
enums, so anything else 404s at the router. The URL carries a fingerprint
of the drawing's sources (LogoIcons), and at that version the response is
public and immutable for a year; without it, an hour and an ETag.

The route has its own `security: false` firewall rather than a
PUBLIC_ACCESS rule: under `main` the appearance cookie listener reads the
session, and Symfony turns the response private with max-age=0.

The favicon serves the tile for every motif and the bare mark for pl; its
link is versioned by the choice (v=<motif>.<paint>) and the drawing (d).
The topbar renders the inline mark and the tile image, one of them hidden,
so the appearance pane can switch between them live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:53:28 +02:00