69 Commits
Author SHA1 Message Date
13303b247e v0.3.9 milestone (#283)
* Run the end-to-end suite on pull requests into milestone and wip branches

A release's work is now collected on a milestone branch (milestone/v0.4.0)
before it reaches main, and the suite only ran on pull requests into main,
so nothing proposed for the milestone was tested. `milestone/**` joins
`main`, and `wip/**` comes along from wip/28, where it already runs the
suite on the phases of #28 — the same block on both, so they merge
without a conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit d009ff72ad)

* linked-issue: read the description of a pull request into a milestone branch (#254)

GitHub links an issue from "Closes #N" only in a pull request into the
default branch. Into milestone/v0.3.8 or a wip/ branch the closing
references stay empty, so the check failed on every such pull request
however its description named the issue (#252 was the first). For a pull
request into another branch, the description is now read for the same
keywords GitHub accepts.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* The release pull request is the suite run behind a tag (#252)

Releasing asked for the E2E workflow to be started by hand on main before
a tag. Since main became protected, the Release vX.Y.Z pull request cannot
merge until that workflow is green on it, and its commit is what is tagged,
so a manual run only ran the same jobs twice. Releasing now says so, and to
update the branch if main moved after its checks ran, since the protection
does not ask for up-to-date branches.

The paragraph in CONTRIBUTING and the header of e2e.yml that said main is
not verified commit by commit describe main before it was protected; both
now describe it as it is.

Closes #251

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Calendar on a phone: a readable month, a toolbar and top bar that fit, a live now line (#256)

The month chip is its title first, on up to two lines, then its time range
and place; a narrower day drops the end and the place for a third line of
title, long words are hyphenated, and an unanswered invitation has a
dashed bar. Below 39rem, where a day is bars, the grid shrinks and the
chosen day is listed in full under it: today at first, any tapped day
after, with a + for that day that the toolbar's + follows too. On a wider
touch screen the first tap opens a day's card and the second its entry
(calendar--month-tap, and a day-open: variant that the hover and the tap
share).

The calendar toolbar fits below 37rem with icons, a short date label and
smaller buttons, and under 22rem today's date in a box for Today. English
writes a same-month week as "Oct 20-26" (range_short_month). The top bar
tightens below 640px and moves Sync now, What's new and Send feedback into
the user menu. Dialogs are the whole screen below 768px, as compose is.

calendar--now moves the now line every minute on the view's clock and
redraws a view that shows the old day or the new one when the date
changes, which also moves the month's circle on today.

And a + on a day that has gone opened the editor on today:
DefaultEventStart::onDay pulled any past day to the next hour. A past day
now keeps its date.

Closes #253

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Close an issue when its pull request is merged into a milestone branch (#260)

GitHub acts on "Closes #N" only for merges into the default branch, so
issues fixed on a milestone branch stayed open until the release reached
main. A workflow on merged pull requests into milestone/** now closes the
issues the description names, with a comment linking the pull request.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Preview the reply, not the quote, and send replies with their quote visible (#258)

The list preview flattened the whole body and skipped short blocks to find
the first long one, so a one-line reply previewed as its attribution line or
the quoted mail. The quoted history (same seam as the reading view's "···")
and a "-- " signature are cut off first now, in HTML and plain text, and the
chrome skip only applies to bulk mail. The JMAP preview uses the same code.

The composer folded the quote with an inline display: none and its clean-up
removed only the toggle, so replies went out with an invisible quote. The
fold is removed in the editor and again on the server, and the reading view
reveals a folded quote in mail already received.

Closes #257

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Move a thread's unread count when JMAP marks a message read or unread (#267)

EmailPatchApplier wrote seenAt for $seen and left MessageThread::$unreadCount alone, so the web list (which draws bold from that counter) disagreed with the phone app. $seen now goes through ThreadStatusUpdater::markRead(), the web UI's own path, and markRead() counts the whole thread rather than the messages passed in.

Closes #266

Co-authored-by: Philippe Delodder <7259755+phdelodder@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Leave a message that is already in the destination folder where it is (#270)

Trashing a conversation with a message already in the bin queued that message as a move from Trash into Trash. webklex's move() then left the connection in EXAMINE and the server refused it ("NO Not permitted in EXAMINE state"), and the row had already lost its UID. ApplyImapFlagsHandler no longer sends a move into the folder a message is in (and gives the row its UID back), and ThreadStatusUpdater's trash(), archive() and restore() leave such messages out of the move.

Closes #269

Co-authored-by: Philippe Delodder <7259755+phdelodder@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Hide a conversation from the lists only when all of it is in the bin (#272)

A thread's labels are the union of its messages', so one deleted message put Trash on the thread and took the whole conversation out of the Inbox, its tab, label lists, All Mail, Unread, the badges and search, along with any reply threaded into it afterwards. The bin rule is now "carries Trash and has no message outside it", written once (binnedThreads()) for every list and badge and spelled out the same way in the search SQL. Option (b) of the issue.

Closes #268

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Let a long message scroll in the expanded compose window (#273)

expandedValueChanged() used closest('div') to reach the editor's wrapper, but closest() starts at the element itself and the editor is a div, so the wrapper never became a flex container and the editor stayed 170px tall, clipped by [contain:paint]. The wrapper is now the flex column and the editor grows (1 0 auto) to fill the window or its content; the plain-text field fills the expanded window too.

Closes #262

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Write each changelog entry as a file of its own, collected at release (#275)

Every pull request added its paragraph under ## Unreleased in CHANGELOG.md at the same place, so the second of any two conflicted with the first once it merged, and each conflict fix re-ran the whole E2E suite. A pull request now adds changelog.d/<issue>-<words>.md; bin/changelog-release.sh, run in the Release vX.Y.Z pull request, collects the fragments (highlights first) and anything still under ## Unreleased into the release's section and deletes them. CHANGELOG.md stays the one file What's new, bin/release-notes.sh and GitHub read.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Say which step failed when notifications can't be turned on (#278)

Every failure in the enable path (registering the service worker, the
browser's push subscription, the server's answer) ended in the same
"Something went wrong" and the exception was dropped, so a report from a
Pixel 8 could not say which. The pane now names the step and the
DOMException name or HTTP status, and logs the exception to the console.

Closes #277

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Contacts: one address book, with Google, Outlook, Nextcloud and CardDAV contacts in it (#282)

A contacts page and pane for plMail's own contacts and the ones on a server:
Google and Outlook on the mail account's own sign-in, Nextcloud through the
Nextcloud connection, any CardDAV server through Settings -> Contacts. Every
field a phone keeps, the photo included, syncs both ways. Duplicates merge
with 90 days to undo; vCard and CSV import and export; contacts suggested
while composing; JMAP Contact/get, /query and /set. Nextcloud calendars come
with the Nextcloud connection too.

Replaces #237 and #109 by @jo2904 and #244 by @DanversKara.

Closes #157
Closes #236

Co-authored-by: jo2904 <jo2904@users.noreply.github.com>
Co-authored-by: DanversKara <DanversKara@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Install APCu and Xdebug with PIE instead of from PECL (#286)

PECL is deprecated in favour of PIE and was down for most of an evening on 2026-10-21, failing every image build. PIE 1.5.2 now installs APCu (and Xdebug in the dev image) from the extensions' own releases; it is mounted for that one step and not left in the image. Nothing changes at runtime.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Keep contact photos where the web container can serve them (#287)

Contact photos were stored in var/contacts, outside the volumes the web and worker containers share, so synced photos answered 404. They now live under APP_STORAGE_DIR/uploads/contacts; a migration clears the lost paths and has synced books read again so the photos come back.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Match a contact's email addresses in any case (#288)

Addresses are stored lower-cased but were matched as the provider spelled them, so a pull removed and re-inserted the same address and broke the unique key (prod sync failure). They are now matched in their stored form.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Open a contact's reference email in the mail pane (#294)

A click on one of a contact's reference emails in the docked contacts
pane was a Turbo visit inside the contacts frame, which blanked the mail
pane. The click is now offered to the mail pane as a mail-pane:open
event; the pane opens the message beside the contact. Without a mail
pane (the full contacts page) it stays an ordinary visit.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Open mail beside the full Contacts page without a reload (#297)

The full Contacts page was a page of its own, so a reference email
clicked there was a visit: the contact was gone, and on a notebook the
mailbox came back on the calendar it remembered.

The full page is now the mailbox with data-contacts-full on the shell:
the mail column put away and the contacts dock given the row, with the
page's own content in the dock's frame. Opening a message from it loads
the message into the existing mail pane, then contacts--pane#reveal
drops the state in a view transition: the mail slides in from the left
and the dock narrows to its width, the same DOM throughout. On a
notebook the calendar gives way; on a wide screen it stays.

The reference links lose data-turbo-frame="_top", which made ui--split
stash a calendar position for a visit that never came.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Preview image and video attachments on click, like PDFs (#295) (#298)

Clicking an image or video attachment chip opens a viewer in the modal
instead of downloading. Previewable chips (PDF included) get a download
half on the right. Images load from the existing attachment route; videos
from a new /mail/attachment/{id}/video route that serves a fixed video
type inline under the sandbox CSP and answers Range requests.

Closes #295

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Erase a message's parts with it, and delete mail one way (#300)

MessageEraser loaded a message's parts to delete their files, then removed
only the message. After the delete Doctrine forgot the message but kept the
parts managed, so the next flush in the same unit of work failed with "a new
entity was found through the relationship MessagePart#message". Every sync
that removed server-deleted mail with an attachment failed that way.

The eraser now removes the parts, removes a thread it empties (announced to
JMAP as destroyed), and logs file-delete failures instead of keeping the row.
MessagePurger hands its local delete to the eraser, so "delete forever" gains
the thread-label re-sync it lacked. ImapAccountSyncer clears an entity manager
a failed folder left open, so one folder's refused flush no longer fails the
rest.

Closes #299

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Rework the contact editor: quick add, paste details and Improve with AI (#305) (#306)

New contacts start in quick add: paste a name, signature, address line or
vCard and the details it finds are listed, each with a one-click type. The
editor shows the name as one field whose parts fill themselves (titles and
degrees included), rows that appear as you type, Paste details for existing
contacts and a Save to picker. Contact help is a new AI feature, off by
default, that rereads pasted text and suggests updates from the signature
of a contact's newest mail. Phones are read with libphonenumber.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Keep spam off the radar and out of search; add a Not spam button (#308)

Cards read out of mail in Spam are left off the Happening Soon panel and
the mailbox strip (read-time filter, so moving the mail out brings them
back), and a spam conversation no longer shows its cards above the
message. The bin keeps its cards. Search and the type-ahead leave Spam
out unless asked with in:spam, as they already did for the bin. A spam
conversation's toolbar gets a Not spam button that moves it to the inbox.

Closes #304

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Add to contacts asks new or existing; contacts button gets the calendar's three positions (#312)

Closes #309
Closes #311

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show the pictures a review request hid, dropped or never asked for (#274)

* Show the pictures a review request hid, dropped or never asked for

A BAUHAUS review request, sent through Bazaarvoice, showed neither its
logo nor its product photos, with images allowed. The image proxy was
fine; three steps around it were not.

- The logo is linked as http://. The sanitizer keeps http sources and
  leaves them to render time, the fetcher only connects over TLS, and
  nothing in between upgraded the URL, so every http image became the
  placeholder. RemoteContentBlocker now signs the https:// form. The
  fetcher's own rules also say in the log which one refused a target;
  until now that refusal was the one silent path.
- The first product card sits inside a <form>, and dropElement('form')
  took the photo, the name and the star links with it. The form is
  unwrapped now; textarea and select still go with their content.
- Each card is class="d" style="display:none", switched on with
  .d {display:block !important}. CssToInlineStyles keeps an inline
  property whatever the rule says, so the card stayed hidden next to its
  Outlook twin. Html5CssInliner now lets a stylesheet !important beat a
  plain inline style, and matches selectors against forms as the divs
  the reader will see, so `form .a` rules take the sender's fallback
  for clients without forms.

  app:backfill safe-html-hidden-cards

re-sanitizes stored mail with a form or a stylesheet !important. One
task for both, because neither fix brings the card back alone.

Closes #271

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Mark the German mail page as translated from the current English one

Both pages gained the same two paragraphs in the previous commit, but
the translated-from hash still named the English page before them, so
DocumentationCoverageTest reported the translation as stale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Credit the image proxy entry and keep it as a changelog fragment

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Paul Lützner <karatektus@gmail.com>

* Swipe a mail row, choose what each swipe does, and long-press for its actions on a phone (#314)

On a phone, below md and by touch only: a row swipes right for read/unread and left for archive, each direction set under Settings → Mail → Swipe actions (read/unread, archive, star, nothing; never trash), and holding a row opens an action sheet with every action its strip offers. Mouse, trackpad and wider windows are unchanged.

Takes over #117, #118 and #119, whose fork was archived. Also gives the Contacts settings' two cards their gap.

Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Admin → Integrations lists the calendar services, so a pinned CalDAV address can be changed later (#279)

CalDAV and Calendar feed get their own group below the file services, with the shared provider row and Configure form; Nextcloud stays with the file services. Calendar services are offered to everyone, so their rows say so and the form has no switch. The server address accepts only http or https, up to 512 characters.

Once people have connected, the CalDAV form says how many connections a changed or cleared pin reaches. A calendar feed offers its address only while one is stored, so it can be cleared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Offer an address book another connection already mirrors locked, not as new (#319)

Two connections to the same server, a Nextcloud one and a CardDAV one made by hand, reach the same address book under the same remote id. Settings → Contacts now offers such a book locked, saying which connection has it, and refuses a posted tick, as it does for calendars. Otherwise every contact in it showed twice.

Closes #317

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Gmailify: the IMAP folder places a sibling's mail, Gmail deletions detach, and a switch to turn it off (#204)

Closes #203
Closes #205

- A sibling's IMAP copy keeps the placement its folder gives it; Gmail's INBOX/SENT/DRAFT/TRASH/SPAM no longer move it (by @phdelodder).
- Gmail deleting its copy detaches the sibling's IMAP copy instead of erasing it.
- app:backfill gmailify-placement repairs copies the old merge had moved.
- Settings → Aliases: a Gmailify switch per Gmail account. Off detaches carried copies in a background job and declines mail Gmail still collects for another account (Bcc'd mail excepted, as documented).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Gmail sync: look up only the listed ids, not every stored one (#321)

Part of #223: newGmailIds() asks only about the ids Gmail just listed, and nothing when none were.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Store whether a conversation is in the bin, kept by triggers (#322)

Part of #223: message_thread.binned holds #268's rule, kept by triggers on message_label, thread_label and message; lists, badges and search read the column. Identical results on 41 compared queries, most 5-100x faster on a 67k-thread mailbox.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Account sync: wait out a provider that can't be reached, quietly (#333)

When the worker couldn't connect to the Gmail or Graph API at all, the
account sync went up Messenger's retry ladder: a WARNING with three nested
stack traces per attempt, then CRITICAL and a parked job if the outage
lasted. That job resumes from a cursor and is asked for again every
quarter hour, so nothing was ever lost.

WaitOutConnectionFailures now puts a SyncAccountMessage that failed only
with an HttpClient TransportException back on its own queue, on the
ingest ladder's delays, with one info line per attempt. After five waits
it is dropped with a single warning. Message batches and every other
failure keep the normal path.

Closes #332

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Sven Joder <sj@pmdevelopment.de>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Philippe Delodder <7259755+phdelodder@users.noreply.github.com>
Co-authored-by: jo2904 <jo2904@users.noreply.github.com>
Co-authored-by: DanversKara <DanversKara@users.noreply.github.com>
Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
2026-10-23 11:18:21 +02:00
Paul LütznerandClaude Opus 5.5 3b78d277d7 A send that fails for a passing reason is retried; one that finally fails is marked on the draft (#242)
Every sender turned every failure into `false`, so a one-minute Gmail
rate limit or a short outage lost the send like a refused recipient.

The three senders now classify through MailDeliveryInterface::deliver():
deferred (rate limits, 5xx, a connection that never opened, SMTP 4xx),
refused (other 4xx, SMTP 5xx, too large) or outcome unknown (a lost
answer, 502/504). send() stays a bool for invitations, alerts and read
receipts.

SendMessageHandler retries a deferral for at most two minutes, keeping
the provider's Retry-After. A lost answer is never resent blind: Gmail
and Graph look for the Message-ID in Sent first (now committed before
the first attempt), SMTP reports it as possibly sent. A final failure,
from the handler or from SendGaveUpSubscriber when the ladder runs out,
marks the draft, words the toast by its reason, and shows a red warning
triangle at the start of the draft's row and on Drafts in the sidebar.

Migration: four nullable columns and a partial index on message.

Closes #125

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:34:56 +02:00
Paul LütznerandClaude Opus 5.5 822b62631c A parcel still on its way is not read as delivered, nor an order number as its tracking number (#239)
"Delivered" anywhere in a carrier's mail made the card Delivered when the
subject named no stage, so "will be delivered tomorrow", "scheduled to be
delivered" and "when it's delivered" put a Delivered badge beside
tomorrow's date. Those future and conditional phrases are now taken out
before the word is looked for.

A bare digit run labelled as another number ("eBay order number",
"Reference", "Bestellnummer", "Order #") is no longer read as a tracking
number, so a FedEx mail for an eBay purchase gives the FedEx number.

Closes #120

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 06:30:15 +00:00
Paul LütznerandClaude Opus 5.5 7b2fe86189 Parcels from amazon.co.uk and the other English Amazon shops are read, on the reader's day (#238)
Only amazon.de, amazon.com and amazon.fr counted as a merchant, so an
"Out for delivery" mail from amazon.co.uk gave no card. amazon.co.uk,
amazon.ca, amazon.com.au, amazon.in and amazon.sg are now merchants too,
and the Track button opens the storefront that sent the mail.

A date like "23/10/2026" is read day first from the day-first
storefronts, with the year required. "Arriving today" and "tomorrow" are
counted on the account owner's clock instead of UTC.

Closes #207

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 08:15:46 +02:00
Paul LütznerandClaude Opus 5.5 f3a2bb2a4a Delete a folder-backed label's folder on the server, after moving its mail (#224)
* Delete a folder-backed label's folder on the server, after moving its mail

Deleting a label that is a folder on IMAP or Outlook left the folder on the
server, and the next sync made the label again. The delete dialog now asks
where the folder's mail goes (Inbox, Archive, Trash or another folder); a
background job moves it, deletes the folder once the server reports it empty
(children first), and removes the label last so no sync can resurrect it.
A folder that never empties is left alone and the job says which.

Also: an Outlook category delete now addresses the category id (it used the
folder id and never reached Outlook), and nested labels are propagated too.

Closes #40

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Label delete: end at once on a refusal, show what it waits for

- A server that refuses the folder delete (IMAP NO/BAD or a dropped
  connection, Graph/Gmail 4xx) ends the job immediately with its reason
  instead of retrying for ten minutes; throttling and 5xx are still waited out.
- While waiting for a folder to empty, the jobs indicator names the folder.
- The destination Archive/Trash is created on the IMAP server before any mail
  moves, so the moved rows point at it; before, they kept pointing at the
  folder being deleted and the job waited on them until it gave up.
- A folder plMail creates starts with nothing to import, so the indicator no
  longer shows it as "Importing" until the next sync.
- A folder still being imported cannot be deleted yet; the dialog says so.
- confirm-dialog.spec drives the account delete form, as the label delete no
  longer uses a browser confirm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:37:09 +02:00
550dd9cf89 Pull the mail list down to sync on a phone (#163)
Pulling the mail list down from its top, with a finger below md, runs the topbar's sync. A shared touch-gesture module decides what a touch is and who owns it, for the row swipes and long-press to come. Mouse, trackpad and wider windows are unchanged.

Takes over #115.

Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:29:01 +02:00
280f58b7b5 feat(mail): switching category tabs replaces the history entry, as in Gmail (#161)
* feat(mail): replace the history entry when switching category tabs on a phone

Below md, a category tab navigates the inbox list frame with Turbo's
replace action, so the browser's Back leaves the inbox instead of
stepping back through every tab. At md and up the link keeps advance,
so desktop behaviour is unchanged.

The choice is made on click by the ui--tab-history controller, not on
connect: the tab strip is morphed on every list refresh, and a morph
removes attributes the server did not render.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>

* feat(mail): category tabs replace the history entry at every width

#150 asked for this on a phone only; the maintainer wants it everywhere,
as Gmail does. With no breakpoint to ask about, the tab links render
data-turbo-action="replace" from the server, so the click-time controller
goes: a server-rendered attribute survives the strip's morph on its own.
The account page's tab strip, added on main since, gets the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): credit the tab history entry to #161, which took over #113

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-15 14:44:47 +02:00
1b04e697f6 All Mail: every conversation outside Spam and the bin, as an opt-in system label (#57)
Archived mail had no list to be browsed in, only search. All Mail lists every conversation outside Spam and the bin (what arrived, what was sent, drafts and archived mail), as Gmail's All Mail does, across the reader's active accounts or within one.

It is LabelRole::AllMail, a view and not a place, like Unread: nothing attaches it to mail, it is created hidden when label settings is opened, it is bound to no account and it refuses moves. Its eye in Settings → Mail → Labels shows or hides both the row beside Drafts and the row at the top of each account's folders. Archiving or snoozing from it leaves the row in place. One list scope, `all_mail`, with the account id as its value or empty for every account; AllMailView holds the rules the row buttons, the bulk toolbar, Move to and select-all ask.

No migration.

Co-authored-by: shady2k <3366415+shady2k@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:36:25 +02:00
210fae090e Show the category tabs on an account's own inbox, behind a setting that is off by default (#55)
Clicking an account in the sidebar opened a plain list, so on an Outlook account (or any account) the category tabs were only on the top-level Inbox. An account's page can now draw the same tabs, the reader's own included, chosen by the same rule as the unified inbox and counted from that account's mail alone. The strip is plainer than the unified one: no "new" pills, sender hints, unread tint or drop targets, since those count every account. It scrolls the open tab into view on a phone.

Settings → Mail → Categories has a new "Tabs on each account" switch, off by default. Off, an account's page stays its whole inbox in one list, as before, and a stale `?tab=` link opens that too. The switch is offered only while "Inbox tabs" is on. Stored in the user's settings bag and carried by a config backup. No migration.

Co-authored-by: Walid Mani <15911342+Flouss@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:37:10 +02:00
Paul LütznerandClaude Opus 5.5 0591d9a854 Settings → Mail → Images: always show remote images (#124)
One per-user switch, off until somebody turns it on. With it on,
MessageRenderer treats every sender as if it were on the image allowlist,
so no message asks before loading its pictures. They still come through the
image proxy, so a sender learns that a message was opened but not the
reader's address. Mail in Spam stays blocked, as it does for trusted senders.

Kept apart from the per-sender allowlist: no "Stop showing" line for a
sender nobody trusted, and switching it off leaves those entries as they
were. Stored in the user's settings bag and carried by a config backup.
No migration.

Closes #124.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:00:44 +02:00
Paul LütznerandClaude Opus 5.5 bdffac32e4 Describe the tab page, switching tabs off and their order in the handbook
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:53:59 +02:00
Paul Lützner 7898182e00 Merge branch 'pr-87' into review-87
# Conflicts:
#	docs/de/features/mail.md
#	docs/de/install/config-backup.md
#	docs/de/internals/mail-ingest.md
2026-10-14 10:42:46 +02:00
f28e8cd044 fix(insight): name the right carrier on a Pickup relay parcel, and read a collected one as delivered (#103)
fix(insight): name the carrier of a Pickup relay parcel, not Deutsche Post

The Pickup relay network (network1/network2.pickup.fr) hands over the
parcels of several carriers, so its sender domain names none of them. An
S10-shaped number fell through to the Deutsche Post default, which exists
for German mailboxes, and every such card read "Deutsche Post".

For a relay sender the carrier now comes from the display name when it
starts, as a whole word, with a listed carrier (Chronopost, Colissimo);
that name is then not a shop, so no merchant is set. Any other name
names no carrier: the payload carrier and tracking link are null and the
card is headed "Pickup". What the number states is untouched: an S10
number ending in FR is still La Poste's and Colissimo's own shape is still
Colissimo's. The default for every other sender is unchanged.

A card's identity is its number, so cards already on the radar are
corrected in place the next time their mail is read.

fix(insight): read a parcel collected at a relay as delivered

"Votre colis a été retiré" and "... récupéré par son destinataire" name
no stage the extractor knew, so a collected parcel stayed "announced".
Both now read as delivered, with "colis" required nearby so the same
words about something else do not count.

The negation guard that already stopped "n'a pas été livré" is widened
to retiré and récupéré: "n'a pas été récupéré par son destinataire"
contains the delivered phrase whole.

Co-authored-by: mani <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:17:42 +02:00
938d549347 fix(insight): re-reading old mail no longer rewrites newer cards or fills Happening soon (#101)
* fix(insight): let an older mail leave a card as a newer one made it

The harvester's upsert lets the last mail it reads overwrite a card's status,
payload, ETA and link, on the assumption that the last one read is the newest.
A sync reads in date order, but the insights backfill walks by id, and the
Graph import stored mail newest-first: a parcel's "out for delivery" mail was
read before its "announced" one, and the older statement overwrote it, so a
re-read left every card on the first status of its series.

A mail strictly older than the one the card already stands on now changes
nothing. Mail of the same instant keeps the old behaviour (the last read
wins), and so does a card whose mail was deleted, which has nothing to be
older than. land() reports whether it wrote, so the backfill's count does not
include skipped drafts.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(insight): window undated cards on their mail's date, not their row's

The undated cards (GitHub activity and the like) are listed for the last seven
days, windowed and ordered on createdAt, the instant the harvester made the
row. That is the mail's age only while the harvester runs as mail arrives. The
insights backfill re-reads old mail and creates its cards now, so a card made
from a month-old notification was a card of the last minute and took a place
among the newest twenty for a week; the Graph import's newest-first ids put
the same distortion into the order.

The query now joins the card's mail and windows and orders on its receivedAt,
which is the newest mail of the series because the card links that one. A card
whose mail was deleted keeps its row's date, the only one it has. createdAt
keeps meaning when the row was created, so nothing is stamped differently and
no migration is needed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(insight): offer a code that states no expiry for fifteen minutes

OtpExtractor sets a code card's happensAt to its expiry, so the radar drops it
when that passes, and leaves it null when the mail states no lifetime, because
a guessed default would retire a code that might still be good. The radar's
list of undated cards then took such a code like any undated card (GitHub
activity and the like): seven days, with the code readable on the card.

Undated codes are now offered for fifteen minutes of their mail's date instead
of the week. That is a guess about how long a code lives, made on the display
side and not in the extractor, and it errs towards showing too little. The
extractor's decision not to invent an expiry stands, the code is still on the
mail itself, and codes that do state an expiry take the dated path as before.
Every other undated kind keeps its week.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: mani <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:16:17 +02:00
aeb36afe92 fix(insight): read an Outlook mail's HTML, not its 255-character preview, and read GLS France's code (#102)
* fix(insight): read an Outlook mail's HTML, not its 255-character preview

For an HTML mail, GraphMessageBuilder stores Microsoft's bodyPreview as
bodyText, and the preview is cut at 255 characters. ReadableBody::of()
returned that text whenever it was non-empty, so every insight extractor saw
only the first 255 characters of an Outlook mail: a tracking number further
down (Pickup, IKEA, GLS France relay notices) was never found.

ReadableBody now reads the HTML for a message that has a graphId, and falls
back to the preview only when the HTML has no words in it. A Graph mail with
a text body has no HTML, so its full text still comes back. Other providers
are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* feat(insight): read the match code in GLS France mail

GLS France states no tracking number of a recognisable shape: its mails carry
an eight-character code under the label "Numéro de colis", and gls-france.com
was not a known carrier sender, so no card was ever made for one.

The sender is listed as GLS, and the code is read from its mail only, behind
that label, in capitals, with at least one letter in it: eight bare digits are
Mondial Relay's shape, and a shop that prints the same label is not GLS. The
card links to moncolis.gls-france.com, GLS France's own tracking page; the
eleven-digit gls-group.eu numbers keep their own link.

GLS's survey mail quotes the same code and is still refused.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: mani <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:15:01 +02:00
Paul LütznerandClaude Opus 5.5 76bf97c82e Answer from the address a mail came in for, under its own name, and take a signature as HTML
Three things about who a mail is from, grouped as v0.3.5.

The sender name (#70). EmailAlias::$displayName was shown in the From
selector and published as the JMAP identity name, and nothing sent it:
MessageSendService built From with the account's name whatever the
address. There was also no field to set it in the browser.

  Account::senderNameFor()   the alias's name, else the account's; the
                             selector, the draft row and the From header
                             all read it
  Settings -> Aliases        a sender name field under each address
  EmailSubmission/set        writes fromName with the address it resolves

The address a reply goes out from (#41, the reply half). Every window
was pre-set from the account alone, so a reply to mail for an alias
opened on the primary address, and a draft saved from an alias reopened
on the primary and was stored that way by the next autosave.

  ReplyFromResolver          the first of From (own mail), To, Cc, then
                             Delivered-To / X-Original-To / Envelope-To
                             that the account may send as
  SenderResolver::tokenFor() opens a draft on the address it carries
  catch-all domains          an account setting, not an alias row: any
                             address at one may be answered from, is
                             left out of a reply-all, and signs with the
                             account signature. ComposeType admits such
                             a token on submit by the same rule. Not
                             offered for Gmail and Microsoft accounts.

Typing an arbitrary From into a new message is not in this.

Signatures as HTML (#32). Pasting source into the contenteditable gave
the source back, and a linked image never showed, the page's img-src
being 'self' data: blob:.

  Rendered | HTML            a textarea beside the editor. Rendered asks
                             the server (signature/preview) and shows
                             what a save would keep, with the elements
                             and attributes the sanitiser dropped named
                             underneath
  SignatureImporter          sanitises, and fetches https images through
                             ImageProxyFetcher into data: sources, 256 KB
                             each and 768 KB a signature; the rest stay
                             linked and are counted
  DataImageEmbedder          on send, a data: image becomes an inline
                             part and a cid: reference, since Gmail and
                             Outlook draw no data URI. It reads the
                             entity-encoded form the sanitiser stores
                             (&#61; for the padding).

No migration: the catch-all domains live in Account::$settings and are
carried by a config backup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:39:40 +02:00
maniandClaude Sonnet 5.5 44d5cefec5 docs: describe inbox tabs of your own and the filter action that fills them
The filters page gets the action, a worked carrier example with the marketing
exclusion, what the action will and will not file (authenticated senders only;
on Gmail only when plMail is told to sort over Google; nothing but a tab) and
three "Things that bite". The mail page gets "Tabs of your own", the backup page
the new rows, the ingest page why the verdict has a column of its own, and the
JMAP README why the five-value vocabulary is untouched. German pages follow with
their hash markers refreshed. CHANGELOG notes the migration and the one extra
inbox read.

Also pins, in the JMAP category test, that a conversation in a reader's own tab
is still served as its built-in category and that a custom key is refused.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:32:15 +02:00
Walid ManiandClaude Opus 5.5 f19818fa50 Put the Delete forever buttons behind a Disposal setting, off by default (#71)
Settings → Mail → Disposal has one per-user switch, off until somebody turns it on. It decides whether Delete forever exists at all: the flame on a row in Spam and the bin, and a new Delete forever above a selection there. Both purge routes answer 403 while the switch is off.

A selection extended with Select all N empties the whole of Spam or the bin in a background job. Any other view is refused, and the worker checks the switch and each conversation's placement again.

The bin no longer offers Delete above a selection: it trashed mail that was already in the bin, took the rows off the page and they were back on the next load.

The choice lives in the user's settings bag and a config backup carries it. No migration.

Continues #56.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:01:23 +02:00
Walid ManiandClaude Opus 5.5 99d2da3da2 Show each inbox tab's unread count, with a setting to switch it off (#54)
The sidebar's Inbox badge could read 1 while Primary, the tab that opens, had nothing unread: the conversation was in Promotions, and the only hint on the strip was a colour on that tab's icon. Each tab now shows how many conversations in it hold unread mail. It is the Inbox badge split by category, so the tabs add up to it, and it is patched in place on sync.

This reverses the documented rule that the "3 new" pill is the only number on a tab. The badge and the bold rows say that there is unread mail and cannot say where.

On for everyone, and a per-user appearance setting switches it off, beside the account corner. One column on the user table; the migration is reversible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:49:58 +02:00
Walid ManiandClaude Opus 5.5 ff1344ceb9 Keep the open message open when the list beside it changes (#63)
With the reading pane on the right, choosing another folder, switching a category tab, turning the page, sorting or searching closed the message being read. All of those swap one Turbo frame, and the mail pane answered every swap by showing the list, which is right while the message covers the list and wrong beside it.

The swap still adopts the new list's URL, so the back arrow returns to the folder that was switched to. With one pane at a time nothing changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:43:45 +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
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
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 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 53e77d3f93 Call the sorting card Categories
"What sorts your mail" named two of the card's three controls. With
the tabs switch on it as well, the card is about categories as a
whole: whether the inbox shows them, which one a message lands in,
and who decides.

Refs #29

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:21:28 +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 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 4ca055a724 Let a backfill that re-reads mail be told how far back to read
`app:backfill events` and `app:backfill insights` re-read stored mail for
what an extractor can now find in it. The usual occasion is a reader that
was just fixed and a mail from this week; run over a whole mailbox they
also dig up every calendar file and parcel since it began — and since
calendar files that name nobody here are now offered rather than added,
that is a card on every one of them.

Both take --since and default to the last 24 hours: a count with h, d or
w, a date, or "all". Anything else is refused and nothing runs. The
command prints the window before it starts.

A separate interface, WindowedBackfillTaskInterface, rather than a
parameter on run(): the other tasks repair a table as a whole, and one
that accepted a window and ignored it would look as though it had done
what it was asked. Given --since, they say they take none.

The insights walk is keyset on the id now instead of OFFSET.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:55:30 +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 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 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 8dc22b49c0 feat(search): show which words a result matched on
A row asserted a match and then showed something else. The preview was the
opening hundred characters of the body, which for most hits does not contain the
search term at all, and nothing was marked — so the only way to learn why a
conversation was in the list was to open it.

Every occurrence is marked now, in the subject and in the preview, and the
preview is a window of the message AROUND the match. The fragment comes from the
same Postgres `ts_headline` the JMAP snippet has always used, over the same query
that found the mail, so the marking is stemmed exactly as the search is and
cannot mark something the search did not match on.

**It also closes a hole that was open in the JMAP API.** `ts_headline` is a
highlighter and not an escaper: it inserts the delimiters it is given and returns
the rest of the document byte for byte. Asking it for `StartSel=<mark>` produced
a string in which the sender's markup and ours were the same kind of thing, and
the check that stood in for escaping — "does it contain `<mark>`" — could not
tell them apart. A subject or text part carrying `<img src=x onerror=…>` shipped
it to every client as snippet HTML, and per RFC 8621 a client is right to render
that. The class docblock claimed the opposite and had done since it was written.

The delimiters are two control characters now. The whole string is escaped and
only then are they swapped, pairs only, in one class that owns both the options
and the conversion so they cannot be changed apart. `toHtml()` answers JMAP;
`toMarkup()` answers Twig, which is what lets the row print a fragment without
`|raw` — that file still does not contain one.

Three things worth knowing:

- `ShortWord` went from 3 to 0. At 3 `ts_headline` trims short leading words, so
  "Oak for the alcove" searched for `alcove` came back as the bare word — a row
  that has rewritten the subject rather than highlighted it.
- A highlighted subject is used only when it still equals the thread's subject
  with the markers removed; anything else falls back. And it goes to a separate
  variable, because `subject` is also spent on `aria-label`, where a screen
  reader announcing the markup would be a worse regression than the one this
  fixes.
- The headline replaces the preview only when it actually contains a marker. That
  makes the two documented `body_text` regressions self-sorting: a bulk sender's
  "this email is only available in HTML" does not contain the term, so it does
  not win.

One batched query per account per page, not one per row: the budget test is a
hard ceiling and a per-row headline would blow it instantly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 17:18:08 +02:00
Paul LütznerandClaude Opus 5 41362290bb Release v0.2.20
Twenty-six commits since v0.2.19, of which the changelog had one entry — the
rest written up here.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:19:50 +02:00
Paul LütznerandClaude Opus 5 fabaf49a80 Let a reader report mail as filed in the wrong tab
Categorisation is decided by three things that disagree — a provider's labels, a
header cascade, and a model — and whether the answer is any good is a judgement
only the owner of the mailbox can make. Everything else in this feature can be
measured against fixtures somebody invented. This is the one route by which real
mail, wrongly filed, becomes something a rule or a prompt can be changed on the
strength of.

The control sits at the bottom of the header popover, where the three answers
already are. A SELECT AND A BUTTON rather than five buttons: knowing a message
was misfiled is a fraction as useful as knowing where it belonged — "promotions,
not primary" is a rule somebody can act on, "wrong" is a shrug. The tab it is
already in is not offered, because "it should be where it is" is not a report.

It records the DECISION and its evidence, not the message: what each of the
three said, what the rules matched on, which of them was deciding, which bulk
headers were present, the sender and the subject. Not the body — the most
sensitive part of a message and the least useful here, since most marketing mail
has no plain-text part at all and the classifier sees "(no plain text part)", so
the column would be empty for exactly the mail most often reported.

Sender and subject ARE kept, and on a multi-user installation an administrator
reading these sees them for other people's mail. That is a real cost, taken
deliberately: a report saying only "something was wrong" is not worth the
button, and it is why nothing is recorded without somebody pressing it.

The reports collect in Admin → AI rather than a nav entry of their own, because
that is where somebody ACTS on them — the prompt and the model are a card
above. The product is the TEXT: one line per report, built in the entity rather
than the template because a format assembled in Twig would change whenever the
table's layout did. Copy takes the block; clearing is a deliberate press, since
these are observations nothing regenerates and a pattern across twenty of them
over two months is the whole value.

No foreign key to `message`: a report outlives the mail it is about. Somebody
deletes the message, the pattern it was evidence of does not go away.

Verified on the stack: the control renders, a report returns 204, a hand-edited
category is refused with 400, the line appears in the admin card, and clearing
empties it.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:44:25 +02:00
Paul LütznerandClaude Opus 5 408fcd1bed Run full-conversation summaries as a job, with nothing held open
A full run is silent for minutes while the model reads, and for the whole of
that silence the answer depended on a browser connection staying open. That
dependency is what has been failing, and no timeout could have fixed it:

    full: true, transcript_chars: 67720, elapsed_seconds: 40, chars_so_far: 0

Forty seconds, four heartbeats already delivered, nothing written yet, and a
model host that was up for seven days either side of it. The connection was
alive and being written to and then was not. Nothing in plMail closed it — a
held-open connection has a proxy, a browser and a network in it, and any of them
may end it without telling this application. Three rounds went into tuning
timeouts before the metrics panel said `cancelled` and named the real shape.

So the full run stops needing a connection. It dispatches SummariseThreadMessage
and answers 202; a worker on the maintenance transport does the work, writes to
the store and publishes a Mercure nudge; the card shows it as queued and fills
in when it lands. The reader may close the conversation, the tab, or the laptop
and still find the summary waiting — which is what somebody just told "this
takes several minutes" would reasonably expect anyway.

maintenance rather than ingest, deliberately: this holds a consumer for minutes,
and mail arriving must not queue behind somebody's summary.

The nudge carries no text. Same shape as JobNotifier and the insights strip —
the store is the record and a missed message costs one stale render — with one
reason of its own: what it would be carrying is a paragraph about somebody's
mail, on a channel that has never held any.

TWO BUGS RETIRED ON THE WAY. The streamed path is now never full, so the
transcript it hashes for freshness is always the one the reader's page
recomputes; a full run used to store a hash that could never be reproduced,
which would have shown every full summary as permanently out of date. And the
handler hashes the ORDINARY transcript for the same reason while sending the
full one to the model — the hash answers "has this conversation changed", and
the only answer that matters is the one the reader will ask.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:28:25 +02:00
Paul LütznerandClaude Opus 5 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 6a4118ddbb Stop reporting a failed semantic search as a slow model
The search page has two ways to print "The model took too long, so this
search used words only", and only one of them is about the model.

SemanticQuery answers TimedOut when Ollama does not return a query vector
inside EMBED_TIMEOUT. That one is honest. MessageThreadRepository::cheapRows()
answered the same TimedOut when the vector arm blew its 5s statement budget —
by which point the model had already answered, in milliseconds, and is sitting
in ai_call_metric marked a success. An operator reading that sentence goes to
the model host, where there is nothing to find.

SemanticQuery's own docblock guards the mirror image of this: the embedding is
done in the controller so that a slow model is never reported as a database
fault. This was the same confusion pointing the other way, and it is the worse
direction, because it names a machine that is working.

Worse, the catch was on DriverException, which is every driver fault Postgres
can raise. DBAL's PostgreSQL ExceptionConverter maps deadlocks, constraint
violations and syntax errors onto dedicated classes and lets 57014 fall through
to a plain DriverException, so "the budget fired" and "the query is broken"
arrived as the same type. A missing plmail_embed_distance(), a stored width
that disagrees with its vector, a connection dropped mid-statement: all
deterministic, all degrading every search instantly and for as long as the
cause stands, all reported as an occasional overrun — and none of them logged
anywhere at all, because the exception was discarded unnamed.

So: SearchTooSlow for a real cancellation, SearchFailed for everything else,
each with its own sentence in all three catalogues, and both logged with their
SQLSTATE. The repository gets its first logger for it. A rare give-up path that
leaves no trace is indistinguishable from a common one, which is how this
stayed invisible.

The new test drops plmail_embed_distance inside the test transaction rather
than making the query slow, so it cannot pass by accident on a busy machine:
42883 is not 57014 under any load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 10:31:06 +02:00
Paul LütznerandClaude Opus 5 dadab12439 feat(inbox): a tab says in colour when it is holding unread mail
The report, with a screenshot: "Unread only" on, Primary selected, the list
reading "No messages in this tab", and the sidebar's Inbox badge reading 66
next to it. Both were true — the 66 was in Social and Updates, and nothing on
the strip distinguished those tabs from the empty one being looked at. The only
way to find the mail was to click every tab in turn.

The reasoning that had kept unread off the tabs holds in the ordinary inbox:
unread is already said by the bold rows and the sidebar badge, so a number on a
tab would be a third voice saying it. Under the filter it inverts. Every row on
screen is unread, so boldness separates nothing, and the badge is one total that
cannot name a tab.

So the strip makes the one statement nothing else can, without a number: the
icon wears its category's colour when there is unread mail behind it. Drawn
outside the filter too, so the strip means the same thing wherever you meet it,
and never on the tab being looked at — that mail is already in the list
underneath, and an identity colour dropped into the accent-coloured active tab
reads as a fault rather than a signal. It was reported as one.

It rides on the existing marker machinery: rendered whatever the count, carrying
a data-count-key the counts endpoint now answers, so a Mercure sync re-tints in
place. The tone travels in an attribute because the patcher cannot know which of
the five a tab wears.

Three unified-inbox counts also stopped counting conversations the list refuses
to show. Binning a thread ADDS the Trash label rather than swapping it, and
findForUnifiedInbox() has always excluded trashed threads while the counts beside
it did not. That lit a tab for a thread the list would not show, on a mailbox
whose badge said one unread and whose tabs claimed two — the Trash-badge-versus-
list disagreement BadgeSemanticsTest exists for, arriving by another road. It
also means countForUnifiedInbox() had been feeding the paginator a total that
could offer a page that does not exist, which is the failure its own DISTINCT is
written out to prevent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 15:29:42 +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