Commit Graph
100 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 41248d0f23 Release v0.3.8 (#285)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:20:35 +02:00
Paul LütznerandClaude Opus 5.5 33f58ade8b Publish an image for every push to a milestone branch (#261)
A milestone branch collects a release before it reaches main, and nothing
built it, so an install could not run that release until it was merged.
docker.yml now builds pushes to milestone/** the way it builds main: amd64,
tagged with the branch name (milestone-v0.3.8) and sha-..., never latest
and never the catalogue. The tag is in the upgrading page's table, in both
languages, and in the changelog's note on image tags.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:46:49 +02:00
Paul LütznerandClaude Opus 5.5 6bd4dc3850 Close an issue when its pull request is merged into a milestone branch (#259)
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>
2026-10-20 16:54:01 +02:00
Paul LütznerandClaude Opus 5.5 a758818488 linked-issue: read the description of a pull request into a milestone branch (#254) (#255)
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.


(cherry picked from commit 894c7f698f)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:37:52 +02:00
Paul LütznerandClaude Opus 5.5 c5df99b4b7 Release v0.3.7 (#249)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:46:59 +02:00
Paul LütznerandClaude Opus 5.5 0591264213 v0.3.7 highlight: single sign-on only, with the login page as its picture (#248)
The one feature in v0.3.7 (#169) is marked as its highlight and moved to
the top of Unreleased. Its picture is the login page with the provider's
button and no password form, taken by a new sso-only test in
whats-new-screenshots.spec.ts.

The two inline-image entries said the same thing twice; they are one
entry now, linked to #213.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:22:16 +02:00
Paul LütznerandClaude Opus 5.5 880f338d53 Hide the Drafts warning in the sidebar when no draft's send has failed (#247)
The mark carried `hidden` on the Font Awesome <i>, whose .fa-solid rule
sets display and lands after Tailwind's utilities, so it showed at every
count, on every install, with no draft marked. `hidden` now sits on a
wrapper span with no other display utility (an `inline-flex` beside it
won as well). The test checks for both.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:51:49 +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 4d18105909 Mail with a repeated Date header is imported instead of failing the sync; Proton Bridge's "no such message" is no new mail (#241)
webklex's header parser hands a repeated Date (or References) to string
functions as an array or as one date with trailing data; the TypeError or
Carbon exception escaped the library's per-message catch and failed the
whole page of the sync or import, every time. ImapQuery now reads such a
message again with the repeated once-only headers collapsed, keeping the
raw header the server sent, so it is imported. A header the repair cannot
mend is recorded against its UID and retried, then skipped, on its own.

Proton Mail Bridge answered the sync's UID SEARCH n:* with "NO no such
message". The range searches read that as an empty result; the
importer's search for a page of known UIDs does not.

Closes #219

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:03:07 +02:00
Paul LütznerandClaude Opus 5.5 122c8e56f3 Publish a GitHub Release for every v* tag, with its CHANGELOG section as notes (#240)
A tag gave a feed reader nothing but a version number: there was no
releases.atom and nothing for "Watch → Releases" to notify about. A new
`release` job in docker.yml runs after the image is published, cuts the
tag's section out of CHANGELOG.md with bin/release-notes.sh (unwrapped,
since a release body shows every newline), and creates the release, or
rewrites its notes on a re-run.

Discussion #226, asked by @adrianfurgol.

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

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

Closes #120

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

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

Closes #207

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 08:15:46 +02:00
Paul LütznerandClaude Opus 5.5 9c76bbc636 The blob prune follows APP_STORAGE_DIR and clears orphaned thumbnails (#234)
app:prune:blobs looked under a hardcoded var/, so installs with another APP_STORAGE_DIR reclaimed nothing; it and the thumbnail cache now use APP_STORAGE_DIR. A third pass deletes attachment thumbnails whose part no longer exists. Closes #232, closes #233.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:38:24 +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 fbf7c0ea6f The weekly blob prune only sweeps the per-account trees (#228)
app:prune:blobs deleted week-old profile pictures, signature images and backgrounds under var/uploads because no row named them. The orphan sweep now only enters numeric account directories and only files in each writer's exact layout, so everything else on those volumes is left alone, including directories added later. Closes #214.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:19:02 +02:00
Paul Lützner e23377eb6a Migrate test databases with app:db:migrate so the cache tables exist (#225)
doctrine:migrations:migrate leaves cache_items out; without it the first cache read inside a test's transaction aborts it, so ThreadSummaryTest and the Gmail import tests failed whenever nothing had created the table first.
2026-10-19 13:51:50 +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
Paul LütznerandClaude Opus 5.5 65e68fcb0a Calendar: an event that ends when it starts stays on its day (#198)
An end equal to the start (or a zero DURATION) is a stated instant and
now gets one minute instead of the nominal hour, so a 23:59 reminder no
longer runs past midnight and shows again on the next day. The nominal
hour for an event with no end at all now stops at the midnight of the
day it starts on, in DTSTART's own zone.

Closes #170

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:34:59 +02:00
Paul LütznerandClaude Opus 5.5 a6f8181c0d What's new: one X on a full-size screenshot (#197)
The picture brought its own X into the corner of the dialog body, just
below the modal's X. It is gone; while a picture is up, the modal's X is
relabelled "Close the screenshot" and its first press puts the picture
back, the second closes What's new, as Escape already did.

Closes #187

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:21:53 +02:00
Paul LütznerandClaude Opus 5.5 08886538a3 Leave a message another Gmail account holds with that account (#196)
* Leave a message another Gmail account holds with that account

With several Gmail accounts connected, mail forwarded from one to another
reaches the second still Delivered-To the first and is attributed there.
Both merges in SyncGmailMessageBatchHandler then found the first account's
own row by Message-ID and wrote the second mailbox's Gmail id over its own:
they were written for a sibling that syncs over IMAP, and the sibling map is
every active account. The first account's sync then stored the message a
second time, and a later merge by Message-ID picked the other of the two
rows and gave it an id its twin still held: a duplicate key on
uniq_message_gmail_id_account, the whole batch failed, and every retry
fetched it again until Gmail answered 429 (discussion #168).

A row is now looked up by its Gmail id before either merge. A merge only
gives an id to a row of a non-Gmail account that has none yet, or has this
carrier's; anything else is recorded as held elsewhere, so the import counts
it and stops asking for it.

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

* Link the changelog entry to its PR

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 16:47:49 +02:00
Paul LütznerandClaude Opus 5.5 aba0130c6b Release v0.3.6 (#194)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 16:29:15 +02:00
Paul LütznerandClaude Opus 5.5 57ea27490e A label a rule applies to new mail stays on its conversation (#185)
Rules run after threading and before the batch's flush, and ThreadLabelSynchronizer took the union over $thread->messages, which did not yet hold the message the threader had just attached. A rule's label was taken straight off the conversation again. The sync now counts the messages that point at the thread in memory (collection, identity map and pending inserts).

Closes #184

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:29:20 +02:00
Paul LütznerandClaude Opus 5.5 0277f72b29 What's new: a screenshot grows to fill the dialog instead of opening a tab (#181)
Clicking a highlight's screenshot opened the PNG in a new tab. It now grows
out of its thumbnail until it fills the What's new dialog, with an X; the X,
Escape or Back shrink it back, and What's new stays open.

Back goes through a history entry of its own (assets/history_layer.js),
imported first in app.js so its popstate listener runs before Turbo's, which
would otherwise restore the page and close the dialog.

Closes #179

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:02:06 +02:00
Paul LütznerandClaude Opus 5.5 0fd9e84a11 What's new: the ? menu above the calendar, main builds, and feedback with a screenshot (#167)
* The ? menu draws above the calendar, and a main build announces its newest release

The menu was an ordinary dropdown inside the header's z-20 stacking
context, so the calendar's day headings painted over it. It now opens in
the top layer through ui--user-menu, like the account menu, and closes
when an item is chosen.

A build that is not a tag (main, sha-…, a checkout) counted as no
release, so installs that follow main never saw the dialog. It now
counts as the newest tagged release in its own changelog; Unreleased is
still never shown.

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

* Feedback with a screenshot stays in the dialog, whatever comes back

Sending with a screenshot closed the dialog without the sent screen. The
form is now posted with fetch() and its answer drawn into the dialog;
anything that is not the dialog (a proxy refusing a large body, a server
error) is said in the form with its status, and the dialog stays.

The screenshot is a JPEG at most 1600 px wide instead of a full-size
PNG, a fraction of the size, so a reverse proxy's default body limit is
far away. The From is the first field of the account that is an
address; an account with none is refused in the form instead of
throwing at send time.

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

* Find the open conversation's toolbar by type in the keyboard-shortcuts spec

The spec looked for the conversation by id alone, and the messages
inside it carry the same attribute with their own ids. On CI a message
id equalled the conversation's, so two elements matched and the strict
locator failed on the first try and the retry.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 00:21:10 +02:00
Paul LütznerandClaude Opus 5.5 994c00b5cd What's new opens after an update, with a way to send feedback (#166)
* What's new opens after an update, with a way to send feedback

The release notes from CHANGELOG.md, read from the image, in a dialog that
opens by itself the first time somebody uses plMail after an update and
never on a fresh install. A ? in the top bar opens it again, with a dot
while something is unseen; "Don't show this after updates" silences it,
and Settings -> General -> What's new undoes that. Highlights are marked
in the changelog with an HTML comment and may carry a screenshot taken by
tests/e2e/whats-new-screenshots.spec.ts.

Send feedback is a mail from one of the person's own accounts to
APP_FEEDBACK_TO (feedback@pluetzner.de by default), with an optional
rating and a screenshot of the page behind the dialog.

Existing users are recorded as having seen 0.3.1 by a migration; anyone
created later starts on the release running at their first request.

Closes #165.

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

* Plain changelog headlines from 0.3.2 on, and highlights with screenshots

Every bold headline users see under "New to you" now says what changed in
a few words. Each release gets a handful of highlights, eight of them
with a picture cropped to the card or pane they are about, taken by
tests/e2e/whats-new-screenshots.spec.ts.

Relative links in the notes keep their words and lose the target, and
the parsed changelog's cache key carries a format version so a new
image never serves the previous one's parse.

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

* Keep highlight pictures for ten releases, and none that nothing names

Every file in public/whats-new ships in every image. ChangelogReaderTest
now fails on a picture no highlight names, and on one whose release is
more than ten back; the release pull request that crosses the line
deletes it and leaves the entry a highlight without a picture.
Documented in CONTRIBUTING.md.

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

* Credit #166 in the changelog

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

* Match the Back button by its whole name in the back-navigation specs

Playwright matches a string name as a substring, so "Back" also found the
new "Help and feedback" button in the top bar, which comes first, and the
specs opened its menu instead of going back to the list.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:17:57 +02:00
ebd2570a59 A draft synced from Outlook or Gmail opens in the editor, and one sent elsewhere stops being a draft (#164)
Synced drafts no longer get a sentAt, so the editor opens them. A migration clears it on rows the provider still files as drafts, and a draft that turns up in Sent is settled as sent.

Takes over #123.

Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 20:42:20 +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
Paul LütznerandClaude Opus 5.5 ffbf7bdd9b Clean up the compose files (#160)
Compose files keep only a short header; the reasoning lives in
docs/install. truenas.compose.yaml is gone (the TrueNAS catalog app
replaces it) and so is compose.prod.yaml (compose.yaml already runs the
published prod image). compose.override.yaml.dist becomes compose.dev.yaml,
passed with -f, so there is no untracked copy that compose loads on its
own. Docs, CODESTYLE, CONTRIBUTING and the entrypoint's error text follow.

Every remaining file resolves to the same `docker compose config` as
before.

Closes #30

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:43:22 +02:00
Paul LütznerandClaude Opus 5.5 0f54eb69ce Require a linked issue on pull requests (#133)
The linked-issue check now fails a PR that closes no issue, unless it is labelled bug, chore or dependencies, and re-runs on label changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:29:14 +02:00
Paul LütznerandClaude Opus 5.5 97b53b26e0 Issue-first contribution flow: templates, labels, milestones (#131)
Issue forms, a PR template, area labels and a linked-issue warning on PRs, and CONTRIBUTING.md "How work gets in": a feature needs an accepted issue first, milestones are releases, main is protected. The roadmap checklist moved to issues #126-#130.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:50:04 +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 549ddefb20 Take a list refresh asked for during another, instead of dropping it
bulk-select-all's "the pager agrees with the rows after a bulk action"
failed now and then under load. Not the job: it archived the 67
conversations in six seconds. A nudge from the job started a list
refresh while it was still writing, the job's "done" arrived while that
fetch was out, and _refreshList() returned early because one was
already in flight. The fetch came back without the archive, and nothing
was left to refresh the list — it showed the archived conversations
until something unrelated happened.

A request that arrives during a refresh is now remembered and taken
once the current fetch lands, keeping the most urgent of whatever asked
in the meantime. list-refresh-queue.spec.ts holds the first fetch at the
network so the second request always arrives during it; it fails
without this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:19:08 +02:00
Paul LütznerandClaude Opus 5.5 fea1a25f82 Stop the signature spec leaking into compose, and type where the test says
compose-formatting's "typed emoticons are left exactly as typed" failed
about a third of the time under load. The signature spec saved a
signature on the worker's user and never took it away, so whichever spec
ran next on that worker composed with one. The emoji test clicked the
editor and assumed the caret landed at the end of the body; with a
signature it lands on the empty line above it, the text went in before
the signature, and the exact assertion failed.

Both ends fixed: the signature spec clears the signature after each
test, and the emoji test places the caret at the end of the body
itself instead of trusting a click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:57:18 +02:00
Paul LütznerandClaude Opus 5.5 0139616c57 Document the inbox tabs for client authors, backups and the ingest internals
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:46:07 +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ütznerandClaude Opus 5.5 ab15ccc762 Ask before deleting a tab, and test the categories page in a browser
The delete form had Turbo switched off, which is what asks Turbo's
confirm question — so a tab and its filing went at one click. It goes
through Turbo now, as the account list's delete does.

The tab card is headed "Tabs": the sorting card's first switch is
already called "Inbox tabs".

inbox-categories.spec.ts makes a tab, moves it with the buttons and
reloads to the same order, and switches Forums off and finds no Forums
tab in the inbox.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:52:09 +02:00
Paul LütznerandClaude Opus 5.5 637e964408 Serve the inbox tabs over JMAP, and tell phones when a re-sort moves mail
A phone could see the five built-in categories and nothing of how the
reader arranged them. urn:plmail:params:jmap:inboxtabs adds:

- InboxTab/get and /changes: every tab in the reader's order, built-ins
  with isEnabled, the reader's own with their name. Per user, answered
  by every account; a change is recorded in each account's log so push
  and /changes work as they do for labels.
- Thread.inboxTab: the tab the web shows a conversation in — the
  reader's own, the built-in, or Primary for a switched-off category.
- Email/query threadInboxTab: the same list the web shows under a key.
- Thread/set { inboxTab }: a move between tabs, pinned like a drop.
  A tab the reader does not have is refused.

Thread.category and threadCategory are unchanged for clients that know
only the five.

The recompute statements wrote every conversation's category back
whether it changed or not and recorded nothing, so a re-sort — any of
the sorting choices, and now switching a tab — left phones drawing the
old tabs. They update only what moves and return it, and every caller
records those as Thread changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:49:02 +02:00
Paul LütznerandClaude Opus 5.5 aa735e88a0 Settings → Categories: one page for the inbox tabs
What sorts mail into tabs and the tabs themselves were two cards under
General. They are their own page now, in the Mail group: the sorting
card, then every tab on one list in the order the inbox draws them.

- Built-in tabs carry an On/Off switch. Off releases conversations
  somebody dragged into the category and queues the re-sort the other
  sorting choices queue; on queues it too, and the mail comes back.
- The reader's own tabs keep rename and delete.
- Everything but Primary is dragged into place, or moved with buttons
  from a keyboard or a phone, as on the account list. Primary sits
  above the list: always first, never off.

The order endpoint keeps only keys the reader has, so a request cannot
plant a key naming somebody else's category.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:39:20 +02:00
Paul LütznerandClaude Opus 5.5 3c0769c1e6 Order every inbox tab, and let a built-in one be switched off
One order across the built-in tabs and the reader's own, and a set of
built-ins switched off, both beside the other sorting choices on the
user (CategorySorting). The order replaces the categories' own position
column, which never shipped: the strip is one list, so its order is too.
Primary is always first and cannot be switched off — it is where the
rest falls back to.

A switched-off category takes no mail. Whatever would have gone there —
by the header rules, Google's label or the assistant — is in Primary,
and the message panel says it would have been that category. The
answer is mapped rather than the next rule consulted: "Forums is off"
does not make a mailing list Promotions for its List-Unsubscribe.

Until the re-sort a switch queues has run, and for a conversation
somebody pinned there, Primary lists and counts the switched-off
category's conversations too, so nothing is in no tab at all.

InboxTabs is where order and on/off are applied, so the strip, the drop
targets and every key that resolves agree. A restore rewrites the
order's own-tab keys to the categories' new ids.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:34:45 +02:00
Paul LütznerandClaude Opus 5.5 52ad273359 Let a filter file Gmail mail, and refuse every built-in tab name
A filter only filed Gmail-labelled mail while the reader had asked
plMail's sorting to overrule Google's. Google's category and plMail's
rules are both guesses, and the override decides between them; a filter
is an instruction the reader wrote, and outranks both. Out of the filer,
the threader and the two recompute statements alike.

A new tab could be called "Werbung" beside the built-in Werbung: the
reserved names were the enum's English values only. Every interface
language's label is refused now.

The per-account recompute sorted every message on the installation for
each account it was asked about. It reads only that account's now —
the same rows, since a conversation belongs to one account — which
matters more once switching a tab on or off re-sorts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:25:51 +02:00
Paul LütznerandClaude Opus 5.5 e5420327ea Bring own inbox tabs up to date with main
- The migration is dated after the ones main has run since, under a name
  no open branch uses (#109 and #112 hold the earlier slots today).
- The inbox's query ceiling is one above the shared one again: main
  raised that to 28 for the sidebar's change counter, and the own-tabs
  read is still the one statement the inbox adds on top.
- French for the new strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:23:53 +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 860d0cdc45 Finish a Gmail import once it is all in, not an hour later
The listing that confirms nothing is missing waited out the same hour
that lets batches drain, so a finished import showed "Importing · 100%"
for up to an hour. When Gmail's current total is accounted for — stored,
carried into another account, or declined — that listing now goes after
ten minutes. Each check costs one getProfile, which also brings the
total up to date; the count on the line no longer runs past it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:17:52 +02:00
Paul LütznerandClaude Opus 5.5 e6d3c377b3 Count Gmail mail stored on another account as imported
With Gmail collecting mail@… and that mailbox also connected, its
messages are stored on (or merged into) the other account, carrying
this Gmail account as gmailCarrierAccount. The import line counted only
this account's rows and skipped messages, so it sat at 65,010 of 76,018
with the queue empty and an estimate of two hours left — short by
exactly what the other account held. Nothing was being fetched; the
backfill itself already treats those ids as synced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:07:31 +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
Paul Lützner 245a84facf Merge branch 'pr-110' into review-110
# Conflicts:
#	CHANGELOG.md
2026-10-14 09:49:38 +02:00
Paul LütznerandClaude Opus 5.5 fded408d31 Keep Spam and Trash without working on them
A Gmail import now takes in Spam and Trash, and everything that reads
new mail for its meaning would have read those too. Message::isSetAside()
says whether a message is filed in either, and the pipeline asks it:

  ClassifyMailStep        no language-model categorisation
  the hold                not kept out of the tabs awaiting a verdict
                          that is not coming
  ExtractInsightsStep     no parcel, invoice or other cards
  ExtractEventsStep       no events read out of it; a forged invitation
                          in Spam stays out of the calendar
  ProposeEventsStep       no proposals
  contact harvesting      Spam only: a spammer is not a correspondent,
                          and somebody whose mail was deleted still is

The three read-for-meaning steps share RecentMailPolicy::recentIds(),
which leaves set-aside mail out unless asked. ReadReceiptStep asks:
pairing a receipt with its mail reads nothing, and a receipt filtered
into Spam is still that mail's.

Unchanged: storing, threading, the rule-based categoriser, the user's
filter rules, JMAP state. It applies to every provider, not only Gmail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:16:36 +02:00
Paul LütznerandClaude Opus 5.5 cdc0ce2637 Merge an unclaimed Gmail message into another account's copy
72d3956b keeps a message for the Gmail account when nothing else claims
it. One case makes that a duplicate: Gmail collected the message from
another connected account (Gmailify or "check mail from other
accounts"), that account's own sync holds it, and none of its address
survived in the headers — a Bcc'd newsletter, say. The old rule dropped
it; the new one would have stored it a second time.

When a message reaches the Gmail account only because nobody claimed
it, the user's other accounts are asked for the same Message-ID first,
and a copy found there is enriched (gmailId, labels) as a Gmailify
sibling's is. A message whose headers name the Gmail account itself is
not looked up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:16:36 +02:00
Paul LütznerandClaude Opus 5.5 72d3956b0d Keep every Gmail message in the mailbox it is in, Spam and Trash included
`app:gmail:skipped` on a real mailbox (#90) showed what the ownership
filter was dropping: the account owner's own mail.

  202  sent from "send mail as" addresses (From: pl@pmdevelopment.de)
   89  forwarded in from an address not connected here
   43  Gmail drafts
   14  received, with no recipient header naming anyone
    4  addressed to the googlemail.com spelling of the account
    6  chats

resolveOwningAccount() kept a message only when its headers named the
account. It now keeps one for the account unless another connected
account has the better claim (a Gmailify sibling named in its headers):

  SENT or DRAFT   the sibling the From names, else this account
  received        resolveReceivedAccount()'s answer, else this account;
                  with Gmailify attribution switched off, still only mail
                  addressed to this account
  CHAT            nobody, and recorded as before

GmailAddressFilter reads googlemail.com as gmail.com.

The listing never asked for Spam and Trash although its comment said
it did: messages.list leaves both out without includeSpamTrash, and
getProfile's messagesTotal, the import's total, counts them. How much
of the 11,200 that mailbox was short of this accounts for is not known
yet: its import was still running when it was counted.

A finished import does not list again, so the migration clears the
record of declined messages (chats aside) and every Gmail account's
backfill markers. Each lists its mailbox once more, fetching only what
it does not hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:54:12 +02:00
Paul LütznerandClaude Opus 5.5 6f24bfabc3 Write down the Gmail messages an import declines, and stop fetching them again
An import sat at 21,964 of 24,199 and said "waiting" (#90). The 2,235
are messages the batch handler fetches and does not store: its headers
tie the message to no account here (a draft, a chat, mail sent from a
"send mail as" address), or Gmail lists it and answers 404.

Declining left a debug line and nothing else, and the import's only test
for "still to come" is "not stored". So:

  every hourly listing   named all 2,235 again, and each was fetched in
                         full again: 134,100 units of the 360,000 an
                         hour a 6,000 project has
  the import             could only end at BACKFILL_MAX_ATTEMPTS, a day
                         of listings later; the account in the report
                         was on attempt 18
  the progress line      could never reach Google's total, which counts
                         them

gmail_skipped_message records each one when it is declined: the reason,
its labels and its From, To, Cc and Delivered-To.

  newGmailIds()          treats a recorded id like a stored one, so a
                         listing with nothing else outstanding settles
                         at once
  an onlyMissing batch   queued by an earlier listing drops them too
  ImportProgress         counts them as done
  app:gmail:skipped      prints the record by kind, label and address;
                         --forget clears it so the next listing asks
                         again

A row goes when its message is stored after all.

Whether some of these messages ought to be stored is a separate
question and is not touched: the filter decides exactly as before. The
record is what that question can now be answered from.

Migration: one new, empty table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:49:22 +02:00
Paul LütznerandClaude Opus 5.5 b67e6dfd2b Close an IMAP conversation a worker has spent five minutes on
Both import consumers sat twenty minutes and more inside one fetch from
a server delivering a few kilobytes every twenty seconds (#95). The
thirty-second read timeout is per read and a trickle never trips it;
nothing timed the command. A consumer handles one message at a time, so
every other account's import waited behind it, and a stop signal did
nothing: a worker stops between messages.

ImapDeadline runs an alarm around every message a worker handles
(AbortStalledImap, for a received envelope only). When it goes off, the
IMAP connections opened since are shut down under their reader. The
read is at end-of-file at once, the library reports a lost connection,
and the message takes its retry ladder like any other network failure.

  not an exception   thrown from a signal handler it would land in the
  from the alarm     middle of a flush as readily as a read
  shutdown, not      webklex's logout() and destructor call
  fclose             stream_get_meta_data() on the stream and die with
                     a TypeError on a closed resource
  a total, not a     so it has to fit honest work: five minutes, which
  rate               is seconds for an ordinary page of fifty messages
                     and tight only for ~300 MB of attachments on a
                     slow line. Wrong, it shows as the class's warning
                     repeating for one folder

Checked against imap.gmail.com over TLS: after the shutdown a read
returns empty in under a millisecond with eof set.

The IDLE process holds its connection for twenty-nine minutes on
purpose and is not a worker handling a message; it is never armed.

Also the changelog for #94 and #96.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:28:53 +02:00
Paul LütznerandClaude Opus 5.5 a541feceea Take "OK, and nothing" from an IMAP server as an answer, not a failure
webklex raises a FETCH that completed with no FETCH lines as a
ResponseException ("Empty response"): its Response counts as successful
only with data, or for the few commands marked as allowed to be empty.
Two places took that for the server not answering.

The import (#96). A UID the server lists in SEARCH and returns nothing
for in FETCH - iCloud, most likely a draft deleted and not expunged -
ends up alone on the last page. The fetch threw before the page's
attempts were counted, so MAX_PAGE_ATTEMPTS never applied: 486 of 487
for good, retried every few minutes, and the account's other folders
waiting behind it. It is now a page that held something back, counted
and passed over after five tries like a message that will not store.

The folder listing. `UID FETCH 1:* (FLAGS)` on a folder with nothing in
it is this same answer, and read() returned null for it. The folder was
never swept, so mail deleted from it elsewhere stayed here, and an
unswept folder withholds reaping from its whole account. It returns the
empty listing now; whether to believe one is still the sweep's bulk
guard.

Only this failure. ImapEmptyAnswer tells it from a dropped connection
(webklex's RuntimeException, never a Response) and from NO/BAD
(ImapServerErrorException), which say nothing about the messages: an
hour's outage must not pass over a page of mail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:16:50 +02:00
Paul LütznerandClaude Opus 5.5 6c018ded6d Reconnect an IDLE the server closed, instead of spinning on it
The library says "empty response" for a read that timed out and for a
connection the server has closed, and the IDLE loop took both for a
quiet mailbox. On a closed connection every read returns empty at once,
so the loop never blocked again: a core per folder until the 29-minute
re-issue. Lark and QQ close idle sessions within minutes, so for them
this was every session (#94: 85 minutes of CPU in three hours for one
inbox, against half a second for a Gmail one).

connectionEnded() tells them apart by the stream being at end-of-file,
or by the read having come back in under a second when a quiet one
blocks for thirty — a TLS stream does not always report end-of-file.

A connection that lived a minute or more is reconnected at once and
counts as no failure. One lost sooner is thrown to the retry ladder, so
a server that accepts IDLE and hangs up cannot become a reconnect loop
in place of a read loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:16:50 +02:00
Paul LütznerandClaude Opus 5.5 6eaa33a534 Tell the sidebar whenever mail changes, instead of when a button remembers to
The badges are patched from /mail/sidebar/counts, and what made a tab
ask was a list: the sync's hub events, and announceWrite() called by
each controller that wrote. Whatever was not on the list had a stale
badge. Measured in a browser (#86): a draft autosaved, the server
counted 1, the badge stayed at 0, and in forty seconds the page made no
counts request at all. The composer saves through a Turbo form on a
timer and had no controller to call from; the send that follows is a
worker's, ten seconds later, and a mail read on a phone is another
client's. No browser code can announce those.

Every change to a user's mail already passes one place, StateManager,
because a phone's /changes has to be complete. That is now what tells
the browser too:

  X-Mail-Changed: 1    on the response to any request that recorded a
                       change (MailChangedHeaderSubscriber). mail_writes.js
                       reads it off every fetch and every Turbo request
                       and announces the write
  mail.changed         on the hub, from the drain that already tells the
                       phones (MailChangeNotifier), for a change this tab
                       did not make
  state:sequence       the change sequence the counts were read at, in
                       the counts answer and on the rendered sidebar. A
                       tab ignores a nudge that is not newer

The sequence is what keeps it to one request per change. The hub's
nudge for a tab's own write was measured arriving before that tab's
counts request had left, so the comparison waits 400 ms and for
whatever is on the wire. announceWrite() gathers calls for 120 ms, since
a write is now announced by the header and, where one exists, by its
controller.

Opening a conversation marked it read every time, read or not: seenAt
was replaced, the provider told again and a JMAP change recorded. With
the header that became a counts request behind every click on read mail.
ThreadStatusUpdater::markRead() now writes only the messages that
change, keeps the first seenAt, and records nothing when nothing moved.

One query more on a page that draws a sidebar (the sequence); none on a
list-frame navigation.

Not exercised end to end here: the worker's own send. The test stack's
send transport is in-memory, so the browser test for "a change made
elsewhere" uses a second tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:58:38 +02:00
Paul LütznerandClaude Opus 5.5 be2e06ff59 Name the reader "me" in the list for a reply sent from a catch-all address
ThreadParticipants knew the reader by Account::$ownedAddresses, and an
address at a catch-all domain is in no list. A reply sent from
yo@shop.test put the account's name in the participants column as though
a third person had joined.

Not every From at the domain, for the reason OwnAddressHelper gives:
somebody else may have a mailbox there. It is the reader's when the
message is filed under Sent or Drafts.

The list page reads its rows as fields, not entities, so that fact is
one more column of the same statement (a count of the message's Sent and
Drafts labels) and no extra query. For loaded messages it is only asked
of a From the account catches, since asking walks the message's labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:58:38 +02:00
Paul LütznerandClaude Opus 5.5 363f7d8204 Split a merged event's flat month bar side by side, not top to bottom
A meeting on two calendars is drawn as one chip whose mark is split
between their colours. The split was always top to bottom, which suits
the mark standing upright beside a title. A month cell too narrow for
words lays the mark flat (the @max-[5.5rem] classes), and there the two
colours were stacked hairlines in a bar six pixels tall.

The gradient's direction is now a custom property, so the container
query that lays the bar flat turns the split with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:51:17 +02:00
Paul LütznerandClaude Opus 5.5 439b1196b7 Keep the sender on a draft an app saves
RFC 8621 names the identity on the submission alone, and JmapDraftWriter
stored every draft from the account's address. A reply an app autosaved
from an alias therefore had no sender of its own: reopened from Drafts,
on that phone or another, it came back on the primary address, and
replyFrom on it could only report the account.

Email/set now takes identityId on a draft, on a create and in an update
patch. It is resolved through IdentityResolver::addressFor(), the
submission's resolver, so an address identity for a catch-all address
works and there is still one answer to what an account may send as.
fromAddress and fromName are written from it.

  an id that does not resolve   ignored, and the rest of the save goes
                                through. The patch is an autosave; the
                                submission names its identity again and
                                is where forbiddenFrom is answered
  a message that is not a draft invalidPatch, as for any draft property
  draftIdentity: true           in the account's mail capability. A
                                server from before this refuses a patch
                                naming the property, so a client has to
                                know before it sends one

Clients that send no identityId get what they always got.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:12:31 +02:00
Paul LütznerandClaude Opus 5.5 cce57f1f75 Say in the changelog that a catch-all address answers invitations and is not the whole domain
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:59:36 +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 f954c4d69d Tell a JMAP client which identity answers a mail, and let it send as a catch-all address
The browser opens a reply on the address the mail was sent to (#41). The
phone apps build their replies themselves and pick a sender from
Identity/get, so they got none of it: every reply opened on the primary
address, and a catch-all address could not be sent as at all, having no
identity and EmailSubmission/set refusing any id it did not publish.

  Email/get replyFrom        the Identity that answers this Email, or null.
                             ReplyFromResolver picks the address, the same
                             call the web composer makes, so no client
                             carries a copy of the rule. Only returned when
                             named in `properties`: it carries a signature,
                             and an app written before it asks for
                             everything
  IdentityResolver           forAddress() gives the listed identity for an
                             alias, and for an address at a catch-all
                             domain an address identity, id `addr-` plus
                             the base64url of the address. addressFor()
                             decodes such an id and asks
                             Account::maySendAs() on every submission, so
                             the id is a spelling and not a grant: one for
                             a domain the account does not catch is
                             forbiddenFrom like any unknown id
  EmailSubmission/get        reports that id for mail sent that way

Identity/get is unchanged, and so is everything an installed app sends
or reads. `*@domain` as an identity (RFC 8621 §6) would have shown up in
their From pickers as an address; a writable `from` on Email/set would
have been ignored or refused by an older server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:50:37 +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
Paul LütznerandClaude Opus 5.5 780ed01de9 Release v0.3.4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:44:52 +02:00
Paul LütznerandClaude Opus 5.5 fdb582572f Stop a queue-monitor test from failing when the clock has a 99 in it
testTheFilterReachesTheClassAndThePayload filters the queue for "99" to
find the SyncAccountMessage for account 99. The filter searches the
serialised body, and the body carries requestedAt — the Unix time the
test ran. Whenever that number contained "99" the other message matched
too, and the test found two where it expects one. It did on main's CI
run of 2026-10-13 09:16 UTC.

Both messages are given a fixed requestedAt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:31:43 +02:00
Paul LütznerandClaude Opus 5.5 748f0f302f Count a Gmail message fetched in full at 60 quota units, as Google bills it
Google's quota table gives messages.get one cost, 20. An import paced on
that was refused at a third of its limit with no other call being made,
so app:gmail:quota-probe fetched thirty messages each way in a minute of
their own and the project's quota meter was read afterwards (#90):

  format=full, batched        1,800 units   60 each
  format=full, one at a time  1,800         60
  format=raw                  1,800         60
  format=metadata               600         20
  format=minimal                600         20

The table's 20 is a message without its body. The import's own traffic
agrees: 96 fetches a minute beside 5,800 units on the meter.

  the cost     60 a message, so the pace starts where Google's count is
               and is not found by being refused
  requests     five messages each, three seconds' worth of a 6,000 limit
  the share    85% of the limit, where 60% of a count three times too
               low had been nearly twice the limit
  the probe    sets the meter's reading beside the table's 20, not
               beside whatever the pacer assumes

On a project allowed 6,000 units that is 85 messages a minute, and the
time left on the import line is reckoned from it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:55:27 +02:00
Paul LütznerandClaude Opus 5.5 ec6199ca74 Let the quota probe say which Gmail accounts there are
It asked for an account id, which nobody knows and the web interface
does not show. The argument now takes an id or an address, and without
one the command lists the active Gmail accounts and asks which — or, run
without a terminal to ask on, prints the list and stops.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:12:02 +02:00
Paul LütznerandClaude Opus 5.5 350c71a9dd Add app:gmail:quota-probe, to read off what Google charges for a fetch
Google's table says a messages.get is 20 quota units; its meter charged
about 59 for each of an import's (#90). The traffic shows no other call
to blame, and Google reports no cost per request, only the minute's
total in the Cloud console. So this makes a minute's total mean
something: a fixed number of stored messages fetched one way — batched
as an import does it, or singly in each format — started just after a
clock minute begins and followed by an empty one. The chart's value for
that minute over the number of requests is the cost of one.

The account's bulk fetching is held while it runs, by a new
GmailQuotaPacer::hold(): the wait a refusal causes without what a
refusal means, so no limit is learnt and no pace is cut. Nothing is
stored and nothing in the mailbox is changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:03:13 +02:00
Paul LütznerandClaude Opus 5.5 ea52678a0b Do not fetch again what an import's earlier listing already brought in
An unfinished Gmail import lists the mailbox again every hour and queues
whatever is still missing. On a mailbox that takes most of a day those
ids were already on the queue from the listing before, and the batch
handler fetches everything it is handed — it has to, for a sync's batch,
which names stored ids because their labels changed. So each hour put
another full copy of the outstanding mail on the queue to be fetched in
full again, on the quota the import was short of (#90).

A listing's batch is now marked onlyMissing and asks which of its ids
are stored before it fetches; a batch of ids that have all arrived costs
one lookup. A sync's batch is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:57:52 +02:00
Paul LütznerandClaude Opus 5.5 73ee0525b9 Update the open background-work list where it stands, so it does not flash
Re-reading the indicator replaced the whole frame, dropdown included,
and opened the new dropdown again. Now that an import re-reads it every
five seconds, somebody watching saw the list go away and come back for
as long as they looked.

With the list open the new rows are fetched and put into the list that
is already there, and the button's label and icon are changed only where
they differ. The frame is still replaced when the list is shut, and when
there is nothing left to show.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:57:52 +02:00
Paul LütznerandClaude Opus 5.5 a0c45f9547 Slow an account down when Google refuses it at a limit already known
An import paced to a learnt 6,000 units a minute had counted 3,240 of
them when Google refused it (#90). The count is this application's and
not Google's: another client on the same quota, a cost reckoned
differently, a limit not enforced as a plain sum over the minute. Which
of those it was is not known.

So a refusal that teaches nothing about the limit is taken as "slower
than this", whatever the count said. The account's pace drops by a
quarter, never below a fifth of its allowance, and wins back two
hundredths of it for every minute without another refusal. Refusals
that pile up inside one pause count once. The time-left estimate
follows the pace.

Each refusal is logged once at info as "Gmail quota: refused", with the
limit name and value Google sent, what had been counted, and the pace
it left — the figures needed to find out why the two counts differ.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:42:24 +02:00
Paul LütznerandClaude Opus 5.5 0235d9f4d3 Re-read the import line on a timer, so its figures move while watched
The indicator is re-read when the server publishes, and an import
publishes when a page of its listing is done. A Gmail import spends most
of its time fetching what the listing named and says nothing meanwhile,
so the count, the messages a minute and the seconds until Google lets it
continue all stood still.

While the indicator shows an import it is now re-read every five seconds
with the list open and every thirty with it shut; never in a hidden tab,
and never when nothing is importing.

The import line's own test spent more than the pacer's reserve and was
made to wait eight seconds for it; it spends less.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:29:51 +02:00
Paul LütznerandClaude Opus 5.5 d06125bbf4 Show how many messages a minute a Gmail import is bringing in
The import line had the quota and the time left, both in units and
minutes. What somebody watching wants to see move is mail: a line with
roughly how many messages were fetched in the minute just gone, worked
out from what the pacer counted. Not shown while Google has the account
paused, when none are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:20:04 +02:00
Paul LütznerandClaude Opus 5.5 95f3291911 Fetch Gmail messages ten to a request, not a minute's worth in a second
On a project allowed 6,000 units a minute an import fetched one batch,
was refused, waited a minute and was refused on the next batch: the
indicator read "paused" beside "about 1,005 of 6,000 units". Fifty
messages in one batch request are 1,000 units in one second, and Google
does not let a per-minute limit be spent that way (#90).

  requests     ten messages each, the pacer asked before every one;
               a refused request stops the rest of its batch, which is
               handed back unfetched
  the reserve  five seconds of allowance instead of twenty, so the
               pacer no longer lets a burst through that Google will
               refuse
  the line     while paused it shows what had been counted when Google
               refused, not a running count that has fallen to nothing

That a burst is what Google refused is inferred from the figures, not
documented.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:12:21 +02:00
Paul LütznerandClaude Opus 5.5 9286c351fc Put any job Gmail refuses on quota back quietly, not only the import's
b44283cb made a quota refusal "not now" for a batch, an import page and
a sync. Everything else that calls Gmail still threw it to Messenger: a
label change pushed while an import had the quota was retried with a
WARNING and two stack traces per attempt (#90).

GiveUpQuietlyOnThrottling now takes a Gmail quota refusal on the first
attempt, for every message alike: the same message goes back onto the
queue it came from, delayed until the account's minute is over, with one
line at info. The job's own retries are not spent on it. After sixty
such waits in a row the refusal is a failure again and goes to the
retry ladder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:03:57 +02:00
Paul LütznerandClaude Opus 5.5 b44283cb1c Learn Gmail's quota from Google's refusal instead of assuming 15,000
Google lowered the per-user limit to 6,000 units a minute for projects
created since May 2026 and raised a messages.get from 5 units to 20.
The pacer had 15,000 and 5 written into it, so what it took for 9,000
units a minute was 36,000: most of what an import asked for was
refused, the log filled with retries, and a refused listing ended the
import's chain at CRITICAL (#90).

  the limit      starts at 15,000 and is read out of the first 403,
                 which names the limit and its value; forgotten after
                 a day so a raised one is found again. Nothing to set.
  the cost       20 a message, and fifty messages a batch
  shared         spend, refusal and limit are in a database-backed
                 cache pool, so worker-backlog, worker-ingest and the
                 web container draw on one allowance
  a refusal      is "not now", not an error: a batch, an import page
                 or a sync that meets one is put back until the
                 account's minute is over, and a refused sync still
                 restarts an import that has gone quiet

The import line in the background-work indicator shows the limit,
roughly what is in use of it, how long the rest takes, and says so
when Google has paused the account.

No migration: the pool is a key prefix in cache_items.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 08:30:17 +02:00
Paul LütznerandClaude Opus 5.5 1e582bcedb Store a message whose id, body or attachment carries a raw 8-bit byte
Postgres refuses a byte that is not UTF-8, and six places still handed
it one. The message was held back, asked for on each of the next five
syncs and then let go, so it never arrived (#81).

  body, text and html    webklex skips convertEncoding() for a part
                         labelled us-ascii, so the decoder that guards
                         every other body was never asked
  Message-ID,            never decoded by anything; repaired in
  In-Reply-To,           MessageIdHelper, which all three go through, so
  References             a reply still finds what it answers
  attachment type, id    the same, in persistAttachment()

All of them go past CharsetHelper::ensureUtf8() now, as a subject and a
file name already did. BatchStorageTest stores one message per case
through the real batch path; each fails with its guard taken out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:26:25 +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 d6cd145951 Fill the placeholders in a failed model host test
A failed Test or Warm up in Admin → AI names its reason by key and hands
the details to the translation as parameters. The parameters were keyed
`status` and `error`, the translations say `%status%` and `%error%`, and
Symfony replaces by exact key. It found the word inside the placeholder and
left the percent signs standing: "HTTP %403%".

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:38:21 +02:00
Paul LütznerandClaude Opus 5.5 23e288edc4 Release v0.3.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:05:20 +02:00
Paul LütznerandClaude Opus 5.5 493ac0dbcc Only use the bundled emoji font where the browser can draw it
The emoji picker was almost empty on an iPhone and in Safari: two of nine
category tabs, a handful of emoji scattered through a blank grid. The
compose body names the same family, so emoji typed there were at risk of
the same (#39).

Noto Color Emoji is vendored as COLRv1, which WebKit does not draw. Safari
loaded the font, found the code point in it and painted nothing, and a
browser does not fall through to the next family for a glyph it believes
it has.

The faces are now declared inside `@supports font-tech(color-COLRv1)`.
Where that is false the two families do not exist, and the lists that name
them fall through to the system's emoji.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:44:28 +02:00
Paul LütznerandClaude Opus 5.5 e6ee3588ec Give the recurring calendar cases a time of day of their own
The editor offers the next full hour, so the recurring cases tested a
different event depending on when the suite ran. Late in the evening in
October the series sat at 00:00 and "moving all events" put it at 02:00 to
03:00. The agenda those cases count chips in is thirty days long and
reached the end of daylight saving, where that hour happens twice: one
occurrence read "2:00 – 2:00" among the rest reading "2:00 – 3:00", and a
case asserting that every chip matches failed on every run until midnight.

The event is the right one, an hour long; it was the test that depended on
the clock. New events in these cases now start at noon, an hour no zone
changes its clock in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:09:36 +02:00
Paul LütznerandClaude Opus 5.5 11edd7a8f1 Keep the README screenshots off the regression suite's users
screenshots.spec.ts seeded the demo mailbox onto the shared per-worker
fixture user, and onto the shared admin for its last picture. The seed does
not add a mailbox, it replaces what the user has: the calendar is wiped and
rewritten, the address book goes, other accounts and custom labels are
removed. Nothing cleaned up afterwards, and the one command the docblock
offered removed the account and neither its labels nor its events.

A retake on the 9th failed live-counts, unread-view, bulk-select-all and
calendar-narrow-week on the 10th — an inbox badge one too high, a demo
birthday as the first all-day chip — and because the demo is dated relative
to the day it is seeded, the failures moved with the clock.

The spec now signs in as two users of its own, TEST_DEMO and
TEST_DEMO_ADMIN, which no other spec knows about. A cleanup in afterAll was
the alternative and could not work: what the seed deletes from a fixture
user is not there to put back.

app:test:seed-demo also refuses a user who already owns a mailbox, before
it writes anything, so the next caller cannot repeat this. It no longer
removes a user's other accounts, which was only ever needed because it was
pointed at somebody else's user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:05:23 +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
Paul LütznerandClaude Opus 5.5 bfc2a022e9 Stop one unreadable message from holding its whole folder
A multipart message whose header reads `boundary = "…"`, with spaces
around the sign, is legal under RFC 2045 and was unreadable: the
library's pattern wants the sign hard against the word, found no
boundary, and threw "no content found". That is thrown while a page of
mail is being fetched, so it took the page and every page after it,
the mark never moved, and the folder failed at the same message on
every poll until somebody deleted it on the server (#45).

Two changes, because the header is one way to be unreadable and there
will be others.

  Boundary  ImapConnectionFactory configures a pattern that allows
            whitespace on both sides of the sign, takes a quoted value
            whole and ends an unquoted one at a semicolon or a space.
            The library's pattern with \s* added would not do: it
            keeps the space before a following semicolon, and the
            boundary then matches no line.
  Fetch     ImapPagedFetch replaces the library's chunked(). Soft-fail
            is on, and the pages are counted from the search rather
            than from what was handed over — chunked() counts the
            latter, so with one message dropped it asks for empty
            pages without end. What each page dropped is handed to
            processBatch() beside it and held like any message that
            would not store: five syncs, then skipped and logged.

The page is walked in UID order with the unreadable messages standing
where they would have stood. holdForRetry() counts the lowest failure
of a run by the order it is told of them, and in any other order two
such messages would take the counter from each other for good.

ImapConnectionFactory::config() is split out of connect() so a test can
parse a message the way a sync does. One test pins that the library's
own pattern still throws on the report's fixture, so the day it is
fixed upstream is the day that test says so.

Fixes #45

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

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

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

Refs #36

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

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

Refs #36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:34:45 +02:00
Paul LütznerandClaude Opus 5.5 7c0cf33dfd Answer the a shortcut on a message with one recipient
Reported from the demo: r and f worked in an open conversation and a
did not. The Reply-all link is only drawn for a message with more
than one recipient, and a shortcut presses the control that is there,
so on every other message a found nothing and did nothing. It falls
back to Reply, as Gmail does.

Every test fixture has a single recipient, which is why the specs for
#36 never met a Reply-all link either way.

Refs #36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:30:12 +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 da5a0bc9ee Carry category sorting in the config backup
User::$categorySorting (source, overrideProvider, tabs) was never written
into a config backup, so a restore put everyone back on rules, the
provider's categories and a tabbed inbox. It now travels through
toArray()/applyArray() like Appearance and AiPreferences do. A backup
without the key restores the defaults; an unknown source falls back to
the rules without losing the two switches beside it.

ConfigBackupCompletenessTest now fails for any #[ORM\Embedded] on User
that the export does not carry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:20:55 +02:00
Paul LütznerandClaude Opus 5.5 0056fc0981 Try the Tailwind build three times in the test stack
The first build on a fresh tree downloads the Tailwind binary from a
GitHub release. On the v0.3.2 tag GitHub answered that download with
a 500 in three of five Playwright jobs, app-init exited 255, and the
E2E run failed before any spec had run. The same code had passed on
main twenty minutes earlier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:08:23 +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 9fdaeb90b5 Release v0.3.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:36:24 +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 f44ef134bf Enable the sponsor button
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:11:10 +02:00