803 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 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
ea70139077 Send In-Reply-To and References on a reply (#227)
* Send In-Reply-To and References on a reply

buildEmail() never wrote them, so every reply from the web composer or
JMAP reached the recipient as a new conversation. References ends with
the parent even when the draft's list lacks it (RFC 5322 3.6.4).

Closes #206

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

* References ends with the parent even when the draft listed it earlier

normaliseList() keeps the first of a repeated id, so a draft whose
References already named the parent somewhere other than last (a JMAP
client's own order) sent a chain that did not end with it. The parent is
now taken out of the list before it is appended.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Paul Lützner <karatektus@gmail.com>
2026-10-20 10:49:20 +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 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
e1200fdd6c Mail the AI host could not be reached for is asked again by the catch-up (#231)
The categorisation stamp was written even when the call never reached a model, so a short outage left every message in flight without a category for good. A timeout, an unreachable host or a refusal by 401, 403, 408, 429 or a 5xx now leaves the message unstamped for the catch-up; an answer, a 404 and a refusal of the request itself (400, 413, 422) are stamped as before. Closes #222.

Co-authored-by: Philippe Delodder <philippe@delodder.be>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 07:42:34 +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
50c842cc9d Reconnecting an integration edits it, and a taken name is a form error (#217)
The setup wizard built a new connection every time its Integrations step was saved, so saving it again collided with the unique key on (user, provider, name) and answered with a 500. It now edits the oldest existing connection for that service. The settings page had the same 500 for a second connection that kept the default name; a UniqueEntity constraint now reports it on the name field. Closes #216.

Co-authored-by: Philippe Delodder <philippe@delodder.be>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:35:50 +02:00
7fb2c8f38c Inline images in a message load through a signed link (#218)
The message body renders in a sandbox that sends no session cookie, so every cid: image was a 302 to the login page. They now load through /mail/inline/{id}, signed with an expiry of one to two days, for the message's own image parts only; parts labelled octet-stream are served when their bytes are an image. Closes #213.

Co-authored-by: Philippe Delodder <philippe@delodder.be>
Co-authored-by: MontyWei <62734446+montywei@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:03:04 +02:00
MontyWeiandMontyWei 57f92f1170 Serve inline images labelled octet-stream by what their bytes are (#220)
Parts labelled application/octet-stream or image/octet-stream are identified from their bytes; only a raster type on the allow-list can come out, so SVG and HTML stay downloads. Part of #213.

Co-authored-by: MontyWei <62734446+montywei@users.noreply.github.com>
2026-10-19 13:51:42 +02:00
b453b79eb4 Single sign-on only: hide the password form and refuse password sign-ins (#210)
* Single sign-on only: hide the password form and refuse password sign-ins

Adds Admin -> SSO 'Single sign-on only' (and OIDC_DISABLE_PASSWORD_LOGIN).
The login page drops the password form and LoginFormAuthenticator refuses a
password sign-in before looking the user up. It only applies while a whole
provider is in force, the saving administrator must already be linked to the
provider, and app:sso:allow-password-login brings the form back from the
console. Config restore leaves it off. Off by default; one boolean column on
oidc_config.

Closes #169

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

* Single sign-on only: require a link to the provider being saved

The admin form let the box be ticked by an administrator linked to any
provider, including the one the page held before. Switching providers and
ticking it in one save made a provider they had never used the only way in.
It now compares the stored issuer with the one on the page, without a
trailing slash, as OidcDiscovery does.

Tests for what had none: the console command (clears the setting, keeps the
provider), a restore over a row that had it on, the link to another
provider, and a control showing the same password signs in with the setting
off. The "right credentials" test now posts the right password.

Docs: there is no iOS app.

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

* Docs: put the iOS app back in the single sign-on page

The previous commit dropped it on the wrong assumption that there is no
iOS app. There is one.

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

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: Paul Lützner <karatektus@gmail.com>
2026-10-19 13:09:12 +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
6292ce2cda Bump @playwright/test from 1.63.0 to 1.64.0 in the playwright group across 1 directory (#190)
* Bump @playwright/test

Bumps the playwright group with 1 update in the / directory: [@playwright/test](https://github.com/microsoft/playwright).


Updates `@playwright/test` from 1.63.0 to 1.64.0
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.63.0...v1.64.0)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-version: 1.64.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: playwright
...

Signed-off-by: dependabot[bot] <support@github.com>

* E2E: abort the stubbed reconnect navigation instead of answering 204

Chromium 156 (Playwright 1.64) reports a navigation that ends in a 204 as a
finished request that commits nothing, where Chromium 155 reported it as
failed (net::ERR_ABORTED). Playwright's click waits for the navigation it
started to commit or fail, so with 1.64 the click never returned and the
test timed out reading "" off a closing page. An abort is reported as a
failure by every Chromium and keeps the document just the same.

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

* E2E: touch beside the open drawer, after it has stopped sliding

The drawer-open pull touched the middle of the rows (x~196) right after
`inert` came off, at the start of the drawer's 200ms slide. Once the 256px
drawer has arrived, that point is the drawer, not the backdrop, so the
hit-test only passed while it beat the transition; slower CI runners lost
that race on every branch from 2026-10-16. Wait for the drawer to rest,
then touch the backdrop strip to its right, and pull at the same point
once it is closed.

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Paul Lützner <karatektus@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:54:34 +02:00
fe800ebbf9 Compose: allow a send addressed to Bcc only (#200)
A send with nobody in To was refused with "Add at least one recipient before sending", even with addresses in Cc or Bcc. The window's guard counted To, Cc and Bcc; the server's Count(min: 1) rule in the send group counted To only.

The rule is now a Callback on the To field that counts all three, read off the bound form since the fields are unmapped. A send with no recipient at all is still refused; a draft may still be saved with none.

Closes #201.

Co-Authored-By: jo2904 <josephdegaullier@gmx.fr>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 00:30:15 +02:00
Philippe Delodder cbe14ea5ae Drop a header block the server repeats at the top of the body (#177)
Closes #173.

Co-authored-by: Philippe Delodder <7259755+phdelodder@users.noreply.github.com>
2026-10-16 18:48:07 +02:00
Philippe Delodder dda54a3876 Outlook's <![endif]--> no longer leaks into a message as text (#178)
Closes #175.

Co-authored-by: Philippe Delodder <7259755+phdelodder@users.noreply.github.com>
2026-10-16 18:35:03 +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 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
7a5a7350bf A conversation no longer splits when its replies are synced first (#176)
Threading looked only from a reply to the message it answers, so replies synced before that message (INBOX before Sent Items) opened threads of their own and stayed there. MessageThreader now also looks the other way: after a message gets its thread, mail that already names its Message-ID in In-Reply-To or References is folded in by the new ThreadMerger. The older thread survives, keeping its JMAP id, summary, insights, star, snooze, labels and pinned tab. A GIN expression index makes the reverse lookup an index probe.

Review follow-up: moved mail is added to the surviving thread's collection so a label rule later in the same batch keeps its labels, plus tests for that, bracketed legacy references and provider-keyed survivors.

Closes #172

Co-authored-by: Philippe Delodder <philippe@delodder.be>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:11:37 +02:00
7c9bf694ff Store a UIDVALIDITY above 2147483647 (#174)
mailbox.uid_validity becomes a BIGINT, so IMAP folders whose UIDVALIDITY is above the signed INT range sync again. Reversible migration; the mapping stays ?int (DBAL 4 hydrates a bigint as an int).

Closes #171

Co-Authored-By: Philippe Delodder <philippe@delodder.be>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:49:28 +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
Svenandpmdevelopment 55a6d67a48 Give the PHP time limit back after every test (#89)
A streaming controller's set_time_limit() outlived its test in the single PHPUnit process and killed slower runs part-way. A PHPUnit extension resets it after every test.

Co-authored-by: pmdevelopment <4947485+pmdevelopment@users.noreply.github.com>
2026-10-15 14:49:28 +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
4738d514dd Hear mail read, unread, starred or unstarred in Outlook (#114)
Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:33:17 +02:00
31b4943a3f Tell the open list when Outlook changed, moved or deleted mail (#122)
Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:11:44 +02:00
0f817b7b0c A message moved in another app leaves its old folder on the thread too (#121)
Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:58:41 +02:00
9fb03587ee Push the read state and star the user chose to Outlook (#111)
Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:58:36 +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
19266223e8 Choose a profile picture through the compose file picker (#65)
Settings → Profile opens the same file picker as attaching to a mail, in an avatar mode: albums, people, search and paging, one picture at a time, files that cannot be a picture greyed out. The choice is posted straight back to the profile form, and the connection is matched against the user's own. From Paperless-ngx the original is taken rather than the archived PDF. The setup wizard keeps its small grid for now.

Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:38:44 +02:00
3d7a25c1e6 Save to Paperless-ngx: tags are choices, and the upload goes through (#78)
The Save to… chooser drew Paperless tags as folders to open, and clicking one did nothing. A DestinationKind (folder, album, tag) now decides how the chooser is drawn: a tag is the choice itself, with a No tag row. The upload is sent as a string with a Content-Length instead of streamed, which Paperless read as an empty form ("No file was submitted", #79).

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

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

No migration.

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

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

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

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

Closes #124.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:00:44 +02:00
77251f5038 fix(security): remember a single sign-on sign-in (#116)
OidcAuthenticator issued no RememberMeBadge, so an OIDC login rested on the
PHP session alone: a browser-session cookie, a file cleaned up after the 24
minute session.gc_maxlifetime, and lost with the container on every deploy.
The badge now carries the firewall's remember-me parameter, because the
callback has no form to tick it on; always_remember_me would instead make the
password form's unticked checkbox meaningless. The token is stored in the
database and a remember-me cookie is still challenged by two-factor.

Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 16:39:04 +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 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