Commit Graph
49 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 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 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
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 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
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 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ü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
09c503c61c Sign in through an OpenID Connect provider (Authentik, Keycloak…) (#106)
plMail could only sign people in with a local password. On an install that
already runs Authentik or Keycloak, that is one more account and password
per person, outside the provider's access policy.

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

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

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

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

Closes #91

Co-Authored-By: Paul Lützner <mail@pluetzner.de>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:17:00 +02:00
4d705a56da Keep remember-me tokens on the server, so signing out holds (#104)
* Keep remember-me tokens on the server, so signing out holds

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

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

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

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

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

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

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

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

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

---------

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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 14:32:15 +02:00
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 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 3e0b4456cf Say what the default TRUSTED_PROXIES trusts, and where to report a hole
#34 points out that the default trusts every private range, so a
machine on the LAN that reaches plMail directly can forge
X-Forwarded-For and step around the per-address limits on the login
form and the booking page.

The default stays. The proxy's own address cannot be known here, and
a proxy missing from the list breaks OAuth sign-in, shares one login
allowance between everybody and strips `secure` off cookies, with
nothing saying why — a worse failure, on every proxied install, than
the one being closed. What was missing is the sentence: the comment
in .env and the reverse-proxy page now say that the default is wide,
what that costs, what it does not (the per-account login limit
ignores the address and still holds), and how to narrow it once the
proxy works.

SECURITY.md is new. The report was filed publicly because there was
nowhere private to send it.

Refs #34

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:03:51 +02:00
Paul LütznerandClaude Opus 5.5 a8aea33874 Open a fresh install from the LAN, not only from the machine it runs on
The stock compose file served https://localhost and nothing else. Reached
by address or hostname, Caddy had no certificate to offer and ended the
handshake, which a browser reports as an SSL protocol error with no way
to proceed. A headless server could not be set up without first setting
SERVER_NAME.

The default now answers every name and address with the self-signed
localhost certificate (default_sni/fallback_sni, and `:443` in the default
SERVER_NAME), so the browser shows its usual warning.

Live updates follow: compose.yaml's https://localhost hub address is
treated as the placeholder it is and gives way to the public address
stored during setup. Any other MERCURE_PUBLIC_URL, and any SERVER_NAME an
operator sets, is left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:58:54 +02:00
Paul LütznerandClaude Opus 5.5 808b2bff53 Give a demo mailbox templates to pick from
A visitor who opened Settings → Templates on a fresh demo found one
empty folder and a button, and the compose window's picker said there
were none. Both true, neither showing what the feature does. The
provisioner now seeds six: a quote follow-up, an invoice reminder, a
payment receipt, a meeting follow-up, an out-of-office and a short
acknowledgement, filed at the top level, under the demo account and in
a folder inside it. Between them they use every variable, and the date
one with offsets in both directions and three formats.

app:test:seed-demo seeds the same six, for the reason it seeds the
same mailbox: a screenshot of the picker should be a picture of the
demo.

They are strings with tokens in them, which nothing checks — a
misspelt variable is braces left in the text — so DemoTemplatesTest
renders every one for a recipient and fails on an open variable or a
brace. It also erases a visitor with nested folders, since the eraser
removes rows one by one and folders point at each other.

The demo account is not given a signature. The signature variable is
in two of the templates and renders as nothing until the visitor
writes one, rather than a signature appearing in every new message
and in the README's compose screenshots to make one variable busier.

One layout bug fell out of looking at it. A long folder path —
"you@example.com / Quotes and invoices" — pushed the editor's Folder
dropdown out past the card's edge; the control may now shrink and the
path is cut with an ellipsis.

Refs #26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:47:02 +02:00
Paul LütznerandClaude Opus 5.5 ec938bca99 Count the sessions a public demo hands out, without keeping an address
Admin → Demo visitors, in demo mode only: sessions started in the last
hour, 24 hours, 7 days and 30 days, the different visitors behind them,
the total and how many have a mailbox now. The users cannot be counted
because the reaper deletes them, so /demo writes a row of its own.

That row holds a keyed hash of the address with its last part cut off
(a /24 or a /48), never the address, and the reaper removes the hash
after 30 days. The demo's privacy notice describes it.

Outside demo mode the section is not in the nav, its route answers 404
and nothing is recorded; the table exists and stays empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:23:27 +02:00
Paul LütznerandClaude Opus 5.5 9c4cebf8b9 Stop rewriting the cache directory at every start
Starting plMail took over two minutes on a NAS with spinning disks, and
the log was silent for all of it — between "Statement statistics are
enabled" and the web server's first line. An empty install on the same
box, from the same image, was up in seconds.

The entrypoint ended with two `setfacl -R` passes over var/cache and
var/log. var/cache is part of the image: about 2,200 files. Changing
anything about an image file from inside a container, an ACL included,
makes the storage driver copy the whole file into the container's layer
and flush it. So every new container rewrote its cache one synchronous
file at a time, in every container of the stack at once, on one pool.

Measured in a fresh container on an SSD: 1.2 s for the first pass,
0.03 s for the second. On the warm production container, 0.06 s — which
is why timing it there showed nothing.

It ran only as root, so the catalogue install (uid 568) never paid it.
Nothing needed the entries: nothing runs as www-data, root needs no
grant, and the Dockerfile already makes both directories writable.

This is the third time that block has been the answer to a slow start.
It walked all of var/, then var/cache and var/log, and now nothing.

Also, on the compose files here: the database health check had no
interval, so Docker's first probe came thirty seconds after start and
the web server and workers could not begin before it. Five seconds now,
with retries raised so a database in trouble is called unhealthy after
the same two and a half minutes as before.

The README sends TrueNAS users to the catalogue app instead of walking
them through truenas.compose.yaml. The file stays: installs made from
it are still running.

Not confirmed on the box that reported it; the copy-up cost is measured
locally and inferred there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:07:40 +02:00
Paul LütznerandClaude Opus 5.5 88b1744385 Leave the ingest worker to fetch mail, and nothing else
Adding an account put new mail behind hours of follow-up work. What
plMail does to a message once it has it — a model call where the
assistant sorts, event extraction, insight cards, receipts — was queued
on `ingest`, beside the syncs, for every message of the import, oldest
first. One consumer serves that queue. 988 envelopes pending and the
oldest an hour old was a mailbox being asked about newsletters from
2019 while mail on the server waited to be fetched.

`ingest` is now what talks to a mail provider. The rest moved:

  enrich_live     worker-live      follow-up work for mail that has just
                                   arrived on an account whose import is
                                   over; never behind an import
  enrich          worker-enrich    the same for an import's recent mail,
                                   and search indexing
  enrich_backlog  worker-enrich    old mail for the assistant, consumed
                                   only when `enrich` is empty
  release         worker-release   ends a hold (below); nothing else

Which queue is decided per batch, not per message class: the pipeline
asks InitialImportState once, carries the answer on PostIngestResult,
and EnrichmentRouter turns it into a stamp.

Only mail from the last 30 days (ENRICH_RECENT_DAYS) gets follow-up
work on arrival. Older mail is stored, threaded and filed by the rules.
The assistant is asked about it afterwards — newest first, a bounded
handful per run, and not until every account of that person has
finished importing (app:ai:classify-backlog, ClassificationCatchUp).
For the other steps `app:backfill` remains the way to read history.

ExtractInsightsStep and ProposeEventsStep ran their work inside the
sync and argued for it; both dispatch now.

New mail no longer appears in one tab and moves to another. For
somebody sorting by assistant the rules' category is a guess about to
be replaced, so where the verdict could still change it the message is
held: stamped before threading, its new thread given no category, which
every tab query already reads as "in no tab". The answer releases it.
Failing that a timed release does, after AiSettings::holdMaxSeconds —
60 by default — and the mail is shown where the rules put it; the model
call carries on and moves the mail when it answers. A message is asked
about once. Admin → AI has the switch, the ceiling, and the measured
delay over the last week beside them.

Three things about the release that were each wrong once first:

  - It ran on the live worker, so it could not fire while that worker
    was inside the call that was taking too long. It was moved to
    export, which mixed mail appearing with mail leaving. It has a
    queue and a worker of its own.
  - The call was cut off at the ceiling and the message asked about
    again later. The hold and the question are separate; only the hold
    has a deadline.
  - A late verdict was stored and moved nothing. It moves the mail.

Both workers still end at one model host, so bulk classification looks
at the live queue before each call and stands aside (LiveMailPriority).

One migration: two columns on `message`, two on `ai_settings`.

Not verified against a real import or a live model host: the tests pin
the queues, the hold and the release against in-memory transports and a
mocked host. Known and left: IMAP still imports oldest first; a reply
into an existing thread cannot be hidden; a Gmail account counts as
importing for up to an hour after its last message lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:18:44 +02:00
Paul LütznerandClaude Opus 5.5 533b773b33 Stop needing to be told what a catalogue can work out
Packaging plMail for a catalogue that forbids per-app workarounds turned
up four places where the image expected its compose file to compensate.

  cookie name      Chosen from the public address: the __Secure- name on
                   https, a bare one otherwise. It used to be prefixed
                   unless an operator knew to set MERCURE_COOKIE_NAME, and
                   an install opened on http://ip:port never got a live
                   update. The hub always reads the bare name and the web
                   server renames the cookie in the proxy, so the two
                   cannot disagree after only one of them restarted.
  storage dir      An absolute APP_STORAGE_DIR inside the project is
                   accepted and normalised to the relative form, so the
                   stored attachment paths do not change. One outside it
                   is refused at start.
  hub address      MERCURE_UPSTREAM names the container the web server
                   proxies the hub to, for a stack that does not call it
                   mercure.
  database secret  No POSTGRES_PASSWORD is minted, and no password file
                   written, when DATABASE_URL already carries a password:
                   that database is somebody else's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:17:47 +02:00
Paul LütznerandClaude Opus 5.5 be3b27cf32 Let an administrator change the public address after setup
Setup asked for it once and nothing could answer it again. An install
restored from another machine's backup therefore kept the old machine's
address: browsers were sent there for live updates and the stream
indicator stayed red, with an environment variable in the compose file
as the only remedy.

Admin → Address edits the same value setup writes. The change is saved
to the generated file and takes a restart to apply. Making it live was
left out on purpose: the entrypoint exports that file into every
container's environment at start, and telling a stale export from a
deliberate pin needs more machinery than a restart costs. The page shows
the address in use beside the saved one whenever they differ, and offers
the web container's restart after every save.

One thing fell out. A hub address on file that is only the previous
public address with the hub's path on it is dropped on save, so it is
derived from the new address again; a hub address of its own is kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:25:56 +02:00
Paul LütznerandClaude Opus 5.5 0507c1e716 fix(truenas): chown the Postgres directory to the uid the alpine image runs as
postgres:18-alpine runs as uid 70; 999 belongs to the Debian image. The
install worked anyway because the Postgres entrypoint re-chowns PGDATA as
root, but the chown, the setfacl fallback and the docs named the wrong user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 10:11:55 +02:00
Paul LütznerandClaude Opus 5.5 9c25eae1bf fix(secrets): the JWT keypair lives beside the generated secrets
.env named var/secrets/jwt outright, which is beside the generated
secrets only while APP_SECRETS_FILE keeps its default. The TrueNAS
layouts point it into their data dataset, so there the keypair landed on
each container's own throwaway filesystem: every service generated one
on every start ("Generated the JWT keypair." in the log), and a token
one of them signed would have been refused by the others and by the
next restart. Nothing issues JWTs yet - the Android app pairs into an
app password - so nothing broke, but the promise in the comments that
every service verifies what the others sign did not hold.

- config/bootstrap_generated_secrets.php derives both paths from the
  directory APP_SECRETS_FILE is in, for every PHP process including a
  `docker exec` console. A real environment variable still wins; the
  .env defaults are empty, as for the other derived values. On the
  standard compose the paths are the ones they always were.
- app:secrets:init generates under a lock, rechecked once held, as
  GeneratedSecretsFile::ensure() does. lexik's --skip-if-exists is a
  check followed by two writes, so services starting together could
  leave one's private key beside another's public key - a narrow window
  that seven concurrent runs did not hit in five rounds, and closed now
  rather than left rare: with the lock every round generated exactly
  once and left a matching pair.
- A reset that rotates secrets removes the two key files where they
  are, instead of emptying a fixed var/secrets/jwt.
- The security.yaml note on "the future first-party app" is corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:07:39 +02:00
Paul LütznerandClaude Opus 5.5 a8364e34d2 feat(deploy): one worker container runs every background process
The stack was nine long-running containers: the web server, the
database, the Mercure hub, and six of plMail's own image, one per
background process. It is three now: php, worker and database, with
secrets-init still running once and exiting.

The worker runs `app:work`, a small supervisor keeping seven processes
alive the way Docker's restart policy kept seven containers alive: the
IMAP supervisor, one consumer per queue, the scheduler and the hub. A
clean exit (the hourly recycle, the restart signal) comes straight back;
a crash backs off from one second to thirty. Output passes through a
whole line at a time, so two processes' JSON records never interleave,
and SIGTERM reaches every child at once with 25s to finish. Each queue
keeps a process of its own, which is the part the split was for: a
consumer inside a long handler cannot take a send. And each process
keeps its container's old name as APP_CONTAINER_NAME, so heartbeats,
/healthz and the log filters are unchanged.

The hub is the official Mercure 1.x binary, copied into the image and
run as one of those processes. Not FrankenPHP's built-in hub, which is
0.24 and refuses the RFC 9068 tokens plMail has minted since v0.2.31.
The worker answers to `mercure` on the network, so the Caddyfile proxy
and MERCURE_URL reach it where they reached the hub container, and older
compose files keep working with this image unchanged. It is configured
from the application's own settings, including MERCURE_COOKIE_NAME, so a
plain-HTTP install names the cookie once for both halves. A 5s shutdown
grace stops it waiting on open subscriptions, which made every stop take
the whole 25s.

The dev override loses its per-worker blocks, one of which (worker-bulk)
had been missing, so that queue ran the published image's code in
development. The demo overlay runs app:work --without=imap-supervisor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 16:20:31 +02:00
Paul LütznerandClaude Opus 5.5 4cbdb4f511 feat(admin): a version checker, with a channel to follow
Admin → Updates: an administrator picks which builds count as an update
(Releases, the `latest` image; Main, the `main` image; or Off), and
every hour app:updates:check asks whether that channel has a newer one.
Until somebody picks, an installation follows the tag its own build
came from, so a `main` image follows main and an unbuilt checkout asks
nothing of anyone.

The registry decides what exists: the channel's image is read the way
`docker pull` finds it (OCI distribution API, anonymous token from the
401's challenge, the index entry for this architecture, the config's
labels), so a commit that builds no image is never announced. GitHub's
compare decides which way two builds differ, so a main build following
releases is not told to go back to an older release, and its commit
list is the page's "what's new". An image from anywhere but GitHub falls
back to what the version numbers alone can say.

Each new build is announced once: a push to every administrator's
registered devices (a new UpdateAvailable payload the service worker
shows), a badge beside the build chip in the admin header, and a dot on
the nav entry. A check that gets no answer keeps the last one and says
why, and the hourly command exits 0 for it: an installation behind a
firewall is not a fault.

Images are stamped with where they are published (APP_IMAGE), so a fork
that publishes its own checks its own. The channel travels in config
backups; what the last check found does not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 14:57:14 +02:00
Paul LütznerandClaude Opus 5.5 1aadc53218 fix(security): prune finished jobs and expired trusted devices nightly
Both tables grew forever: TrustedDeviceRepository::pruneExpired() and
BackgroundJobRepository::findFinishedBefore() had no caller. The nightly
app:monitoring:prune now deletes expired trusted devices and background
jobs finished more than 30 days ago (--job-days). The job lookup became a
bulk DELETE, since hydrating would scale with the history and trip over any
row whose kind the enum no longer lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:37:29 +02:00
Paul LütznerandClaude Opus 5.5 dee4257d3f fix(security): check every hop of user-supplied URLs against private networks
The Nextcloud, Paperless and Immich drivers followed up to twenty redirects
unexamined, the URL validator let any hostname through without resolving it
(container names, nip.io, 127.1, 2130706433, mapped IPv6), and push endpoints
were not checked at all. Requests to user-supplied URLs (the three drivers,
CalDAV, ICS feeds, Web Push) now go through UserUrlHttpClient, which wraps
NoPrivateNetworkHttpClient and re-checks every hop. The validator resolves
the host. LAN integrations keep working through the existing
INTEGRATIONS_ALLOWED_HOSTS and admin-pinned server addresses; push endpoints
get their own PUSH_ALLOWED_HOSTS for the bundled ntfy, and never redirect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:37:15 +02:00
Paul LütznerandClaude Opus 5 376cf4ae72 Add Paperless-ngx as an attachment source and a save destination
Connect it with an address and an API token and it appears beside the other
services; nothing else in the UI had to change, because the picker, the
destination chooser and the connect dialog are all driven by the provider
taxonomy and the driver interfaces.

TAGS ARE THE CONTAINER LAYER. A document archive has no directory tree —
Paperless replaced folders with tags and expects the rest to be found by
searching — so the picker's "descend into a container, pick a file" has
nothing to descend into. The root lists every tag as a folder with the newest
documents beneath it, and opening a tag lists that tag's documents.
Correspondents and document types would serve equally well and are
deliberately not offered: three sideways slices of one archive is a navigation
problem, and search already crosses all three.

Search is Paperless's own full-text index, so it reads the OCR layer as well
as the metadata and a scanned invoice is findable by a line item nobody typed.
That is the feature: there is no longer a reason to open Paperless, find a
document and download it before writing the mail.

Upload is asynchronous — post_document answers with a task id, not a document
id — so upload() returns it prefixed. Ids are opaque outside the driver, which
is what makes the prefix safe, and download() refuses a task id with a
sentence a person can act on rather than letting it 404 later.

ShareLink is not declared. Minting a Paperless share link is a permissioned
feature with its own expiry semantics, not something to do silently. The
taxonomy test that assumed only photo services lack share links had a false
premise; it now states the real rule rather than being relaxed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 21:51:58 +02:00
Paul LütznerandClaude Opus 5 ab9ecbc9e0 Hand a whole-view bulk action to a worker, which it never was
RunBulkStatusHandler exists to take a mark-read or archive over a whole view
out of the request — its docblock quotes the thirty-second timeout that
produced it, and BulkStatusController answers with an indicator so nobody
waits. Neither was true: the message was never listed in messenger.yaml's
routing map, and Messenger handles an unrouted message inline in whichever
process dispatched it. Every whole-view action ran inside the web request,
under the limit it was written to escape, at request concurrency — which is
where the production deadlock came from.

It gets a queue of its own rather than joining maintenance, because somebody
is watching an indicator for it and queueing it behind an embedding backfill
would be slower than the inline version it replaces. That means one new
container, worker-bulk, in every compose file and in the handbook.

The deadlock itself is retried now instead of failing the job, three times per
chunk, and a collision that outlasts those hands the job back to the queue
rather than ending it. The failure path no longer writes through an
EntityManager Doctrine has closed, which is what it was doing for every
failure that came out of a flush.

And the list catches up: nothing ever refreshed it on the strength of the job,
only on the toolbar's own "the write landed" event, which is now the instant
the work was QUEUED rather than finished.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:18:57 +02:00
Paul LütznerandClaude Opus 5 822a17bfef Name the last silent path, and document the proxy it points at
The performance panel said it: "Last call: Summaries · cancelled". That row is
only written when the generator is ABANDONED — AiAssistant::recorded() records
ERROR_CANCELLED when its consumer never reached the end — so the summary was not
timing out at all. It was the controller's abort path, which breaks out of the
loop when connection_aborted() fires and returns without a frame and without a
word, because losing the reader is a normal thing that needs no explaining.

Except it is only normal when the reader left. From inside this process, a proxy
closing the stream looks exactly the same, and one of those is a fault. Silent,
the two were indistinguishable: a card reading "the summary could not be
written", nothing in the log, and a `cancelled` row that reads as somebody
changing their mind. Four rounds of this were spent on timeouts because the one
record that named the real shape was in a panel nobody thought to read.

It logs now, with the elapsed seconds and how much of the answer had reached the
browser. Those two tell the cases apart: somebody who gives up does it at a
human moment and usually mid-answer, while a proxy does it at its configured one
and during the silence before the first token — and the same round number every
time is the whole diagnosis.

AND THE DOCUMENTATION HAD HALF OF THIS ALREADY. reverse-proxy.md turns buffering
off and raises the read timeout to 24h — for /.well-known/mercure, and nothing
else. plMail serves two long-lived streams and only one of them was ever
mentioned; the assistant's endpoints got nginx's default 60-second read timeout,
which is shorter than a summary of a long conversation legitimately takes. That
is now written down, in both languages, with the failure mode spelled out next
to it.

No behaviour change beyond the log line. The proxy block is the fix, and it is
not one plMail can apply to somebody else's proxy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:01:14 +02:00
Paul LütznerandClaude Opus 5 e7f4513574 fix(backup): the config backup had stopped carrying what it promises
A restore looked like it worked. That is the whole shape of this: nothing threw,
the review page listed a clean plan, and the parts that had gone missing produced
no row to warn about, because a section that does not exist has nothing to say.

── A removed user came back to life ────────────────────────────────────────
The restorer's own docblock and the handbook both say a soft-deleted user counts
as existing, "because they hold the email address against a unique index".
They had stopped holding it. Removal frees the address on purpose — the admin
delete action rewrites it to `deleted-<id>@invalid` — so existingId() answered
null for every removed user, the plan called them Absent, and the restore created
them afresh and LIVE: old password hash, TOTP secret, recovery codes, every
mailbox credential. Listed as an ordinary creation, indistinguishable from a new
colleague. The handbook steers operators straight into it.

The rewrite destroys the only copy of the address, so nothing could match it
back; `email_at_deletion` is that copy, written at the same moment. Rows
tombstoned before the migration stay unrecognisable, which is stated and held by
a test rather than papered over.

The regression test that was supposed to catch this passed throughout, because it
wrote `deleted_at` and left the email in place — a row the application cannot
produce. It now tombstones the way the delete action does.

Its sibling: a violated unique index escaped as an uncaught 500 with no message.
api_token.token_hash is unique across the WHOLE install and a soft delete
cascades nothing, so a removed user's tokens still hold those values. Translated
into a stated failure with a sentence attached. Not demoted like other failed
writes — a violated constraint aborts the transaction, so there is no "rest" to
let through, and stopping with a reason is the honest shape.

── Four things a person configured never reached the file ──────────────────
The assistant configuration in full: model host, both models, the reverse-proxy
token that exists in plaintext nowhere else, the feature switches, and every
prompt an administrator rewrote. The per-user half had been carried since the day
it shipped; the instance-level half it depends on had not, so a restore returned
everyone's preferences pointing at a host that was gone.

Account signatures and read-receipt defaults — and on the instance this was found
on, `compose.signature` was the ONLY account setting in use, so every export it
produced held an empty settings bag for every account. The per-alias overrides
key themselves by alias row id, which names somebody else's alias on the target,
so they now travel inside their alias and are rewritten under the new id after
the flush, the way `calendar.target_id` already was.

Five user settings, the chosen log level, and the OAuth scopes that describe what
the provider granted to the refresh token the file already carries.

── Why none of it was caught, which is the part worth keeping ──────────────
Every guard pointed the wrong way. The env test builds its expectation by calling
the constant under test. The users test is fourteen assertArrayNotHasKey calls —
it fails if somebody ADDS a field, never if one goes missing. The version
assertion compares a constant against its own echo. A backup's characteristic
defect is omission, and nothing could fail in that direction.

Two tests now compare the backup against the application rather than against
itself, and fail closed: every User and Account SETTING_ constant must be carried
or excluded on the record with its reason, and the database section must carry
every admin-configured table. The second would have gone red the day AiSettings
landed. A round-trip test pins the set of things a restore drops to exactly two,
each deliberate, so the set can only change on purpose.

── Also ───────────────────────────────────────────────────────────────────
The KDF had a ceiling and no floor, so `memlimit: 1` passed every check and took
a SodiumException out onto a blank error page — through the anonymous
/install/restore among others. Both memzero paths now run under try/finally, the
abnormal case they exist for. App passwords keep the date they were created
rather than the date they were restored.

And the admin handbook said the file contains "no user accounts". It contains
every one, with their secrets — published in two languages. Corrected, along with
the inventories, which now list what actually travels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 17:33:22 +02:00
Paul LütznerandClaude Opus 5 755f3cf257 feat(users): an administrator can run the whole life of an account, and you can change your own password
Managing users was reachable and worked — list, search, create, promote, demote,
remove — but three things around it did not, and one of them was load-bearing.

── You could not change your own password ──────────────────────────────────
Nowhere. Not in Settings, not by mail, not at all; only an administrator setting
one for you. Settings → Security now leads with a Password card that asks for
the current password (an unmapped UserPassword constraint, so it is checked
against the signed-in token rather than against anything the form was handed)
and takes the new one twice. The route carries no id and the form has no
data_class, so it structurally cannot become the admin form by accident.

The floor those passwords are held to was three literal 12s in three files that
happened to agree. It is now User::PASSWORD_MIN_LENGTH, read by all three.

── An account could only be removed, never switched off ────────────────────
Nothing sat between "leave it alone" and "destroy it", so the answer to "this
person has left, keep their mail" was to delete them. There is a deactivated_at
now, and a UserChecker registered on BOTH firewalls — the JMAP one included,
because an app password outlives the browser session it was minted from and a
check only on `main` would shut somebody out of the web UI while every mail
client they had already connected kept syncing.

The refusal is in checkPostAuth rather than checkPreAuth on purpose. checkPreAuth
runs before the password is verified, so refusing there would answer "is this
address a suspended account here?" to anyone who types an address — the question
hide_user_not_found exists to keep unanswerable. After verification, the only
person told is the one who could otherwise have signed in.

Symfony runs user checkers at authentication only, so a session already open
would have carried on until it happened to end. UserProvider::refreshUser()
refuses the same two states on every request. Suspension also travels through
config backup, which would otherwise have handed a suspended account its sign-in
back on restore.

── Two smaller lies ────────────────────────────────────────────────────────
A search under three characters silently returned the unfiltered list, which a
QueryBuilder has no way to complain about, so the floor was dropped rather than
kept as something that could only mislead. And an admin declining to demote
themselves got a success toast for a change that never happened; the refusal now
says so, while the rest of the form still saves.

── Restoring a removed user is deliberately absent ─────────────────────────
Removal overwrites the address, the name and the hash specifically to free the
unique address for reuse, so a restore would have to invent all three and the
original address may by then belong to somebody else. Doing it properly means
snapshot columns and a guided flow that asks for a fresh address — a different
change, which alters what removal means. Documented as absent on the screen an
administrator would look for the button on, because switching an account off is
the answer to the question people are actually asking.

Also fixed en route: the modal form theme dropped help_translation_parameters,
so the first field to use one rendered "at least %count% characters" on screen.
And GermanIsInformalTest only ever globbed messages.de.yaml, leaving validators
and the new security catalogue — exactly where a translator reaches for "Sie" —
outside the test that exists to catch it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 16:02:12 +02:00
Paul LütznerandClaude Opus 5 2348b75621 feat(db): pgvector as an opt-in image, built without changing libc
The original rejection of pgvector was tested rather than reasoned, and it
stands: pgvector/pgvector:pg18 means musl to glibc on an existing data volume,
and 44 collation-dependent indexes — 18 of them UNIQUE — are then walked with
the wrong comparator, silently, because musl records no collation version so
Postgres's own mismatch check never fires.

So the extension is compiled INTO the alpine image instead. libc never changes,
and there is nothing to rebuild. Verified end to end rather than argued: an
existing volume migrated on stock alpine, stopped, restarted on the pgvector
image, then bt_index_check(heapallindexed => true) across 175 btree indexes —
zero errors.

Two build flags carry more weight than they look. OPTFLAGS="" because
pgvector's Makefile defaults to -march=native, which bakes the build host's
instruction set into vector.so and gives SIGILL mid-query on a host without it,
with an empty log. with_llvm=no because a missing .bc file makes the JIT skip
inlining silently, and inlining does not apply to index scans anyway.

Pinned at v0.8.6, not the earliest that works. v0.8.0 does not compile against
Postgres 18 at all (vacuum_delay_point gained a parameter); v0.8.1 is the first
that does. But 0.8.3 fixed possible index corruption with HNSW vacuuming and
0.8.4 fixed an unrepaired HNSW graph, and message_embedding is ON DELETE CASCADE
from message on a system that deletes mail in bulk — so vacuum is a hot path
here and shipping the earliest that merely compiles would ship two known
corruption bugs.

THE HANDBOOK'S 24x WAS WRONG AND IS CORRECTED TO 1.75x. The SIMD dot product is
microseconds; what dominates is that embedding is real[], so every row pays a
4KB detoast and a fresh real[] to vector conversion. 74,000 rows: 7.70s becomes
4.51s.

NO INDEX IS CREATED, and that is the finding rather than an omission. An HNSW
index answers ORDER BY ... LIMIT k and the search asks distance <= 0.45, which
no ANN index serves. The expression form needed to build one at all is
(embedding::vector(1024)), which Postgres treats as a DIFFERENT expression from
the inlined function's (embedding::vector) — so the index exists, the planner
ignores it, and nothing says so. And it costs 7m40s and 578MB to build at the
maintenance_work_mem this stack ships, behind the one advisory lock every
entrypoint waits on: an eight-minute dead stack, not a slow migration. The
measured route to an index is written down in ai-assist.md rather than half
taken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:48:35 +02:00
Paul LütznerandClaude Opus 5 5f648864ab feat(admin): set the log level from the page that shows the logs
Asked for directly: "let the admin panel set the log level. i do not want
to mess with env vars."

APP_DB_LOG_LEVEL meant editing a file on the host and restarting the
stack, and the moment it is actually wanted is the moment that is most
awkward — something is wrong, the answer is one level further down, and
restarting throws away whatever was in flight. So the level gets turned
down, forgotten, and left down.

WHAT IT IS NOT

Not another control in the filter row. That row narrows what is SHOWN out
of what was stored; this decides what gets STORED. They look alike and
mean opposite things, and side by side they would invite an admin to
change the wrong one and conclude the log was broken. Its own row, its own
form, its own wording.

NULL IS NOT "TODAY'S VALUE"

A stored level of null means "follow the environment", which is
deliberately different from copying APP_DB_LOG_LEVEL's current value into
the row. An install that has never chosen keeps following its
configuration including later changes to it, and there is a way back to
that after a level has been set — which storing the resolved value would
have removed, silently, on the day the env var next changed.

PER RECORD, NOT PER PROCESS

Monolog fixes a handler's level at construction. That is right for one
configured in a file and useless for one an administrator can turn down
from the page they are reading, so DoctrineLogHandler decides in
isHandling() instead, and the constructor's level is now only the answer
when no resolver was given — which keeps directly-constructed handlers,
and every existing test, behaving as they did.

LogLevelResolver caches for ten seconds. A query per log line would put a
database round-trip inside the thing that runs when the database is in
trouble; a value cached for the life of the process would leave every
long-lived worker ignoring the change until the next deploy, and the
worker logs are usually the ones it was lowered for. It uses DBAL, not the
ORM, for the same reason the handler does, and every failure — no table
yet, no database, a name that is not a level — falls back to the
environment rather than throwing. A logger that throws while deciding
whether to log is worse than one briefly out of date.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:21:00 +02:00
Paul LütznerandClaude Opus 5 8c2ef7663b feat(ops): a demo deploy that cannot quietly do nothing
Three commands in the handbook, one of which fails silently.

`docker compose pull` can run out of disk part-way through extracting a
layer. `up -d --wait` then brings the stack up on the image that is
ALREADY there and reports every container Healthy — so a deploy that
changed nothing is indistinguishable from one that worked, and the only
evidence is a line buried in the pull output. v0.1.36 was believed
deployed for twenty minutes while the demo went on serving v0.1.35.

Piping made it worse. Through a pipe the exit status belongs to whatever
came after it, so the `pull | tail -2` in the handbook's sequence
reported success no matter what the pull did. Nothing in the script is
piped.

And the disk fills on its own: every deploy leaves its predecessor behind
and nothing collects them. Eleven orphaned images is what filled it, on a
host that also runs Apache, MySQL and ClamAV and never has more than a
few GB spare.

So: check for room, prune if short, pull and STOP if that fails, up,
then clear the images the deploy replaced.

  · `-a` on the prune is load-bearing. A replaced release is a fully
    tagged image nothing references any more, and dangling-only leaves
    every one of them.
  · `--volumes` is never passed. The demo's database is in one.
  · compose.proxy.yaml is included by existence rather than by name: it
    belongs to a host that already has something on port 80 and is not in
    this repository.

Verified on the real demo host, both ways — a normal run reports "image
unchanged" and exits 0, and DEMO_MIN_FREE_MB=999999 refuses, exits 1, and
leaves the running stack alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 11:11:09 +02:00
Paul LütznerandClaude Opus 5 748e1cdcdf feat(demo): a privacy notice, and two fixes found by using the demo
**The privacy notice gets its own page and its own link.** A section inside the
Impressum is not one — it has to be reachable in its own right — and it is
required even though this instance asks for nothing. A web server writes the
caller's IP address into a log on every request and an IP address is personal
data, so "we collect almost nothing" is a statement that has to be made rather
than a reason to omit the page.

Written from what the software does rather than from a template. The cookies
named are the three it actually sets, observed on a real visit; the claim that
nothing reaches a third party is the content security policy, which permits
connections to this server only; and the retention period is read off
APP_DEMO_TTL through DemoMode::ttlDescription() rather than typed into the
prose, because a notice stating a period the reaper does not honour is worse
than one saying nothing, and two copies drift the first time somebody tunes the
setting.

APP_DEMO_PRIVACY_HOST names whoever runs the machine, who is a processor.
Unset, the page says so in red — the same rule the Impressum follows, for the
same reason: a legal notice naming nobody looks compliant from a distance.

**Pressing Receive twice looked broken.** The second press appeared to do
nothing for up to thirteen seconds. _refreshList() coalesces bursts through a
fifteen-second floor measured from the LAST refresh, so the press right after a
delivery was the one certain to be inside it. The floor is right — a sync
publishes one event per mailbox per account, and it turns eight fetches into
one — but it was being applied to somebody who had just pressed a button and was
watching for the result. refreshNow() skips it; onAccountSynced() still does
not. The difference is whether anybody is waiting.

**The user menu still wore its own tooltip.** The previous fix stopped a NEW
bubble appearing over an open menu but left the one already on screen, which is
why it vanished only when the pointer left. There is a document-level click
listener whose whole job is to take it down, and ui--user-menu#toggle calls
stopPropagation(), so the click never reached it. The listener runs in the
CAPTURE phase now — at the document, before the event reaches its target, where
no handler can hide a click from it. The same reason the scroll listener above
it already uses capture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 12:44:35 +02:00
Paul LütznerandClaude Opus 5 6e9a441333 fix: an install page a demo cannot close, and eleven flashes nobody translated
Two bugs found by using the thing.

**A demo visitor could lock an install out of /install.** InstallGuard closes
that page as soon as a user exists, and demo mode mints one per arrival — so on
a fresh demo instance the first passer-by or crawler took the install page away
with them, permanently, before the operator had made themselves an
administrator. Nothing announced it either: the page 404s, which is exactly
what it does on a correctly installed instance, so the symptom is
indistinguishable from working. Both the guard and createFirstAdmin's
advisory-locked re-check now count real users only.

**Flash messages reached the page as raw translation keys.** The flash bag on
this install holds sentences, not keys — the toast region prints what it finds,
and cannot translate, because most of these interpolate a name or a count only
the controller has. Eleven call sites across TwoFactorController,
SecurityStepHandler and AdminDashboardController pushed keys instead, so a
rejected code produced a red toast reading "two_factor.flash.code_rejected".
The strings existed and had been translated into all three locales months ago;
nothing looked them up. A static test now scans src/ for flash calls passing a
dotted literal, because nothing about that failure is visible in review — the
call site reads exactly like a correct one.

That fix broke two E2E tests, which is the interesting part: they asserted on
"Two-factor authentication is on" and had been matching the panel heading ALONE
because the toast beside it was showing a key. Translating it made both match
and strict mode rightly refused. They were passing because of the bug.

**Demo mode, from running one publicly.** The account form opened in a
turbo-frame, so the guard's redirect had nowhere to land and the modal span
forever — it now renders a panel saying why the form is not coming. The receive
button was a full browser navigation, which reloaded the page on top of mail
Mercure had already delivered AND wiped motion.js's in-memory set of seen rows,
so the whole list replayed its entrance; it is a stream touching only the demo
bar now, and mail simply arrives. Plus an Impressum, because a public demo in
Germany needs one — blank by default and loudly saying so, since § 5 TMG wants
a real operator and a notice naming nobody looks compliant without being so.

**Interface, all reported from use.** The snooze menu's two distant options were
taller than the near ones, because anything past two days carries the date and
"Sat, Aug 29, 08:00" wrapped inside min-w-56. Clicking a dropdown trigger
focused it, and focus shows a tooltip — so opening the user menu painted its own
hint across the menu. The eight recovery codes ran together on one line: they
were joined with real newlines and <code> collapses those without
whitespace-pre-line, so nothing was rendering what was already there. Confirming
a 2FA code gave no sign anything was happening, and the reply carries codes
shown exactly once. Layout was the last <select> on a panel where every other
short choice is a segmented group. Solar and paper put a white field on a warm
off-white surface, which under the boxed layout's translucency left the search
box with no visible edge. Read receipts had a tab of their own coming — the card
holds nothing else, and signatures left the alias tab before it for the same
reasons. And /settings opened on its fourth entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 11:03:08 +02:00
Paul LütznerandClaude Opus 5 231d0ab0da feat(demo): a public demo mode, with mail that arrives on demand
plMail is hard to show. It is a mail client, so everything interesting only
happens once there is mail — and a stranger cannot reasonably be asked to hand
a demo server their IMAP password to see it. APP_DEMO_MODE fills that gap: mail
that is nobody's, and a button that makes more of it arrive.

One switch for the whole install, not a per-account flag. The promise is that
nothing reaches the network, and that is only checkable if there is one place
to check; a per-account flag would move the promise into every outbound path
individually, where it holds until the first one that forgets to ask. Read
through `default:` so an install that never heard of the variable is a normal
install — the accident worth designing against is a real deployment becoming a
demo, not the reverse.

**A mailbox per visitor.** /demo provisions a throwaway user, signs them in and
drops them in a seeded inbox. One user each rather than one shared account: two
people arriving at once would otherwise be marking each other's mail read and
reading each other's drafts, and the product would look broken while working
perfectly. app:demo:reap deletes them on a timer.

**Receiving.** A bar at the foot of the page walks a scripted queue — a reply,
an invoice with a real attachment, a delay notice, an HTML newsletter — and
wraps at the end, naming what comes next. Delivery goes through the real
PostIngestPipeline rather than writing rows by hand, because what is being
demonstrated is that plMail *does* something when mail arrives: it threads, it
categorises, it runs the filters the visitor just built, and it appears without
a reload. A hand-written row would put mail on screen and none of that would be
true, which is a worse demo than no button.

**Sending.** DemoMailSender is registered above every real sender and claims
every account while demo mode is on, so SmtpMailSender is never asked — "a demo
cannot reach a relay" becomes a property of the wiring rather than a rule each
sender has to remember. It answers a few seconds later, from whoever the visitor
addressed, on the thread they are still looking at.

**What is switched off**: adding or editing a mail account, OAuth and CalDAV
connect, calendar subscribe — a stranger typing their real credentials into a
box on someone else's server has handed their mail away, and there is no version
of that form that is safe to show. Mail sync, push renewal and calendar sync
no-op, since a demo's accounts point at documentation domains. Everything else
stays clickable: locking down all the non-mail surfaces produces a tour of a
product with the interesting doors locked.

Two things this needed that did not exist. The demo mailbox moved out of
SeedDemoMailboxCommand into DemoMailbox, shared with the provisioner, so the
README screenshots and the hosted demo cannot drift into being pictures of
different things. And deleting a user — this project had no such path, because
a self-hosted install's users leave by having their password changed. The
eraser discovers owned classes from Doctrine's metadata rather than a list:
twenty entities point at User today under two different property names, and a
hand-written list would be right until the next entity was added, at which
point the reaper would start failing on a foreign key at three in the morning.

compose.demo.yaml hardcodes APP_DEMO_MODE rather than interpolating it, and
ComposeEnvironmentTest now holds it to that. Compose reads Symfony's committed
.env, which ships the variable as 0, so `${APP_DEMO_MODE:-1}` would have
resolved to 0 for everyone who cloned the repo — and the failure is the bad
kind, where the stack comes up healthy and simply is not a demo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 10:05:40 +02:00
Paul LütznerandClaude Opus 5 d3e4eced03 fix: three corrections the suite caught, one of them a recipient bug
**The shared dropdown escape was too broad.** Sending every ui--dropdown menu to
the top layer fixed the ones trapped in a pane and broke the ones deliberately
anchored to the box they live in — the compose window's emoji picker is centred
ON the window and asserted to stay inside it. It is opt-in now
(`data-ui--dropdown-escapes-value`), and the snooze menu on a mail row asks for
it because it opens inside the mail pane.

**The recipient dropdown is NOT reparented to <body>, and that is now written
down.** `dropdownParent: 'body'` fixes the reported symptom in one line — the
compose window grows to make room for the suggestion panel instead of the panel
overlaying it — and it reintroduces something worse. Subject sits directly under
To, so an overlaid panel covers it, and a click aimed at Subject lands on
whichever suggestion is under the pointer and enrols it. That is a real report:
it silently added a third party to a message addressed to someone else, and
three specs in compose-recipients.spec.ts exist to keep it fixed. They caught
this.

So the panel stays in flow and the window growing is the price, with the
reasoning in the code so the next person does not spend the same hour. The way
out is a layout change — the panel opening where it cannot cover the next field
— not a parent change.

**The session guard swallowed too much.** It cancelled every 401 and 403, not
just the redirect that would have swapped a login form into the shell, so the
compose dock's own "your session has expired, reload and sign in" — said in the
place the user just clicked — never rendered. Only the redirect is cancelled
now; a bare 401 travels on to whoever asked for it, who usually has something
more specific to say.

compose-safety's held-shape test sets its own setting and restores it in an
afterEach rather than an afterAll, so a failure there cannot leave the worker's
user on a setting the rest of the file does not expect.

Full Playwright suite: 563 passed, 0 failed. That is 563 rather than the 517 a
green run used to report — the exclusive project depends on the main one, so
every flake was also skipping those 19 specs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:39:14 +02:00
Paul LütznerandClaude Opus 5 8c3080d00a fix(security): an SVG attachment is not an image, and a shipped install gets headers
CODE-REVIEW S-02 (high), S-04, S-08, S-05, S-06.

S-02 — three controllers decided what may render inline with
`str_starts_with($contentType, 'image/')`, and `image/svg+xml` satisfies it
while executing script as a top-level document. nosniff does not help: nothing
is mislabelled, the type genuinely is what it says. The type comes from the MIME
headers of an incoming mail.

Reachable from the mail itself. MailBodySanitizer::resolveCids() rewrites EVERY
`cid:` in the document rather than only `img src`, and the sanitiser allows
relative links — so `<a href="cid:logo">Rechnung ansehen</a>` has the part id
filled in by the application, and the click opens the attachment route without
the `?download=1` that every template adds.

Now an allow-list (InlineDisposition), which is the difference between
"everything not yet known to be dangerous" and "the formats actually wanted",
plus `default-src 'none'; sandbox` on the response — so a later widening of the
list is a bug rather than a vulnerability. Parameters are stripped before
comparing, because `image/svg+xml; charset=utf-8` would otherwise walk past the
one entry meant to stop it.

S-08 pulled along: the JMAP download had the same prefix test. Guarded today
only by the jmap firewall being stateless and wanting a header a browser
navigation does not send, which is a thin thing to depend on.

S-04 pulled along: the file-picker preview carried no Content-Disposition at
all — implicitly inline, in our origin, with a type chosen by Nextcloud, Immich,
Dropbox, OneDrive or Drive. A file an attacker drops in a shared folder is a
realistic shape for the setups these integrations exist for. A non-image now
falls back to the placeholder rather than to a download: a download prompt fired
by a thumbnail is worse than a grey box.

S-05 — the Caddyfile now sends HSTS, X-Frame-Options, nosniff, Referrer-Policy
and Permissions-Policy, and drops the Server banner. None of these existed
anywhere in the repository, so the ones a live instance showed were its
operator's proxy — while the README's own install instructions are `docker
compose up -d` and "open https://localhost". `defer`, so the per-response
policies on the attachment and image-proxy routes still win. Referrer-Policy is
the one that matters most for a mail client: it sends its users to strangers'
links all day.

S-06 — mercure subscribe defaults to [] rather than '*'. Never reached today,
since both cookie-minting paths pass explicit topics, but a future
createCookie($request) without a topic argument would have issued a subscription
to every user's mail events and looked entirely ordinary in review.

Also: mailpit removed from the dev override. MailerInterface is injected in no
service in src/ — plMail's own outgoing mail leaves through the user's account
via MailSenderRegistry — and the override never set MAILER_DSN either, so the
container has been catching nothing. Both configuration pages said the dev stack
pointed at it; they now say what is actually true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 12:16:02 +02:00
Paul LütznerandClaude Fable 5 9f9f2c9820 fix(backup): APP_SECRET and TRUSTED_PROXIES leave the export
Every other name left this inventory because it describes the machine.
APP_SECRET left because of what a restored copy would do.

Take stock of what this codebase derives from kernel.secret: remember-me
is the signature-based handler, so the sixty-day cookie is signed with
it; sessions and CSRF tokens die with the session; JMAP signs with the
JWT pems, whose contents travel separately; URI signing and uuid47 are
unused. Nothing on disk or in the database is encrypted under it -- that
is APP_ENCRYPTION_KEY's job, and it does travel.

So restoring it changed exactly one thing: a REMEMBERME cookie minted by
the source install kept verifying on the target. Both halves of that
check are in a v2 backup now that users travel -- the signature covers
the app secret and the password hash -- so a browser signed in to the
machine the file came from was signed in to the machine it was restored
onto, without anybody deciding that. Cookies are not scoped by port, so
two stacks on one host hand each other's browsers straight through.

TRUSTED_PROXIES goes for DATABASE_URL's reason instead: the addresses
whose X-Forwarded-* headers this install believes are a fact about what
sits in front of THIS container. KeptDeliberately and External
respectively, so old backups carrying either classify and stay inert.

Found while reproducing a 403 that turned out to be a double submit of
the 2FA form (previous commit). The stale-cookie hypothesis was
falsified as the cause of that -- a pre-restore remember-me cookie was
correctly challenged for a second factor, it did not bypass anything --
but reproducing it is what showed the cookie surviving the restore at
all, which is the property this commit removes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 14:49:39 +02:00
Paul LütznerandClaude Fable 5 d1739e6c96 fix(backup): MERCURE_JWT_SECRET leaves the export
It pairs the app with the hub container beside it, and the hub reads it
only at container start -- so a restored value re-keys one half of a
running pair and every EventSource dies until the whole stack restarts,
which is how this was found. Old backups carrying it classify External,
like the DSNs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 14:26:14 +02:00
Claude Fable 5 03a0d6b877 A config backup knows its own operator
Three changes to the config-backup subsystem, made together because they
land on the same two pages and the same document.

**Users, and everything they configured, join the backup.** Restoring onto
a fresh install used to end at "this installation still has no users —
create the administrator account to finish", which is a backup of a server
rather than of an installation. Document version 2 adds a `users` section,
keyed by email because that is what an import matches on. Per person: the
password hash, the TOTP secret and its recovery codes, app passwords, mail
accounts with their IMAP/SMTP and OAuth credentials, aliases, per-user
integrations with their secrets, filters, labels, calendars and the share
links and booking pages whose token digests keep published URLs working.

Everything encrypted at rest leaves decrypted inside the envelope and is
re-encrypted under the target's key on the way in, exactly as the database
section already did — that is the only way a credential survives a move to
a host with a different APP_ENCRYPTION_KEY. Deliberately absent: trusted
devices and push subscriptions (grants to one browser or one phone, and a
trusted device is a skipped second factor), label bindings and sync state
(re-derived, or about a host that is not this one), avatars and background
images (filenames in a volume this file does not carry), and mail, calendar
entries and contacts, which have not moved an inch.

Import policy: a user this install does not have is created whole; a user
it does have is left completely alone, whole. Skipping is the design and
not a caution — a backup is a photograph of a moment that has passed, and
applying a stale one to a live account resets today's password to
February's with no undo and nobody told. Soft-deleted users count as
present. The ids inside a filter, a calendar or a booking page name rows in
the *source* database, so the restore builds a map as it creates rows and
rewrites every reference through it; a reference to something the file did
not carry is dropped rather than guessed at.

The /install restore page now has two endings and asks the guard which one
it is in: sign in as somebody the file restored, naming the administrator,
or — for a document that carried no users — create the administrator as
before.

**MAILER_DSN and MESSENGER_TRANSPORT_DSN leave the export.** Machine-local
deployment choices that compose supplies a default for, in DATABASE_URL's
category: the target's relay and queue are the ones that match the
containers running beside it. Old backups carrying them classify as
External and stay inert.

**The restore result page stops listing non-tasks.** A restored value
already identical to the live one is dropped from "These are yours to do"
and counted in one muted line; the APP_ENCRYPTION_KEY block becomes a
collapsed disclosure that still carries the line the one operator who needs
it came for; and when nothing is actionable the page says so and puts the
way out first.

Two robustness fixes fell out: planning no longer hydrates every user to
build a comparison (per-email now), and neither the existence check nor the
comparison can 500 a review page on an install whose encryption key was
rotated without re-encrypting.

No schema changes — the backup is a file format, not tables.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 14:02:15 +02:00
Paul LütznerandClaude Fable 5 7e3ba2b9c0 Reset the topbar globals per request, stop exporting the database's own keys, and offer a password
Three fixes riding one pass:

LogAlertGlobal memoised per request and was reset per request only under
PHP-FPM. FrankenPHP keeps the service alive, so the first badge a worker
computed was the badge every later request got - an emptied log table
with a yellow outline that survives refresh, seen in the wild. All four
stateful Twig globals now implement ResetInterface like InviteReader
always has; SidebarCounts and CalendarGlobal could additionally leak one
user's counts into another user's topbar.

POSTGRES_PASSWORD, postgres_password and DATABASE_URL leave the backup:
machine-local, initdb-consumed, never applicable on a restore target.
Old backups that carry them still import - recognised and classified
external, not silently dropped.

The export form gains an optional generate button: fills both fields,
puts the password on the clipboard and says so; where the browser
withholds its clipboard the fields unmask instead, because a masked
password nobody was told is not a backup anyone can open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 18:45:21 +02:00
Paul LütznerandClaude Fable 5 567db06844 Restore a config backup by writing it, not by printing it
The import classified every environment value as unwritable and handed
back two dozen lines to paste into .env.local. That was built on a
premise that is wrong for the way plMail is deployed: nobody hand-edits
those. frankenphp/generate-secrets.sh mints them on first run into
var/secrets/generated.env, every service mounts the volume that file is
on, the entrypoint loads it before it execs the server, and the app
process can write it. So the import writes it.

What the entrypoint actually promises, and what everything here follows
from: a real environment variable wins over the generated file, which
wins over .env.local and .env, with an empty value counting as absent at
every level. The shell (load_generated_secrets) and PHP
(config/bootstrap_generated_secrets.php) apply exactly the same rule.

ConfigBackupObstacle — "why can plMail not write this" — becomes
ConfigBackupDisposition, "what becomes of this": applied,
applied-on-restart, shadowed-by-compose, external, kept-deliberately,
not-writable. The restart instruction is now one sentence for the whole
plan rather than one line per value, because it is one action.

Three fates survive, each forced by the deployment rather than by
caution:

  - POSTGRES_PASSWORD, the postgres_password file and DATABASE_URL stay
    external. generate-secrets.sh does rewrite that file from
    generated.env on every boot, but the Postgres image reads
    POSTGRES_PASSWORD_FILE only at initdb — on a database that already
    exists the role keeps the password it was created with, so writing
    one without the other is a stack that cannot authenticate.
  - APP_ENCRYPTION_KEY is kept on purpose: the import re-encrypts the
    backup's credentials under the key in force here, and swapping it
    underneath them would make the rows it just wrote unreadable. It is
    a note now, not a chore — the line is still offered for the operator
    who is also restoring the old database.
  - A name pinned non-empty in the process environment beats the
    generated file at the next start. Detected by comparing the live
    value against what the file holds, because the entrypoint exports
    that file's own contents and "is it in getenv" distinguishes
    nothing. On the stock compose.yaml that is three names.

The /install path is the one this was for: upload and password are the
whole job, everything lands in this instance's own secrets file and
database, and the page asks for one restart instead of a wall.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 18:28:22 +02:00
Paul LütznerandClaude Fable 5 1adac1fe40 Carry an instance's configuration in one password-sealed file
Export from the admin area: env secrets, the JWT keypair and the
database-held provider credentials - FCM included, decrypted inside the
envelope because the restore target holds a different encryption key -
sealed with Argon2id and secretbox at parameters a Raspberry Pi can
afford, in a documented format a php one-liner can open without plMail.

Import shows its plan before touching anything: what it can write is
written and re-encrypted under this instance's key, what it cannot -
process environment, the Postgres role's own password - is rendered as
the exact lines to place by hand. The install wizard offers the same
restore before the first account exists, behind the same guard that
closes /install forever afterwards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 19:12:21 +02:00
Paul LütznerandClaude Opus 5 b4e3cb6afb Correct what translating the handbook found wrong in it
Six defects, all found by somebody reading a page closely enough to
render it in another language. Each is fixed in English and in German
together, and the German markers are re-hashed only where the German
prose now says what the corrected English says — re-hashing alone would
mark a stale translation as current, which is the one thing that check
exists to prevent.

  calendar-model.md      claimed the materialiser honours "exactly four
                         things in a patch" above a table of five, and its
                         traps section named a different four. The truth
                         is five keys read, of which four are the patch
                         EventInstanceEditor writes; `excluded` is not a
                         patch field at all and is spelled by
                         RecurrenceRuleConverter::exclusionOverrides(),
                         because the one override value whose only job is
                         to be exactly right should not have two places
                         that must be right about it.

  CLIENT_DEVELOPMENT.md  said snooze was "not exposed over JMAP today" and
                         offered itself as the canonical "ask, don't work
                         around" case — while Thread/set has been
                         implementing exactly that. Somebody asked. Its
                         methods table was also missing Thread/set,
                         SearchSnippet/get and all four calendar methods,
                         which the same page announces two screens above.

  install/docker.md      described the missing blob volumes as a live
                         defect. They were added to compose.yaml hours
                         after that page was written — by the report that
                         found them — and nobody updated the page. It now
                         documents ten volumes and keeps the story as
                         history, with a pointer to the CHANGELOG note for
                         anyone upgrading past it.

  providers/imap-smtp.md named a form label no interface shows. The
                         hardcoded labels in AccountType are overridden by
                         the Twig template, so the fields are "Email
                         address" and "Username".

  settings.nav.calendars had no German, so one item in a German settings
                         menu rendered in English.

  CONTRIBUTING.md        said "four translators" where there were six.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:41:59 +02:00
Paul LütznerandClaude Opus 5 4d103cfbdf Write the handbook, and make it fail the build when it goes stale
README says what plMail is and gets you running; CONTRIBUTING says how to
change it. Nothing covered the part in between — using it, operating it,
and registering the Google and Microsoft applications it talks to. That
is docs/: 34 pages across features, installation, providers and
internals, mirrored to the GitHub wiki.

The wiki is a mirror and never a source. bin/mirror-wiki.php flattens the
tree into the wiki's flat namespace, rewrites every relative link,
generates the sidebar from the index so the two cannot disagree, and
stamps each page with a footer saying where it came from. A browser edit
survives until the next push to main. That cost is deliberate:
documentation not versioned with the code drifts from it, and the drift
is invisible until somebody follows an instruction that stopped being
true.

Which is the whole problem, so the checkable half is checked.
DocumentationCoverageTest fails the build when a command is missing from
the reference or documented and gone, when a variable in .env is absent
from the configuration page, when a relative link dangles, when a page is
unreachable from the index or promised and missing, or when a page has no
"Things that bite" section. It cannot check whether a paragraph is TRUE,
and says so — this is the floor, so review attention goes to the part a
machine cannot have an opinion about. CONTRIBUTING has claimed since it
was written that the command table "is part of the definition of done";
nothing enforced it until now, and it was wrong in three places.

Writing it found things. They are fixed here rather than documented
around, because a handbook describing a bug accurately is still a
handbook nobody can follow.

  compose.yaml            mounted no volume for var/attachments, var/raw
                          or var/uploads. Workers write those during sync
                          and the web container serves them back, so each
                          kept its own copy in its own layer — downloads
                          404, and everything died on a recreate. The
                          override and the TrueNAS file both mount them;
                          the file the README tells an operator to run did
                          not. The same omission cost real data once
                          before. See the CHANGELOG for moving what is
                          currently stranded.

  app:calendar:materialise  new, nightly. Occurrences are drawn when an
                          event is saved and nothing moved that window
                          again, so a weekly standup ran out of dates in
                          two years and its reminders stopped with them —
                          silently. The query it runs had existed since
                          the materialiser did, documented as exactly this
                          sweep, and had never been called.

  Microsoft wizard        asked for three of the nine permissions plMail
                          requests, and a token already issued is not
                          upgraded when the rest are added later.

  GmailWatchService       told you to grant Pub/Sub Publisher to
                          gmail-api@system.gserviceaccount.com. The
                          account is gmail-api-push@; a topic granted
                          exactly what the docblock asked for never
                          delivered a notification.

  IcsExporter             said it wrote no VALARM. It has written them
                          since alerts landed.

  CLIENT_DEVELOPMENT.md   said there was no JMAP calendar API and that
                          SearchSnippet/get was absent. Both shipped.

  CONTRIBUTING            said only app:calendar:push registers a push
                          channel; subscribing has done it since the
                          subscribe flow learned to.

  Three docblocks         named getters removed long ago, or described a
                          method two below the one they sat on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:13:25 +02:00