mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
main
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
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
bdffac32e4 |
Describe the tab page, switching tabs off and their order in the handbook
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
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 |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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 (= 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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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
|
||
|
|
13385fd976 |
fix(ai): Stop on a summary stops the model and keeps nothing
Since a summary whose connection drops is finished and stored anyway
(
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |