132 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
cdd0cd4fd8 The installed app's icons follow the chosen logo, drawn by the browser (#229)
An app installed from the browser or added to an iPhone home screen kept the wax seal. The manifest and apple-touch-icon now name the chosen logo; the browser draws the PNGs on a canvas and uploads them per user, so the image needs no librsvg. Builds on #92. Closes #141.

Co-authored-by: mani <15911342+Flouss@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:16:55 +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
b940bd3a31 The installed app says New mail only when mail arrived (#162)
The in-app Web Push device is now told only when mail lands in the Inbox of a mailbox that has finished importing, and Outlook stops recording folder moves for messages that did not move. JMAP clients and FCM phones keep every StateChange. Migration: jmap_push_subscription.arrivals_only, set once for in-app devices.

Takes over #112.

Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:12:06 +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
Paul LütznerandClaude Opus 5.5 b4b4eb1a72 Read French mail as German and English mail is read
The French interface came with a note that appointments in French mail
were left for later. They are read now, for every user, since the
interface language says nothing about the language of the mail:

- The date detector and its gate know the French months and weekdays,
  "1er", 14h30 / 14 h 30 / "à 9h" / "vers 15 heures" / midi, demain,
  après-demain (before demain, as übermorgen before morgen),
  aujourd'hui, ce soir, and "mardi prochain" either way round. A bare
  single-digit "2h" is not a time: beside a date it is a length.
- A French reader reads 04/08/2026 day first, as a German one does.
- French quote attributions ("… a écrit :") and forwarded headers
  ("Envoyé :", "Message transféré") are not appointments.
- Durations: "2 heures", "une heure".
- Invoices: facture, net à payer, montant dû, échéance, numéro de
  facture, and "Total HT" is not the total.
- Login codes: code de vérification and its relatives, "valable 10
  minutes", and French order and customer numbers are refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:03:22 +02:00
jo2904andClaude Opus 5.5 5550727888 AppLocale : proposer le français
Ajoute 'fr' à AppLocale et à framework.enabled_locales, qui doivent
rester synchronisés (le commentaire de translation.yaml le dit). Le
sélecteur de langue des réglages et celui de l'installation lisent
AppLocale::cases() : « 🇫🇷 Français » y apparaît sans autre
modification.

Deux réglages suivent la langue et devaient connaître le français :
- ClockFormat::forLocale() : le français écrit 14 h 00, donc 24 heures
  par défaut comme l'allemand. Sans ça, un utilisateur francophone qui
  n'a rien choisi verrait « 2 PM ». Testé dans ClockFormatResolverTest.
- Le sélecteur d'emoji charge ses noms et mots-clés par langue (la
  recherche « cœur » ne trouve rien dans les données anglaises).
  assets/emoji/fr/data.json vient de emoji-picker-element-data, comme
  en/ et de/. J'ai vérifié que ces deux fichiers sont identiques octet
  pour octet à ceux publiés par le paquet, donc fr/ vient bien de la
  même source. Le gabarit choisit maintenant de, fr ou en d'après la
  langue au lieu du seul test « starts with 'de' ».

Laissé tel quel : DeterministicDateDetector, qui lit les dates dans le
contenu des mails selon leur langue, et non la langue de l'interface.
Apprendre les dates françaises (« le 4 août ») est un autre chantier.

Docs : README, features/appearance.md (et sa traduction allemande, avec
le hash mis à jour), et une entrée de CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
2026-10-13 23:33:38 +02:00
09c503c61c Sign in through an OpenID Connect provider (Authentik, Keycloak…) (#106)
plMail could only sign people in with a local password. On an install that
already runs Authentik or Keycloak, that is one more account and password
per person, outside the provider's access policy.

A second authenticator on the `main` firewall, beside the password form.
Both end in the same token for the same User, so the user checker, the login
throttle and TOTP two-factor apply unchanged. Authorization code flow with
PKCE, state and nonce on league/oauth2-client's GenericProvider; endpoints
come from the provider's discovery document.

- Set up under a new Admin → SSO tab; the OIDC_* variables are the fallback
  for an install configured from its compose file, and a saved provider wins
  whole. This lets an administrator who points it at a provider of their own
  sign in as any account without a second factor; the security model says so.
- The ID token's signature is not verified: it only ever comes from the
  token endpoint over TLS (OIDC Core § 3.1.3.7 step 6). So the issuer and
  endpoints must be https and no request to the provider follows a redirect.
  Accepting an ID token from anywhere else needs JWKS verification first.
- An account is linked by (issuer, sub). The address is used for the first
  link only, and only when the provider reports it verified.
- Auto-creation is off by default and creates an ordinary account with no
  usable password.
- app:user:oidc-unlink forgets a link from the console, for somebody deleted
  and re-added at the provider.

Migration: two nullable columns and a unique index on `user`, and a one-row
table `oidc_config`. Nothing backfilled; reversible.

Not run: Playwright on this branch. No automated test makes a round trip
against a real provider; that was checked by hand on a local test stack.

Closes #91

Co-Authored-By: Paul Lützner <mail@pluetzner.de>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:17:00 +02:00
b793faccff fix(calendar): read the calendar page in the user's zone when the default calendar is synced (#105)
A subscribed Google/CalDAV calendar carries the zone its remote account
holds for it, often UTC for a shared one. CalendarTimeResolver::zoneFor()
let that zone win whenever such a calendar was the default, so the whole
page was drawn in UTC: an 11:30 Europe/Paris appointment, stored correctly
as 09:30 UTC, showed at 09:30.

zoneFor() now uses the user's own zone (profile, else install default) when
the default calendar isSynced(); a local calendar keeps its zone.

UpcomingEventIndicator repeated the same decision by reading the default
calendar's column itself, so the top bar's "today" stayed on the remote
clock. It now asks the resolver.

Co-authored-by: mani <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:28:10 +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
4d705a56da Keep remember-me tokens on the server, so signing out holds (#104)
* Keep remember-me tokens on the server, so signing out holds

A remember-me cookie that carries only its own signature stays valid
until it expires, and signing out only asks the browser to delete it.
A request already on its way with it - the page's own background
fetches - reached the server after the sign-out and signed the person
straight back in.

The tokens are kept on the server now (Symfony's DoctrineTokenProvider,
table rememberme_token), one per device. Signing out deletes this
device's token, so a late cookie opens nothing; other devices stay
signed in. Symfony's CacheTokenVerifier keeps parallel requests that
rotate the token from looking like theft.

Existing signed cookies are no longer read: people who relied on "Keep
me signed in" sign in once more.

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

* Say what remember-me is now, wherever it was described

Remember-me moved from a signed cookie to tokens kept on the server, and
five places still said "signature-based, not stored": the handbook's
security page, the config-backup page's account of why APP_SECRET stays
behind, and three comments beside the code that depends on it. English
and German both.

RememberMeAcrossRestoreTest proved its point against SignatureHasher,
which the firewall no longer uses. It now asserts what the property rests
on today: the firewall holds the persistent handler, and a backup taken
while a token row exists carries neither its series nor its value.

One test added for the claim the docs repeat: a changed password ends the
cookie, because the token carries a digest of the hash it was issued under.

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 <mail@pluetzner.de>
2026-10-13 17:18:43 +02:00
Paul LütznerandClaude Opus 5.5 cadab57a9b Count a catch-all address as the user's only for a mail it arrived by
76bf97c8 made every address at a catch-all domain the account's own.
A catch-all catches what has no mailbox, and on a shared domain other
people have one: with shop.test declared, anna@shop.test was dropped
from a reply-all, a reply to her mail could open with her address in
From, and her mail was read as the user's own sent mail.

OwnAddressHelper answers per message instead:

  caughtRecipients()  the catch-all addresses in Delivered-To,
                      X-Original-To or Envelope-To; only where the server
                      left none, those in To and Cc
  sentByAccount()     an alias in From, or a catch-all address in From
                      on a message filed under Sent or Drafts
  forMessage()        the aliases plus caughtRecipients()

ReplyFromResolver and ReplyDraftBuilder use it, and Account::ownsAddress()
is gone with its one caller.

Invitations use it too, which they did not before at all: an invite to
yo@shop.test named no address the account knew, so the card had no "me",
no answer buttons, and the event was treated as naming nobody here
(InviteReader, EventReconciler, EventProposer). The reply already goes
out from the attendee address.

With no delivery header and two catch-all addresses in To, both are
still taken for the user's; the docs say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:52:31 +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 c795eeeee1 fix(mail): re-resolve what a deleted tab held, and say exactly what the guard guarantees
Removing a category cleared the pins of the conversations in it but left them
in whatever built-in tab they had when they were dragged, until their next
message; the removal now re-resolves exactly those conversations, in the same
transaction. The "wrong tab" report is no longer offered for a conversation in
a tab the reader made: it is evidence about plMail's rules, and "should be
Promotions" was refused there as already true.

The inbox keeps its own two query ceilings (28 and 16) while the other lists
keep 27 and 15, so one view's extra read cannot loosen five guards. The two
partial indexes are declared in the mappings so Doctrine agrees with the
migration. A restored category name is trimmed and cut to the column.

The docs, changelog and the filer's own docblock now state the guard's limits:
it checks the message, not the conversation; it needs a filter that names the
sender; and a filter that does not name one files any sender that
authenticates for its own domain. Dead delegating methods, a reference to a
class that does not exist and two stale comments are removed or corrected.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:32:16 +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 2a765d8b32 Let the calendar button skip the split view or the full-width calendar (#80)
The button in the top bar is a three-way switch: mail, the calendar beside the mail, the calendar full width. Settings → Calendar → Behaviour has one choice, Calendar button, that can make it a two-way one: All three (the default), Full width only, or Split only.

The position the button leaves out is not removed: a stored one closes to the mail on the first press, and the drag handle still reaches both ends. A mail link clicked while the calendar is full width goes back to the mail when the split is skipped.

One value in the user's settings bag (calendar.switch_skip), included in the config backup. Nothing is added to the database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 13:23:38 +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 92116fcdcb Change an account's colour from its row in settings (#69)
An account's colour was handed out once, when the account was added, and nothing could change it. Every row in Settings → Mail accounts now has a swatch that opens the palette of eight.

It is a control on the row and a route of its own, not a field in the edit form, because that form refuses OAuth accounts and the swatch would have been missing on every Gmail and Outlook account. The value is range-checked rather than wrapped. Two accounts may share a colour. No migration: colorIndex already exists and is already in config backups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:45:44 +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
88197578d5 Let search by meaning use a model host of its own
One host held both models. The two jobs often want different machines:
writing wants the largest model somebody can run or rent, and search wants
a small one that is asked constantly, for every query and once for every
message of a mailbox. Writing from a hosted endpoint with search on an
Ollama at home indexes the mailbox on a machine the operator owns and
bills nothing per message, and one host for both could not express it.

Admin → AI has a tick box that gives search its own provider, address,
token and extra headers. AiSettings::connectionFor() returns that
connection for search when the box is ticked, and nothing else had to
change, because every call already asked for the connection of its
feature. The second host is sent only its own token and headers, and none
when it has none. A feature is usable when the host it runs on is filled
in. Test asks both hosts, each for the model that lives on it.

Stored vectors stay filed under the model name alone, so moving search to
a host that serves the same model keeps the index and no stored row is
touched. Five columns on ai_settings, unused until the box is ticked; the
migration is reversible.

The idea of a separate embedding connection comes from #58. Its vector
identity, which hashed the address into every stored row, is left out: it
forced a full re-embed whenever a host changed its address.

Co-authored-by: shady <shady2k@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:11:45 +02:00
cd7828395d Send the model host extra headers
A bearer token is not all that stands in front of a model. A reverse proxy
with basic auth, a zero-trust gateway that wants a client id and a secret,
a routing gateway with a key of its own, a provider that takes a project
by header: each of those made plMail unusable with it.

Admin → AI has an Extra headers box under the token, one `Name: value` per
line. ConnectionHeaders reads them strictly while somebody can still fix
what they typed, and forgivingly on the way to the wire, so a column that
cannot be read costs the headers and not the feature. They are stored
encrypted and ride on AiConnection, so both clients send them on every
request without knowing they exist.

The values are never shown again; the page lists the names on file. An
empty box keeps them, typing replaces them, a tick box removes them, and a
change of address or provider drops them as it drops the token. A typed
Authorization replaces the bearer. The headers the HTTP client writes
itself are refused.

Fixed values only. The idea of extra headers comes from #58; its per-task
session header is left out, because nothing plMail does needs one.

Co-authored-by: shady <shady2k@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:01:54 +02:00
3c7a03b843 Talk to an OpenAI-compatible endpoint as well as Ollama
The language model had to be an Ollama container. Admin → AI now has a
Provider field, and the second choice is the request format OpenAI
published and nearly everything else has copied: llama.cpp, vLLM, LM
Studio, LiteLLM, the hosted providers, and Ollama itself under /v1.

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

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

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

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

Co-authored-by: shady <shady2k@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:54:49 +02:00
Paul LütznerandClaude Opus 5.5 351c92b8a3 Send the model host its token
Admin → AI has a Token field for an Ollama behind a reverse proxy. The
token was stored, encrypted, and never sent: every call site passed the
address alone. A host that asks for one answered 401 to everything while
the page said a token was on file.

The address and the token now travel as one AiConnection, and OllamaClient
scopes its HTTP client to it, so every request carries the header —
including the fallback embedding dialect and the version read behind a
probe. Redirects are not followed once a token is attached.

The token belongs to the address it was typed for. The form drops a stored
token when the address changes and none is typed, in Admin → AI and in the
setup wizard alike; the rule lived in both controllers and is now in the
form. The Test button binds to a copy of the settings, shows the typed
token to the typed address, and asks nobody without a valid CSRF token.

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

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

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

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

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

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

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

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

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

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

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

Review added, in the same change:

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

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

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

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

Review added, in the same change:

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

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

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

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

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

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

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

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

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

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

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

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

Refs #34

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:01:03 +02:00
Paul LütznerandClaude Opus 5.5 bf3e1e8336 Serve templates to JMAP clients
A new capability, urn:plmail:params:jmap:templates, and five methods.
Template/get|set and TemplateFolder/get|set read and write what
Settings → Templates does, through the same library and the same
sanitiser: a template written on a phone is inserted into the web
compose window too. They are per user and take no accountId argument;
accountId is a property saying which mail account an object is filed
under. State is a hash of the list, as Appearance's is, because the
change log is keyed by mail account and these are not.

Template/render is the one a client inserts through. The stored body
still has its {{tokens}} in it, and turning them into values needs
the user's clock, the signature rules and the token grammar — three
things that would each be a second implementation in an app. The
client names the account it writes from and, if it has one, the
recipient, and gets a subject, an HTML and a text body, and the list
of recipient variables nobody could answer.

Filling the recipient on the server is a second implementation of a
rule the web runs in the browser, and both ends now say so. The web
keeps doing it client-side because it must not put a request inside a
keystroke; a native app already asks the server for every template
and has the recipient as data. TemplateRecipientFillerTest holds the
cases the two have to agree on.

  TemplateLibrary::deleteFolder   now removes the subfolders through
                                  the entity manager as well as the
                                  cascade, so a request destroying
                                  several folders is not handed a
                                  managed object for a deleted row.

The Session lists the variables and date formats an editor may
offer. No app uses any of this yet.

Refs #26

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

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

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

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

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

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

Refs #26

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

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

Refs #31

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:20:26 +02:00
Paul LütznerandClaude Opus 5.5 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