47 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 6bd4dc3850 Close an issue when its pull request is merged into a milestone branch (#259)
GitHub acts on "Closes #N" only for merges into the default branch, so
issues fixed on a milestone branch stayed open until the release reached
main. A workflow on merged pull requests into milestone/** now closes the
issues the description names, with a comment linking the pull request.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:54:01 +02:00
Paul LütznerandClaude Opus 5.5 a758818488 linked-issue: read the description of a pull request into a milestone branch (#254) (#255)
GitHub links an issue from "Closes #N" only in a pull request into the
default branch. Into milestone/v0.3.8 or a wip/ branch the closing
references stay empty, so the check failed on every such pull request
however its description named the issue (#252 was the first). For a pull
request into another branch, the description is now read for the same
keywords GitHub accepts.


(cherry picked from commit 894c7f698f)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:37:52 +02:00
Paul LütznerandClaude Opus 5.5 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
dependabot[bot] d6afb52827 Bump actions/labeler from 5 to 7 (#192)
Bumps the actions group with 1 update: [actions/labeler](https://github.com/actions/labeler).


Updates `actions/labeler` from 5 to 7
- [Release notes](https://github.com/actions/labeler/releases)
- [Commits](https://github.com/actions/labeler/compare/v5...v7)

---
updated-dependencies:
- dependency-name: actions/labeler
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-17 11:00:33 +02:00
SvenandClaude Opus 5.5 08dda0ea96 Let a deployment pull request through without an issue of its own (#183)
A milestone branch on its way to main (milestone/v0.4.0, #182) collects
work that was agreed and reviewed piece by piece, each piece closing its
own issue. It has no issue of its own to name, and listing the pieces'
issues only works once each has landed, so the required linked-issue
check refused it. `deployment` joins `bug`, `chore` and `dependencies` as
a label that needs none.

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

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

Closes #30

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:29:14 +02:00
SvenandClaude Opus 5.5 c6cd81ba15 Keep the importmap's packages between CI runs, and retry their fetch (#132)
Every job of the E2E workflow ran `composer install`, whose post-install
importmap:install fetched all 45 packages from cdn.jsdelivr.net: about 270
requests per run. jsDelivr builds a `+esm` bundle on request, and now and
then one times out ("Idle timeout reached for .../openpgp@6.3.2/+esm",
"Failed to connect to cdn.jsdelivr.net"), which failed the job before a
test ran.

assets/vendor/ is now cached, keyed by importmap.php. importmap:install
skips a package already there at the version importmap.php names, so the
CDN is asked once per change to the importmap. When it still fails, the
install is tried up to three times; a retry finds vendor/ in place and
only reruns the scripts.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:50:04 +02:00
Paul LütznerandClaude Opus 5.5 f44ef134bf Enable the sponsor button
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:11:10 +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
dependabot[bot] c2c25211c7 build(deps): Bump actions/download-artifact
Bumps the actions group with 1 update in the / directory: [actions/download-artifact](https://github.com/actions/download-artifact).


Updates `actions/download-artifact` from 7 to 8
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v7...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-15 17:20:30 +02:00
Paul LütznerandClaude Opus 5 63d757896d Start worker-bulk wherever the test stack is started
v0.2.25 moved whole-view bulk actions onto a queue and gave the browser stack
a consumer for it, and then left every OTHER way of bringing that stack up
naming only `app`. `up <service>` starts that service and its DEPENDENCIES; a
worker consuming a queue the app writes to is a dependent, and compose has no
way to know it matters. So CI queued a whole-view archive to a real Postgres
transport with nothing to pick it up, and bulk-select-all.spec.ts waited
fifteen seconds for rows that were never going to move.

Reproduced before fixing, by stopping the worker and running the spec: the
same two tests, in the same file. With the worker named, three pass in seven
seconds.

The same line existed in three places — the CI composite action, the
test:env:up script and the handbook's worktree recipe — and all three now name
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:33:35 +02:00
Paul LütznerandClaude Opus 5 af7414683f Run the browser suite in quarters instead of in a queue
One job, one two-core runner, PHPStan then PHPUnit then ~690 browser
tests in that order. Everything waited for everything, and the part that
takes the time waited on two static checks that say nothing about
whether a page renders.

It is six jobs now: `image` builds once so the rest start from a warm
layer cache and no two of them race to write the same scope, `static`
runs PHPStan and PHPUnit beside the others rather than in front of them,
four `browser` shards divide the suite with Playwright's own `--shard`,
`browser-exclusive` takes the install-wide specs, and `report` merges
their blobs into the one HTML report a person downloads.

A STACK PER JOB IS THE POINT, not a side effect. A single app container
was already the ceiling on worker count, so eight browsers against one
of them would have bought nothing. It is also what frees the four specs
that own install-wide state — the AiSettings singleton, the integration
provider rows, the Mercure hub one of them restarts. They ran last,
alone, in one worker, behind everything else; they were never slow, they
were serialised, because there was one installation to collide over. On
a runner of their own there is nothing left to collide with, which is
why the job passes --no-deps: the project's `dependencies` on the main
one is exactly right for a developer running against ONE stack, and here
would only mean running the whole suite twice.

The setup those six jobs each repeat is now one composite action rather
than six copies of eighty lines, which is the argument compose.test.yaml
already makes about owning DATABASE_URL.

  measured, 4 workers, whole suite 4m55s

    1/4  168 tests  2.8m   account-*, admin-*, appearance*, calendar*
    2/4  169 tests  1.3m
    3/4  155 tests  1.4m
    4/4  137 tests  0.7m
    exclusive  23   1.1m

The numbers are in the matrix comment because they are the argument for
whatever the count becomes next: Playwright divides by test COUNT and
the counts are even, while the durations are not — the first quarter
holds the specs that navigate and measure layout. A run is as fast as
its slowest shard, so that block is where the next gain is, and more
shards is the only lever since there is no duration-aware split.

Two things fell out.

A pull request stops paying for superseded runs — a second commit
cancels the first one's. Tags are never cancelled; a release is the run
whose answer gets kept.

And admin-card-insets.spec.ts has been failing since v0.2.15, taking two
minutes and a CI retry to do it. It waits for a section to finish
loading by counting spinners left in the frame, and counts elements
rather than visible ones — so when the AI card grew a "Warm up now"
button whose spinner sits beside its icon, rendered and hidden, waiting
for a click, the wait started looking for something that is never
removed. Ten sections, each spending the timeout on a frame that had
loaded perfectly. Asking for visible spinners takes that test from a
120-second failure to 4.7 seconds passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 22:47:32 +02:00
dependabot[bot] ffeb7732ec build(deps): Bump the actions group across 1 directory with 12 updates (#7)
Bumps the actions group with 12 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `4` | `7` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `3` | `4` |
| [docker/login-action](https://github.com/docker/login-action) | `3` | `4` |
| [docker/metadata-action](https://github.com/docker/metadata-action) | `5` | `6` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `6` | `7` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `4` | `7` |
| [actions/download-artifact](https://github.com/actions/download-artifact) | `4` | `8` |
| [actions/cache](https://github.com/actions/cache) | `4` | `6` |
| [actions/setup-node](https://github.com/actions/setup-node) | `4` | `7` |
| [actions/configure-pages](https://github.com/actions/configure-pages) | `5` | `6` |
| [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) | `3` | `5` |
| [actions/deploy-pages](https://github.com/actions/deploy-pages) | `4` | `5` |



Updates `actions/checkout` from 4 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

Updates `docker/setup-buildx-action` from 3 to 4
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

Updates `docker/login-action` from 3 to 4
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

Updates `docker/metadata-action` from 5 to 6
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5...v6)

Updates `docker/build-push-action` from 6 to 7
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

Updates `actions/upload-artifact` from 4 to 7
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

Updates `actions/download-artifact` from 4 to 8
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

Updates `actions/cache` from 4 to 6
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v6)

Updates `actions/setup-node` from 4 to 7
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v7)

Updates `actions/configure-pages` from 5 to 6
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v5...v6)

Updates `actions/upload-pages-artifact` from 3 to 5
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v5)

Updates `actions/deploy-pages` from 4 to 5
- [Release notes](https://github.com/actions/deploy-pages/releases)
- [Commits](https://github.com/actions/deploy-pages/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/configure-pages
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/deploy-pages
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/upload-pages-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/metadata-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 12:58:26 +02:00
Paul LütznerandClaude Opus 4.8 dda61170c3 ci: rebuild the main tag on push, amd64-only, keeping arm64 for releases
Cutting the docker build to release tags stopped the `main` image tag
from ever moving again — nothing rebuilt it, and a tag build can't apply
a branch tag — so the dev-tip deployment that follows `main` froze at the
last pre-change build. Restore the push-to-main build, but choose the
platform matrix by event: a `main` push builds amd64 only (the dev-tip
deployment is amd64), a `v*` tag still builds amd64+arm64 for the release
image. Fresh dev tip without paying for arm64 on every push.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 17:16:48 +02:00
Paul LütznerandClaude Fable 5 ec70316681 ci: build the image on release tags only, not twice per release
docker.yml built the two-arch image on every main push AND every tag — and a
release is a main push followed seconds later by a tag, so the main-push image
was discarded by the tag build. It also rebuilt on every pull request, where
e2e.yml already builds the same image to test it. Both duplicates are gone;
a version and 'latest' are still published per release tag, workflow_dispatch
covers an ad-hoc build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 15:44:59 +02:00
Paul Lützner 7bc8d5d1da ci(e2e): the runner owns the paths the containers write through
The first real run got through the image build, composer install, stack
boot and browser install, then died twice on the same rule: Docker creates
a missing bind-mount source, and every missing parent of it, as root.

~/.cache/composer is mounted into the composer container. On a cache miss
nothing had created it, so Docker created ~/.cache as root as well — and
`npx playwright install` then failed with EACCES on mkdir of a SIBLING,
~/.cache/ms-playwright. The composer cache itself was fine; the parent was
not.

var/ and public/assets/ are where compose.test.yaml mounts its named
volumes, so Docker creates them in the checkout as mount points, empty and
root-owned. `mkdir -p var/log` in the log-collection step then failed,
which would have cost the artifact on an otherwise green run.

One mkdir before anything containerised runs fixes both. Preferred to
`--user $(id -u)` on the one-off containers: the image's PHP tooling
expects root, and root-owned FILES were never the problem — tar reads
mode-644 files it does not own, so the artifact upload and the cache save
were always safe. Only creating a directory inside a root-owned one
failed, and Docker never creates a directory that already exists.

vendor/ and assets/vendor/ are deliberately left root-owned: no
runner-user step reads or writes either.

Verified locally on an unprivileged user, both directions. Without the
step, `mkdir -p var/log` reproduces CI's exact error; with it, var/,
var/log and public/assets survive a full `compose up` runner-owned, the
log step writes a tar-readable e2e-server.log, and ~/.cache/ms-playwright
can be created after the composer container has run.
2026-08-10 16:28:48 +02:00
Paul Lützner 56606f2c78 ci(e2e): the suite runs against the FrankenPHP the app ships on
CI served the browser suite with `php -S` on the runner, behind a router
script written for the occasion, with PHP and its extensions installed
beside it. Every accommodation in that workflow existed because of that
server and not because of the app: a php.ini copied into the scan dir with
sudo, PHP_CLI_SERVER_WORKERS, a router so index.php did not answer 56 asset
requests with 56 kernel boots, a copy of the environment into $_SERVER
because the cli-server SAPI provides none, and finally a restart loop
because the thing segfaulted mid-suite. A green run proved something about
a server no user will ever meet, while PHPUnit ran bare on the runner in
the same job that every developer ran in a container.

It now boots compose.test.yaml — the app's own image, from this
repository's Dockerfile — and runs `npm run test:unit:docker` and the
Playwright suite against it. Local and CI are one invocation.

Three things had to be true first.

app-init could not bootstrap a checkout that had never seen composer. It
overrides the image entrypoint to get the ordering right around the
database, and that entrypoint is also what installs dependencies into the
bind mount, so a fresh clone — or a worktree, or a CI runner — died on
"Dependencies are missing" before the stack came up at all. It installs
them itself now when vendor/ is absent.

admin-queue.spec.ts and mercure.spec.ts each carried a private copy of
`docker compose -f compose.test.yaml` with no -p, so both silently
addressed the DEFAULT project. Point the suite at a stack under any other
project name and the queue seed populated a database the browser was not
looking at, while the hub stop that mercure.spec.ts uses to prove the
stream recovers stopped a project that did not exist — which compose
reports as success. That is the whole of why admin-queue was believed to
need a running worker: it never did. `delivered_at` is what "a worker is
holding it" means to the transport, and app:test:seed-queue writes it.
Both now go through E2E_COMPOSE, resolved once in playwright.config.ts.

The image needed a stable tag so CI could build it ahead of time with a
layer cache, which is why compose.test.yaml names it rather than leaving
it to `<project>-<service>`.

Deleted: the webServer block, tests/e2e/support/router.php, the Postgres
service container, setup-php and its extension cache, the php.ini scan-dir
step, and the separate migrate/seed/asset steps the stack already does.
The server log still goes up with the report — from `docker compose logs`
now, and covering app-init and Postgres too, because when the failure is
that the stack never came up that is the only place the reason exists.

Verified against a compose stack from this worktree: PHPStan clean,
PHPUnit 2820 tests / 6979 assertions green, Playwright 253 passed and
0 failed — admin-queue and both mercure recovery tests among them.
2026-08-10 16:17:28 +02:00
Paul LütznerandClaude Opus 5 f6aeb02e0d Give the landing page a way off itself on a phone
You were right, and I had it backwards. I left the burger off the landing
page on the grounds that it has no sidebar to open — but .topbar-links are
hidden at that width too, so the front page offered no navigation at all.
Somebody arriving from a link had a page and no way anywhere else. It
needs the drawer MORE than a handbook page does, not less.

The drawer is on every page now, carrying the handbook navigation plus the
bar's own links, and it is hidden above the breakpoint where the sidebar
would have no business sitting beside a hero.

That hiding is scoped to a min-width query rather than left to source
order. Written as a plain rule it sat after the narrow block, matched at
every width and won on order — so the first version of this put a burger
in the phone's markup that nothing could click, which is exactly the bug
being fixed, one layer down.

On the horizontal scrolling: the document itself does not pan, at 320,
390 or 393. What moves is a <pre>, which scrolls inside its own box by
design — a wrapped shell command is a command somebody pastes wrong. But
`overflow-x` was `visible`, so anything that overflowed in future WOULD
take the page with it. It is `clip` now, not `hidden`: hidden would make
the element a scroll container and silently break the sticky top bar,
which is the thing the fix was protecting.

And the image build skips documentation. A handbook page, a translation
or a change to the site tooling cannot alter the image, and rebuilding it
for two architectures to publish identical bytes is the most expensive way
to do nothing. CHANGELOG.md is deliberately not in the ignore list — a
release commit always touches it, so the release path can never be
filtered out by a rule aimed at prose — and workflow_dispatch is there for
the case that reasoning misses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 08:40:56 +02:00
Paul LütznerandClaude Opus 5 6786c2ea7d Run the browser suite for releases, not for every commit
E2E is the expensive workflow here — a Postgres service, PHP with eight
extensions, a Chromium download, an asset build, PHPStan, PHPUnit and
around two hundred browser tests — and it ran on every push. Most of that
capacity went on proving the same thing repeatedly about commits nobody
was releasing.

It now runs on a `v*` tag, on a pull request against main, and on demand
through workflow_dispatch. Pull requests are kept because that is somebody
proposing a change, which is exactly when a second opinion is worth
having, and there are few of them.

**The trade is real and is written down in both places rather than left
to be discovered.** `main` is no longer verified commit by commit: a
regression pushed there is found when a version is tagged, possibly
several commits later, and the tag is then what fails. Running the suites
locally before pushing is the answer, and it is faster than waiting for a
runner in any case.

One sharp edge, now stated in CONTRIBUTING: a tag starts this workflow and
the image build in parallel, so a failing suite does not stop the image
being published. Tag from a tree the suites have already passed on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:57:51 +02:00
Paul LütznerandClaude Opus 5 e6d0e9ca5d Keep the reader in the language they chose, and publish faster
**The picker was not the bug; the navigation was.** Every sidebar link on
a German page was built against the English path, so choosing German
lasted exactly one click and then put you back in English. The sidebar
and the Handbook link now follow the page's own language.

**And the choice survives arriving somewhere else.** It is remembered, so
a bookmark, a link from somebody else or a fresh visit lands in the
language you picked rather than resetting to English every time. The
redirect cannot loop — it lands where the page's language already equals
the remembered one — and it cannot strand anybody, because choosing
English is what sets the memory to English. The landing page is exempt:
following the memory there turned every visit to the site into a redirect
into the handbook, which is a redirect away from the page somebody asked
for rather than a language preference.

tests/e2e/site.spec.ts covers all of it — the two bugs you found by
clicking, plus the three ways the fix could go wrong. It is opt-in behind
E2E_SITE (`npm run test:e2e:site`) and does not run in CI: it needs a PHP
build and eight browser pages, and what it guards is a static site whose
failure is visible the moment anybody opens it.

**Publishing is faster.** The Pages job ran composer.json's auto-scripts
on every docs change — cache:clear, assets:install and importmap:install,
which is a Symfony kernel boot and a set of CDN downloads for JavaScript,
to render Markdown. --no-scripts, plus a download cache. The wiki job
needs no Composer at all and never did: bin/mirror-wiki.php has no
dependencies, which is now said out loud so nobody adds one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:54:41 +02:00
Paul LütznerandClaude Opus 5 972a8a9d3f Give plMail a landing page, built from the repository it describes
A wiki is where somebody already inside GitHub looks. It has no way to be
the thing a link to plMail shows a person who has never seen it, so there
is now a site as well: a landing page and the whole handbook, at
karatektus.github.io/pl_mail, deployed by .github/workflows/pages.yml on
every push to main.

It holds no content. The landing page IS README.md and the handbook IS
docs/, rendered into a shell by bin/build-site.php — nothing on the site
can say something the repository does not, because there is nowhere for
it to be written down. Even the hero is lifted rather than written: its
lead is README's own opening paragraph, because a hero composed here
would be the one piece of prose no file backs and the first thing to go
stale. The wiki mirror stays; two outputs of one source cannot disagree
with each other.

The tree is preserved rather than flattened, unlike the wiki mirror,
which had to flatten because a wiki has one namespace. So a cross-link
between two pages needs only its extension changed and an image reference
needs nothing, and the rewriting that remains is the small honest kind: a
path that escapes docs/ becomes a link into GitHub, because the site
carries the handbook and not the source.

**The theme picker is generated from the app's own enum.** Theme::swatch()
already carries surface, ink and accent per theme for the appearance
picker, and the rest of a palette is derived from those at build time —
so the site offers exactly the seven plMail offers, and adding an eighth
to the app adds it here. Transcribing them would have agreed until
somebody added one, and then the site would quietly offer six of seven.

The language picker lists a locale as available when docs/<locale>/
exists and as "not translated yet" when it does not, which makes
translating the handbook the act of creating that directory and nothing
else. Today that means English, with German and Pirate shown and
disabled — a switcher that silently omitted them would say the site has
no opinion about German, where this says where the gap is.

Search is the whole handbook as 140KB of JSON, fetched on first focus and
matched in the browser. A hosted index would be faster to write and would
stop the site being a directory of files that works from a checkout, from
a fork's Pages, and behind whatever proxy somebody puts it behind.

league/commonmark is a require-dev dependency: the site is built in CI and
never by the application, so a Markdown parser in the runtime image would
be a package nothing runs. Bumped guzzlehttp/guzzle to 7.15.2 while
composer was open — 7.15.1 carries two advisories, one of which is a
noncanonical host bypassing host-based checks, which is the shape of the
SSRF guard.

Also corrects this file's claim that the non-Docker Playwright variants
start `symfony serve`. They have not since that server was replaced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:54:19 +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
Paul LütznerandClaude Opus 5 085c4130aa Serve the browser suite from PHP, not from the Symfony CLI
103 Playwright tests failed on CI and none of them were broken. The
Symfony CLI detects running Docker containers and injects its own
environment into the app it serves, overriding DATABASE_URL even when one
is explicitly set. On CI the Postgres service is a container, so the URL
the workflow sets was replaced with credentials nothing accepts, the app
could authenticate nobody, the per-worker login fixture died, and every
test in that worker fell over behind it. PHPUnit and every bin/console
step passed in the same job, because none of them go through the CLI.

    DATABASE_URL=postgresql://app:app@127.0.0.1:5432/app \
      symfony php -r 'echo getenv("DATABASE_URL");'

SYMFONY_SKIP_DOCKER_COMPOSE, SYMFONY_SKIP_DOCKER_SERVICES and
SYMFONY_DOCKER_ENV were all tried; none of them stop it, and there is no
--no-docker flag. So the suite now starts PHP's own server, which has no
detection to disable. The CLI install goes with it: it was there for
`symfony serve` alone.

Two things that server needs and does not do by itself. Naming
public/index.php as the router makes index.php answer everything,
including the 56 compiled assets /login pulls in — each one a kernel boot
returning 404 — so tests/e2e/support/router.php returns false for real
files and lets the server serve them off disk. And the cli-server SAPI
builds $_SERVER from the HTTP request and stops: getenv() answers,
$_SERVER does not, and Dotenv reads $_SERVER. Without the bridge in that
router, DATABASE_URL would silently revert to the committed default in
.env — the same failure as above arriving by a different road. HTTP_* is
excluded from the bridge, because $_SERVER['HTTP_PROXY'] is
indistinguishable from a Proxy header once a Request is built.

The server's log is redirected to a file and uploaded with the report
rather than piped to the console: it writes three lines per request, and
one slice of the suite produced 67,709 of them, which is how a real
failure gets buried. It is still the only place a PHP fatal that never
reached the kernel is recorded, so it is kept rather than silenced.

One onboarding test now retries opening the user menu. The menu is opened
by a Stimulus controller, and Playwright's actionability checks say
nothing about whether a controller has connected — the button is visible
in the server's HTML long before the module graph that animates it
arrives, and a click that lands in that gap is simply lost. FrankenPHP
served those 56 assets fast enough to hide it; this server does not.

Verified against a CI-shaped stack — host PHP, Postgres in a container,
the workflow's env block, this config starting the server: 185 passed,
14 skipped, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 13:46:08 +02:00
Paul LütznerandClaude Opus 5 25f1cf74c8 Say which build this is, in the admin header
Migrations run on boot and `latest` moves, so "is this the build I think
it is?" is a question with real consequences and no cheap answer. The
release and the commit now sit in the admin header — in the header
rather than in a panel, because it is asked while looking at something
else.

Stamped at image build and never discovered at runtime. A container has
no .git: the source is copied in and the history stays behind, so
anything that shelled out to git in production would answer from a
mounted volume or from nothing. The release workflow passes both values
from the metadata action it already computes; a plain `docker build`
passes neither, and an image nobody stamped says "development" rather
than guessing, which is also why the chip is absent entirely on a
checkout — a permanent "development" badge is furniture.

There is deliberately no test that a real checkout describes itself.
That would assert a property of wherever the suite runs rather than of
the class: the test container has the source but not the history, and
where it has both, git refuses the directory for dubious ownership — so
it passes on a laptop, fails in CI, and says nothing either way. This
suite has been bitten by that exact shape twice already (an id sequence
gap that only existed on a well-used database, a sidebar state that only
existed after another spec ran). The contract is tested instead: an
unreadable repository means "development".

config/reference.php is gitignored and untracked. Symfony regenerates it
on every cache warm-up and not deterministically — two keys inside one
@psalm-type block swap places — so a tracked copy showed a phantom diff
after any command that booted the kernel, all session. Its own header
says it is auto-generated and for apps only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 12:56:23 +02:00
Paul LütznerandClaude Opus 5 0fdcab067e Put the app's PHP ini where FPM will actually read it
The previous commit copied 10-app.ini into the scan dir reported by
`php -r PHP_CONFIG_FILE_SCAN_DIR`, which is the CLI's. The step passed
and changed nothing: `symfony serve` switches to PHP-FPM as soon as it
finds public/index.php, and the CI log confirms it —

    [Web Server] PHP listening path="/usr/sbin/php-fpm8.4"

so the requests under test were served by a SAPI reading
/etc/php/8.4/fpm/conf.d, not the cli one. The 3MB upload test went on
failing at stock 2M.

Symfony CLI's documented per-project override is a php.ini at the
project root, which it hands to FPM. Measured in an ondrej-PPA
container, serving through symfony serve exactly as CI does:

    baseline          sapi=fpm-fcgi upload=2M
    cli scan dir      sapi=fpm-fcgi upload=2M    <- the previous commit
    project php.ini   sapi=fpm-fcgi upload=25M
    fpm scan dir      sapi=fpm-fcgi upload=25M

Both copies are kept, because both SAPIs are in play: FPM serves the
browser suite, and the CLI runs PHPStan and PHPUnit. The CLI needs its
own, since that SAPI deliberately ignores a php.ini in the working
directory — verified rather than assumed.

php.ini is gitignored: CI writes it, and a committed one would quietly
override these limits for everyone from a file nothing points at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 20:32:07 +02:00
Paul LütznerandClaude Opus 5 c51c405c00 Load the app's PHP ini in CI
The compose suite has a test that attaches a 3MB file, and it has never
passed on GitHub Actions — not since the commit that introduced it.

frankenphp/conf.d/10-app.ini is what lifts PHP's stock 2M/8M upload
limits to 25M/60M, and it reaches PHP through a COPY in the Dockerfile.
CI never builds that image: it serves through `symfony serve` against
setup-php's interpreter, which loaded none of it. So the runner sat at
upload_max_filesize=2M, PHP refused the 3MB upload before the request
reached Symfony, and the attachment chip the test waits for was never
rendered. e7b9ac8 added the test and the ini bump together, which is
why it looked like it should work — the two halves just landed in
environments that do not overlap.

Copying the file rather than restating the values in `ini-values` keeps
one source for both environments, and covers every other setting in it
for the next time one of them starts mattering to a test.

Verified by forcing a container back to 2M: same locator, same failure,
same artifact path as the CI run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 20:20:32 +02:00
Paul LütznerandClaude Opus 5 71d5474b5d Set the Node version in one place
.nvmrc said 22, the CI workflow pinned 20, and the machine the browser
suite was actually developed and run on had 24. Three numbers, none of
them agreeing, which means "passes locally, fails in CI" had three
plausible explanations before anyone looked at the code.

.nvmrc is now the single source — CI reads it with node-version-file —
and it says 24, which is what the suite has been passing on.
package.json's engines floor stays at >=18: that is a genuine minimum
rather than a choice, and there is no reason to raise it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 15:45:26 +02:00
Paul LütznerandClaude Opus 5 dc0647f23f Publish latest from release tags, add a changelog, unstale the roadmap
latest followed the default branch, so every push to main went straight
to the tag the README tells people to pull — including work in progress,
and migrations that run automatically on boot. It now moves only on a v*
tag; main still publishes under its own name for anyone wanting the tip.

The roadmap listed three finished things as open, including the label
refactor and the nested label UI, both of which shipped. A roadmap that
under-reports is worse than none: it is the document a contributor reads
to decide what to pick up. Corrected, with the genuinely open items
described accurately and a note asking that it be kept honest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 12:28:42 +02:00
Paul LütznerandClaude Opus 5 ddccc8fa2e Add PHPStan and Dependabot, and fix two bugs they found
55k lines of PHP with no static analysis, and a dependency tree holding
four OAuth clients, a JWT library and an IMAP parser with nothing
watching it for advisories.

PHPStan at level 5 with a baseline. Level 5 is where the class of defect
this codebase actually has starts showing up — nullable entity getters
flowing into non-nullable parameters — without a fortnight of triage
first. 208 findings are baselined as a debt ledger, not a licence; the
level goes up when the ledger is small enough that raising it is a day's
work.

Two of the 217 were real bugs, both fixed here rather than baselined:

"Test connection" in account settings constructed ConnectionTestResult
with four arguments where six are required, and passed null for a string
parameter. Both call sites are the early returns for an incomplete form
and a blank password — that is, the two cases the button exists to
report, each of which raised an ArgumentCountError instead.

GraphCategorySyncer caught App\Service\Mail\GraphApiException, which does
not exist; the client throws App\Domain\Exception\GraphApiException. The
catch never matched, so the 403 that means "this account needs
reconnecting with MailboxSettings.ReadWrite" propagated as an unhandled
error instead of degrading quietly, which is what the comment inside it
says it is there to do.

One more it found is left alone deliberately, because it needs a decision
rather than a fix: Controller/Admin/User/DefaultController references
App\Form\Admin\UserFormType and User::setName(), neither of which exists,
so that admin page fatals on load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 12:28:32 +02:00
Paul Lützner 7ec1f56ee7 test speedup 2026-07-31 08:37:13 +02:00
Paul Lützner f0e6782f28 now fix docker 2026-07-31 05:12:08 +02:00
Paul Lützner d0685d0ee6 update 2026-07-28 12:33:46 +02:00
Paul Lützner edfb189b23 cached - this should be fast 2026-07-27 13:25:16 +02:00
Paul Lützner 664efa7d29 enable gha cache 2026-07-27 13:22:32 +02:00
Paul Lützner 4f57efa479 updates 2026-07-26 10:58:31 +02:00
Paul Lützner 06aeb7db3c basic compose tests 2026-07-22 10:29:28 +02:00
Paul Lützner ad406ec2ab test fix 2026-07-21 17:05:28 +02:00
Paul Lützner 9415c9591e more tests 2026-07-21 17:03:02 +02:00
Paul Lützner 646b036dc5 fix tests 2026-07-21 16:25:30 +02:00
Paul Lützner 75ab0cedee fix tests 2026-07-21 16:22:53 +02:00
Paul Lützner 517bd5d345 testing 2026-07-21 16:18:46 +02:00
Paul Lützner b290a551d0 gh actions fix 2026-07-20 15:05:06 +02:00
Paul Lützner e30b38b706 docker update 2026-07-20 14:55:18 +02:00