25 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 122c8e56f3 Publish a GitHub Release for every v* tag, with its CHANGELOG section as notes (#240)
A tag gave a feed reader nothing but a version number: there was no
releases.atom and nothing for "Watch → Releases" to notify about. A new
`release` job in docker.yml runs after the image is published, cuts the
tag's section out of CHANGELOG.md with bin/release-notes.sh (unwrapped,
since a release body shows every newline), and creates the release, or
rewrites its notes on a re-run.

Discussion #226, asked by @adrianfurgol.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 06:42:05 +00:00
Paul Lützner e23377eb6a Migrate test databases with app:db:migrate so the cache tables exist (#225)
doctrine:migrations:migrate leaves cache_items out; without it the first cache read inside a test's transaction aborts it, so ThreadSummaryTest and the Gmail import tests failed whenever nothing had created the table first.
2026-10-19 13:51:50 +02:00
Paul LütznerandClaude Opus 5 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 24a5ab3de1 build: mercure 0.8, and a database of PHPUnit's own
MERCURE 0.5.0 / symfony/mercure 0.8.0, kept out of the earlier sweep
because it carries the live-update transport and should not ride along
with eighteen patch bumps. It earned that: HubInterface gained
getProtocolVersion() and getCookieName(), so the two hub doubles in
CalendarSyncFinishedNotificationTest stopped satisfying it — a fatal
error, not a failing test. Both implement them now, returning what a real
hub would rather than pretending to configuration these tests do not
have. Verified where it matters: the full browser suite, including the
specs that stop and restart the hub to prove the stream recovers.

A DATABASE OF ITS OWN for the unit suite.

Both suites run APP_ENV=test, and doctrine.yaml's
`dbname_suffix: '_test%env(default::TEST_TOKEN)%'` therefore pointed both
at app_test. One database, two suites — and one test in here truncates
real tables, because exercising a data reset is its whole purpose.

Sequentially that is safe; Postgres rolls a TRUNCATE back like anything
else. Concurrently it is not: TRUNCATE takes an ACCESS EXCLUSIVE lock, so
live queries from the browser suite block until the unit test's
transaction ends, and specs with no connection to any of this report
timeouts. It fits the transaction abort seen here once and never
reproduced.

TEST_TOKEN is Symfony's own mechanism for exactly this, so the fix uses
it rather than inventing a second DATABASE_URL. bin/test-unit.sh sets it
and does the create/migrate/seed a fresh database needs.

The seeding is the part worth guarding: PageRendersTest, ListFragmentTest
and others SKIP THEMSELVES when the admin user is missing, so without it a
green run would quietly be ~50 tests smaller than it looks — a worse
failure than a red one. Checked rather than assumed: 4047 tests either
way, no skipped or incomplete markers.

The assertion count moved, 11540 → 11526, and settled there across runs.
That is the point of the change showing up: the unit suite was reading
rows the browser stack had left behind, and now it reads only its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 15:08:17 +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 5bda9e9db8 Stop the landing page being 1331 pixels wide on a phone
Two causes, and the first is the kind of bug that hides behind a
reasonable-looking line.

`.layout` carries `align-items: flex-start`, which is right in the row
layout — it stops the sidebar stretching to the article's height. On a
phone the same container becomes a column, and there `flex-start` no
longer means "top": it means "as wide as your content". So <main> sized
itself to its widest unbreakable line and came out 1331px wide inside a
393px viewport. The handbook did not show it because .prose caps at 46rem
there, so it was merely wrong by less — which is why the phone tests
passed while the front page was unusable.

The second was a long URL inside inline code. It has nowhere to go and
pushed a 320px phone over by seven pixels. Inline code may break now;
code inside a <pre> deliberately may not, because that block scrolls and
a wrapped shell command is a command somebody pastes wrong.

The overflow check now runs over the landing page, the handbook index, a
long install page and a German provider page rather than one page that
happened to be fine, and reports how many pixels over it went. Both
failures were silent, and a page that scrolls sideways looks broken before
a single word of it is read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 08:32:50 +02:00
Paul LütznerandClaude Opus 5 5d83c4d5e9 Make the site usable on a phone
Three things, and the middle one is the reason the other two matter.

**The sidebar was a wall.** Below the breakpoint it became a full-width
block ABOVE the content, so reaching the first paragraph of any page meant
scrolling past thirty-four links. It is an off-canvas drawer now, opened
by a burger, closed by the burger, the scrim, Escape, or following a link
— which navigates, and closing first stops the old page's drawer
animating away over the new one.

**Both pickers moved into the foot of the drawer.** Four controls do not
fit across a phone: with all of them in the bar the search box was
squeezed to twenty-four pixels, which is not a search box. They are moved
rather than duplicated, because two copies means two elements sharing an
id and neither picker working, and moved by script because no stylesheet
can reparent a node. The bar now holds a burger, a wordmark and a search
box.

**Zoom.** The viewport carries user-scalable=no as asked, and it is worth
knowing that is half a measure: iOS Safari has ignored it since iOS 10,
and where it IS honoured it is a WCAG 1.4.4 failure. What actually stops a
phone zooming unbidden is the 16px minimum on inputs — Safari zooms the
page to focus anything smaller — so that is the part carrying the weight.

Covered by tests/e2e/site.spec.ts, still opt-in and still not in CI. Two
things worth recording from writing them: `devices["iPhone SE"]` is the
FIRST-generation SE at 320×568, so a tap coordinate written as 350 was
off-screen and silently hit nothing; and the scrim covers the viewport, so
its centre is under the open drawer and a locator click is refused. The
tap is derived from the drawer's own edge now.

One self-inflicted wound worth naming: rewriting the narrow-screen block
by slicing from the media query to end-of-file also deleted the picker and
fallback-notice rules that had been appended after it, which put "Theme"
and "Language" on screen as visible text. Found by looking at a
screenshot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 08:26:26 +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 4d3c1266ad Send the wiki and the language picker where they meant to go
Two things German broke, both mine.

**The wiki build.** bin/mirror-wiki.php resolved a translated page's links
against its own directory, where bin/build-site.php and
DocumentationCoverageTest both resolve against the English tree. It was
the odd one out, and nothing noticed until there was a translation to be
odd about: from docs/de/features/, `../screenshots/inbox.png` is
docs/de/screenshots/ and does not exist, and from docs/de/install/,
`../../CONTRIBUTING.md` lands one directory short of the repository root.
Twelve links, and the check refused to publish — which is the check doing
its job, so this is a fix to the script and not to the gate.

Having resolved through the English tree, a link now lands on the
reader's own language wherever that page exists. Otherwise every link in
the German handbook would have walked them back into English.

**The language picker did nothing on the landing page.** It rewrites the
/docs/ segment of the path, and the landing page has none — so the switch
matched nothing, the browser went to the identical URL, and it looked
like a reload. The landing page is README.md and has no translation, so
there is no German counterpart to send anybody to; it now goes to the
handbook's index in that language, which is where somebody changing the
language on a front page was headed anyway.

Both were found by looking rather than by the suite: the wiki failed
loudly in CI, and the picker was reported by the person clicking it.
Neither is covered by a test, which is worth saying — the render check
catches a dangling link but not a link that resolves somewhere useless,
and nothing at all exercises the picker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:46:31 +02:00
Paul LütznerandClaude Opus 5 629d937d97 Translate the handbook into German
All 34 pages, under docs/de/. The site serves them at /docs/de/, the
language picker switches between them, and a page that ever falls behind
can simply be deleted — the English falls back with a banner saying so.

The handbook duzt. plMail is something a person runs for themselves on
their own machine, and a manual that siezt is describing a different
product. That correction arrived after four of the six translations were
drafted, and those pages were rewritten rather than patched: a handbook
that switches register halfway is worse than either choice consistently.

Two defects in the coverage test, both found by translators hitting them:

  The link check resolved a translated page against the English tree by
  replacing '/docs/de/' inside the DIRECTORY — which never fires for a
  page sitting directly in docs/de/, whose directory has no trailing
  slash. Every page one level down passed and only the language's own
  index failed, so the bug was invisible in exactly the files anybody
  writes first. Derived from the relative path now.

  The traps-section exemption was keyed to literal English paths, so
  docs/de/CLIENT_DEVELOPMENT.md was required to carry a section its own
  source is excused from. A translator dutifully invented one, which is
  the failure mode worth naming: a test that demands nonsense usually
  gets it. The exemption follows a page into its translations now, and
  the invented section is gone.

And one in the site builder, which was breaking English pages too. Links
carrying a fragment were never rewritten from .md to .html — the greedy
target group swallowed the '#', realpath could not resolve what was left,
and the link was emitted untouched. Every cross-page anchored link in the
handbook 404'd while the same link without an anchor worked.

The site's chrome is translated too. A German page framed in an English
search box says the translation was an afterthought.

plMail's own German is now consistent with it: fifteen strings in the
calendar and integration screens siezten while the rest of the catalogue
duzt. They were written today, which is how they diverged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:32:41 +02:00
Paul LütznerandClaude Opus 5 af3265a045 Make a translation possible, and default the site to Solar
Three things, all in service of the German handbook that follows.

**A language is a directory.** docs/<locale>/ with the same paths as the
English tree, and the site renders a language from the ENGLISH page list,
falling back to the English page for anything not translated and saying so
in a banner in the reader's own language. Without that fallback a language
is all-or-nothing, which means it is never begun; with it, the first page
translated is the first page somebody reads in German and the rest still
resolve. It also makes deleting a bad translation a safe fix rather than a
hole.

**A translation says what it was made from, and a test checks.** Each
translated file opens with `<!-- translated-from: <path> sha1:<hash> -->`
and DocumentationCoverageTest re-hashes the English. This is the only
check here aimed at truth rather than inventory, and it gets at it
sideways: nobody can test whether a German paragraph still describes the
code, but a translation made from a page that has since changed is a claim
about the past, and that is checkable. A stale translation is worse than a
missing one — the missing page admits it, the stale one is fluent and
wrong.

A content hash, not a git blob hash and not a commit date. Dates mark
every translation stale after a whitespace sweep, and git is unusable from
the test container, which refuses this repository for dubious ownership —
a failure this suite has already had once.

The other checks are now locale-aware: a translated page is not required
to appear in the English index (its position is its path), its traps
section is looked for under the fixed heading its language uses, and its
links resolve against the ENGLISH tree — which is what the site does too,
and what stops a partial translation demanding that every page it links to
is already translated.

**Solar is the site's default theme**, and "System" now means something.
There was a [data-theme="dark"] block and no prefers-color-scheme query at
all, so System was light on every machine — a switcher option that
silently did nothing. The media query is scoped :not([data-theme]) so an
explicit choice still out-ranks the operating system, or Solar would lose
to a dark-mode laptop.

The README now points at the handbook above the fold, phrased as a link to
the handbook rather than to "our website" — the same line is the top of
the landing page that README generates, where "visit the site" would be
addressed to somebody already on it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:10:44 +02:00
Paul Lützner 3f9f5e34b9 Make /docs/ a page rather than a 404
The handbook's index rendered as docs/README.html, which is a faithful
translation of the file name and the wrong URL: every "Read the handbook"
link points at the directory, and so does anybody who trims a page's URL
back a level. Both got GitHub's 404.

README.md in the docs root now becomes index.html, and the link rewriter
applies the same rename to whatever pointed at it — otherwise the pages
that link back to the index would all point at the file that no longer
exists.

Found by asking for the URL rather than trusting the deploy's green tick.
2026-08-05 21:56:57 +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 36bc791f93 Measure the JavaScript, and cover the three controllers that had none
Playwright collects V8 coverage when E2E_JS_COVERAGE is set, one raw file
per test, aggregated by bin/js-coverage.mjs. Only this application's own
scripts are counted — Stimulus and Turbo are in the same importmap and
would otherwise dominate a number nobody here can act on.

The aggregation is the part worth reading. V8 nests its ranges: a
function that ran reports one span with a count, and the branches inside
it that did not as zero-count spans within it. So the covered set has to
be resolved per test and only then unioned. Pooling the dead ranges
across tests instead reads "this block did not run in test A" as "this
block never runs" — and since the importmap preloads every controller on
every page, every spec that merely loaded a module cancelled the specs
that exercised it. It reported 28.2% and did not move when three specs
were added, which is what gave it away. Corrected: 63.1%.

Three new spec files, for three controllers with no browser test at all:

  search        the operator dropdown and what the parser does with an
                operator it cannot honour — the difference between "no
                results" and "your whole mailbox". 22% to 90%.
  admin-queue   what a worker is holding, and that the filter asks the
                server rather than sifting the page already fetched.
                0% to 79%, with app:test:seed-queue to give the panel
                something to be about.
  filters-scope removing the last condition, and the sentence naming the
                account once one is chosen. 18% to 77%.

One bug, found the way browser tests are supposed to find them: the
search box is an <input type="search">, which Chromium clears natively
on Escape — so "first Escape closes the list, second clears the box" had
never worked. The close branch preventDefaults now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:50:37 +02:00
Paul Lützner a947c4aaac theming done for now 2026-07-23 16:09:03 +02:00
Paul Lützner 3b10b52488 theme update 2026-07-23 14:57:04 +02:00
Paul Lützner 7c8d0de52d update 2026-07-23 14:34:56 +02:00
Paul Lützner efad90b3de theme update 2026-07-23 14:28:06 +02:00
Paul Lützner ec29fc7479 new sweep 2026-07-23 14:19:14 +02:00
Paul Lützner 7a1170b643 update theme 2026-07-23 14:17:49 +02:00
Paul Lützner 162fec0944 theme step1 2026-07-23 13:53:37 +02:00
Paul Lützner 55862ccb2f Add webapp packages 2026-06-24 14:53:43 +02:00
Paul Lützner db8b7fbac5 Add initial set of files 2026-06-24 14:51:50 +02:00