76 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
jo2904andClaude Opus 5.5 5550727888 AppLocale : proposer le français
Ajoute 'fr' à AppLocale et à framework.enabled_locales, qui doivent
rester synchronisés (le commentaire de translation.yaml le dit). Le
sélecteur de langue des réglages et celui de l'installation lisent
AppLocale::cases() : « 🇫🇷 Français » y apparaît sans autre
modification.

Deux réglages suivent la langue et devaient connaître le français :
- ClockFormat::forLocale() : le français écrit 14 h 00, donc 24 heures
  par défaut comme l'allemand. Sans ça, un utilisateur francophone qui
  n'a rien choisi verrait « 2 PM ». Testé dans ClockFormatResolverTest.
- Le sélecteur d'emoji charge ses noms et mots-clés par langue (la
  recherche « cœur » ne trouve rien dans les données anglaises).
  assets/emoji/fr/data.json vient de emoji-picker-element-data, comme
  en/ et de/. J'ai vérifié que ces deux fichiers sont identiques octet
  pour octet à ceux publiés par le paquet, donc fr/ vient bien de la
  même source. Le gabarit choisit maintenant de, fr ou en d'après la
  langue au lieu du seul test « starts with 'de' ».

Laissé tel quel : DeterministicDateDetector, qui lit les dates dans le
contenu des mails selon leur langue, et non la langue de l'interface.
Apprendre les dates françaises (« le 4 août ») est un autre chantier.

Docs : README, features/appearance.md (et sa traduction allemande, avec
le hash mis à jour), et une entrée de CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018nYWPc4bWacb79hm4ytfni
2026-10-13 23:33:38 +02:00
Paul LütznerandClaude Opus 5.5 8f7d2cb073 Work through the first-impression feedback in #37
Eleven of the twelve items. The twelfth, a deleted label coming back
after the next sync on IMAP, is left for an issue of its own: there a
label is a folder, and what deleting one should do to the mail in it
needs deciding first.

A selection survives the pager. It was nothing but the ticked
checkboxes, so "next page" replaced the rows and the selection with
them. The rows that are not on screen are now remembered in
assets/mail_selection.js, per list, and the toolbar, the label menu,
the move menu and a drag all read the selection from there. The
checkbox stays the truth for anything visible.

The whole view can be selected from the select menu. It was only
offered by a banner that appears once a page is fully ticked. And a
whole-view selection no longer survives a row being unticked: the
action still ran on everything.

A sync no longer logs a stored message as one it could not build.
MessageSyncer reads the UIDs a folder holds once, before it fetches,
and a move made meanwhile files rows under UIDs that list does not
have. The INSERT was refused on (mailbox, uid), rightly, and each
message was then held back as a failure. The list is read again when
a batch is refused.

A pairing code and the JMAP address under App passwords carry the
configured public address, and the browser's only when none is set.
This reverses the earlier choice in DevicePairingController, which
handed a phone paired at home a LAN address.

Hiding quoted text gives the height back. scrollHeight cannot report
less than the frame it is measured in, so the collapse reports the
root element's box instead and holds other reports until the parent
has resized.

Mail in Sent shows its images without asking. Read off the Sent label
and not the From line, which a forged mail fills in as it likes. Spam
still wins.

Smaller: placeholders are lighter than typed text, the actions on a
connected integration are buttons on one baseline, Save signature and
four composer buttons no longer hover to white on white, the Spam
badge loses the accent, and German says "vor 12s".

Refs #37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:51:39 +02:00
Paul LütznerandClaude Opus 5.5 219fc9e283 Keep a mail's styles from drawing over the app around it
Inline styles survive the sanitiser on purpose: in mail they are the
design. In the reading pane that costs nothing, because the message
is in a sandboxed frame. The quote under a reply and the print page
are the sender's HTML in the app's own document, and there
`position: fixed` on a full-size element covered the composer — or
the whole window — with a page of the sender's choosing the moment
somebody pressed Reply. Reported in #34.

Two layers, since either alone has a hole:

  the sanitiser   drops `position: fixed` and `sticky` from every
                  body, and `absolute` and `z-index` too from one
                  being composed. Absolute stays for mail that is
                  only read: real newsletters use it, and in the
                  frame it can only rearrange the message.
  the stylesheet  the composer's editor and the print page's body
                  are containing blocks that clip. This is what
                  holds for a body stored before the sanitiser
                  changed, or a declaration written some way the
                  pattern does not anticipate.

Refs #34

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:03:51 +02:00
Paul LütznerandClaude Opus 5.5 09600802db Let a message be written once and inserted as a template
Settings → Templates is a new section: a tree on the left, one editor
beside it. The tree opens with one folder per mail account, and those
are not stored — an account folder is the account, drawn as a folder,
so the mirror cannot drift. Folders the user makes go inside an
account, inside one another or beside the accounts. Removing an
account or a folder keeps the templates in it and moves them up.

A template holds variables: the recipient's first name, name and
address, the sender's name and address, a signature, and dates with an
offset and a format of their own. They are stored as text tokens in
the body, because the body goes through the inbound-mail sanitiser and
a marked element would not survive it; the editor draws them as chips
and turns them back before it posts.

The compose window gets a button beside the signature one. Dates,
sender and signature are filled in by the server when a template is
inserted. The recipient is not: a template is usually picked before
anyone is addressed, so recipient variables arrive as markers and the
window fills them when To has somebody in it, and asks before sending
with one still open. A template's signature replaces the window's
rather than joining it, and one the template names is pinned so a
change of From leaves it alone.

  TemplateTokens     the notation, read the same way in PHP and in
                     assets/compose/template_variables.js
  TemplateRenderer   what each variable becomes, and who fills what
  TemplateLibrary    the tree, and the only writer of where a
                     template or folder sits

One bug fell out of running it in a browser. Chromium will not put a
caret between a non-editable element and the edge of its block, so a
template ending in a variable could not be given its full stop — the
keystrokes were dropped. The editor keeps a zero-width space beside
each chip and strips them before saving.

Two new tables, mail_template and template_folder. Web only so far:
nothing is published over JMAP yet.

Refs #26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:27:17 +02:00
Paul LütznerandClaude Opus 5.5 8ad3e83c52 fix(ui): the last hard-coded colours in the templates use the theme's tokens
Four kinds, each of which ignored the theme it was drawn in:

- Checkboxes and radios asked for the accent through `text-accent`. A
  native box is coloured by accent-color; `color` reaches it only
  through @tailwindcss/forms, which this app does not load, so every
  one of them was ticked in the browser's own blue. They use
  `accent-accent`, and modal_form_theme says why.
- Icons and icon buttons in admin, settings and the thread row's hover
  actions were Tailwind's zinc-400 and gray-400, with dark: variants and
  grey hover washes. They are ink-faint, with hover:text-ink and
  hover:bg-hover, or hover:bg-danger-soft for the destructive ones.
- The state pills on bg-raised wrote ink-faint, a tier held to 4.5:1 on
  the surface, which the raised tint under them takes as low as 3.85:1
  (14 of 39 themes under 4.5). They use ink-muted.
- Accent buttons wrote text-white, which a light accent cannot carry.
  They use the accent's own ink.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:29:34 +02:00
Paul LütznerandClaude Opus 5.5 dd6bd5a62b fix(ui): drop the compose window's undeclared formErrors target
compose--compose never declared or read it, so the attribute promised a
hook that did not exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:18:25 +02:00
Paul LütznerandClaude Opus 5.5 3bff24340a feat: the picker for send-later and booking hours; month arrows
calendar--when now takes three shapes: start and end with days (the
event editor), one date and time (compose's send later, bounded by the
field's min and max), and times only (a booking page's daily hours).
The shared attributes and words live in calendar/_when.html.twig.

Also:
- the month view has previous/next arrows, stopping at the bounds
- "Quarter hours" rounds a :05 to the nearest quarter and closes;
  it used to reopen at once because the minute sat between quarters
- the all-day label hugs its text, so the empty row under the picker
  no longer ticks it
- "Today" becomes the weekday when the strip is narrow, asked of the
  strip's own width
- ui--datetime, the interim text time field, is gone with its last use

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 17:30:49 +02:00
Paul LütznerandClaude Opus 5.5 6ec93e098a fix: time fields follow the 12/24-hour setting, not the browser's locale
The event editor, the custom send-later time and the booking page's
hours used native datetime-local / time inputs, which print the time in
the browser's locale whatever the page says: "03:00 PM" for someone
who chose the 24-hour clock.

ui--datetime keeps the native input as a hidden value carrier (same
name, id, ISO value, targets and actions) and draws a native date field
plus a text time field printed by the clock setting and read liberally
("15", "1530", "3pm", "3:30 p"). Edits fire input/change on the
carrier; writes to it redraw the fields; focus and aria-invalid are
passed through; the label is re-pointed and the time field is named
"<label>, time".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 15:01:40 +02:00
Paul LütznerandClaude Opus 5 aebae962a3 Release v0.2.24
Give a joined control's halves the group's own corners.

v0.2.23 made the focus ring inset, which stopped the clip deleting it, and
stopped there: an inset ring on a square box is still a square ring, and the
group's 7px inner radius erased 3.39px of the 2px band at each outer corner.
The highlight arrived with its corners bitten off.

The halves are rounded to match the group now — chip face and save button,
both halves of the Send pill, the first and last segment of the calendar view
switcher, the last of those read off the segments actually rendered rather
than off loop.first, which counts candidates a caller may not publish.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:41:48 +02:00
Paul LütznerandClaude Opus 5 d681830fc5 Release v0.2.23
Draw a focus ring inside a clipped control, not around it.

Reported on an attachment chip, where the highlight read as a second border
with the wrong corners. The buttons carry no radius: the rounding lives on
the group, which also clips, so the browser's default outline was a rectangle
around a square half with its corners sliced off. An inset ring — the idiom
the settings segmented controls already used — is painted inside the padding
box, and the clip rounds it instead of removing it.

Six sites: the attachment chip, the Send pill and its chevron, the calendar
view switcher, the select and account menus, and a block on the time grid,
which clipped its chip's ring away in full.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:24:17 +02:00
Paul LütznerandClaude Opus 5 d0d5d017a3 Let the writing assistant's instructions be changed from the composer
Tuning a prompt is a loop — change a clause, generate, read it, change
it back — and the only place to change one was Settings → AI. That means
abandoning a half-written mail, saving, coming back and starting the
draft again, so in practice nobody did it and the note stayed whatever
it was on the day it was written.

The assistant's panel carries the note itself now, prefilled with the
saved one and editable in place, with "Try again with this" beside it.

Nothing is saved. The box lives as long as the composer does, and
Settings → AI is still where a lasting instruction is written: trying a
sentence out and adopting it should take different gestures, or you are
left with whichever experiment you happened to stop on.

Absent and empty are different answers, and the plumbing keeps them
apart. ComposeAssistController::note() reads presence rather than
emptiness, and the controller sends the field only once the box has been
opened — so a composer nobody has touched behaves exactly as it did
before, and an emptied box is a person asking for this attempt to carry
no standing instruction at all. That second case is the half of the
feature that cannot be reached any other way: working out why a draft
keeps coming out wrong means being able to take an instruction AWAY, not
only to pile another one on top.

It replaces the writer's own note and reaches nothing above it. The task
instructions, the language rule and the administrator's own prompts
belong to the application, and a box in the composer that could switch
those off would be doing it in the one place nobody would think to look
for them.

The disclosure sits in the panel's header rather than in its action row,
and that is the difference between offering it after a good draft and
offering it after a bad one. The action row only exists once there is
something to accept, and the moment somebody wants to change the
instruction is the moment the answer came back wrong — "no answer" being
the wrongest it gets.

The browser test asserts on the request body, because that is the only
place the claim is legible: with the model host answering nothing, a
field that was sent and a field that was not look identical on screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 19:45:49 +02:00
Paul LütznerandClaude Opus 5 dfe27b7219 fix(compose): the keyboard covered the window on an iPad and left a gap on an iPhone
Two reports, one misunderstanding. While the on-screen keyboard is open, iOS
goes on telling a page that the screen is its full height — only the *visual*
viewport shrinks — and a `fixed` element is placed against the layout one. So
anything anchored to the bottom of the screen sits behind the keyboard and
believes it does not. Android shrinks the layout viewport too
(`interactive-widget=resizes-content`), which is the whole reason the same
window looked right there.

The phone window already answered this: below md it sizes itself from
`visualViewport`, so the keyboard ends up below it. Above md nothing ran at
all — `_applyMobile` called `_unwatchViewport()` on that branch — so the dock
card kept its `bottom: 1rem` from an edge that was now keyboard, with the
action bar and Send underneath it and no gesture that brought them back.

The controller now tracks the viewport at every width and publishes
`--keyboard-inset` on <html>; the dock wrapper and the card's height cap read
it from app.css. The card moves up AND caps against the room left, because
lifting a 600px card on a screen with a keyboard on it otherwise pushes its
title bar off the top.

The iPhone gap was the other half of the same thing.
`env(safe-area-inset-bottom)` also describes the layout viewport, so it went on
reserving the home indicator's 34px against a window whose bottom edge had
become the top of the keyboard: clearance turned into a dead band of window
between the action bar and the keys. It is dropped while the keyboard is up.

Three things an adversarial review of the first draft found, all of them real:

  • Pinch zoom read as a keyboard. `innerHeight` ignores the scale and
    `visualViewport.height` does not, so zooming to 2x reported half the screen
    as keyboard with none on it — and `scroll` is subscribed as well as
    `resize`, so panning resized the card continuously under the user's finger.
    Zoom is deliberately available here (WCAG 1.4.4), so this was reachable. No
    arithmetic separates keyboard from zoom; at any scale but 1 the correction
    is withheld.

  • `--keyboard-inset` is one property written by every compose window, and
    above md a dock window and an inline reply are open at once by design. The
    first draft deleted it on any disconnect and argued in a comment that the
    survivor would re-assert it — which only holds when a blur caused the
    teardown. `_settleSend()` empties the inline frame from a timer eight
    seconds after a send, keyboard untouched, and would have dropped a dock
    window back under it mid-sentence. Cleared only by the last one out.

  • The height cap had no floor and the lift no clamp. An iPhone in landscape
    is 844x390 — wider than md, so it gets the dock card — and `90vh` less a
    200px keyboard computes to 151px against ~210px of header and action bar. A
    box shorter than its `shrink-0` children does not shrink them: it spills
    them out of its bottom edge while the scroller between them, which carries
    From, To, Subject and the message, collapses to nothing. Nothing fits a
    composer and a keyboard into 390px, and this does not pretend otherwise —
    it stops the fix from breaking the case it cannot fix.

tests/e2e/support/keyboard.ts stages what a keyboard leaves behind, since
Chromium's visual viewport never shrinks: the real VisualViewport with its
height, scale and offsetTop overridden, and a resize to announce it. Each of
the four faults above has a spec that goes red without its fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:53:59 +02:00
Paul LütznerandClaude Opus 5 af42afce31 fix(ai): write in the language of the mail, and stop offering a reply to nothing
Three small things, all reported from use.

The writing help answered German mail in English, and proofreading a German
draft quietly translated it. The instructions are written in English and a model
reads that as evidence of the language it is meant to answer in — so the rule
has to name that pull and refuse it, not merely ask for the right language. The
proofread case is the serious one: a task asked to fix punctuation returned a
different message in a different language, and the writer would have had to
notice before sending it. Verified against the real host: a German mail now gets
a German reply, and proofreading fixes the umlauts without touching a word.

"Draft a reply" was offered on a new message, which has nothing to reply to. The
server already refused it, so the option was always there and never worked —
which reads as a broken feature rather than an inapplicable one. The rewriting
tasks stay, because they act on whatever is in the body.

And the last force-click goes. `force` skips the hit-target check, and skipping
it is how the snooze button came to be pressed while it was invisible: the event
went to its coordinates, landed on the ROW, and the failure said "the menu did
not open" — true, and silent about why. The shared helper now re-hovers on every
attempt (a row replaced under a stationary pointer never receives :hover, so
retrying alone re-reads the same nothing) and clicks honestly. snoozed-list
stops carrying its own copy of that helper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:29:36 +02:00
Paul LütznerandClaude Opus 5 2d1586cb68 fix(compose): a draft that died at thirty seconds, under the quote, in silence
Three faults in the writing help, all of them things that only appear once it is
actually being used.

PHP's max_execution_time is 30 seconds for the web SAPI while the model call is
allowed 120, so the limit always belonged to the wrong side of the request. A
writing model that has fallen out of the keep-alive window needs about thirteen
seconds to be read off disk BEFORE the first token exists, and a reply worth
reading is another twenty or thirty after it — so the limit fired mid-generation
and the reader watched the text stop halfway:

    MaxExecutionTimeError: Maximum execution time of 30 seconds exceeded
    at CurlResponse.php line 370

The worst possible place for it to land, too: inside the HTTP client's read, so
the unwinding that cancels the model call and records the row never happened and
Ollama carried on generating for a reader who had already been shown an error.
The bound now comes from the pieces that can actually enforce it — the upstream
timeout, and connection_aborted() on the next write.

The answer went in at the end of the editor, which in a reply is UNDERNEATH the
quoted original: an answer nobody scrolls to, sitting below the message it was
answering. It goes above the quote now, which is where a person's cursor already
is when they start typing a reply. Plain text too, where the boundary is the
first `>` line less its attribution line — inserting between "On … wrote:" and
the quotation splits it in half.

And the wait had nothing moving in it. There is nothing honest to put in a
progress bar, because the host reports no progress at all while it loads, so a
still sentence was all there was and it read as a window that had hung. Three
dots that breathe say "working" and claim nothing about how far along it is,
which is exactly as much as we know. They stop at the first token, where
arriving text says it better, and they hold still but stay visible under
prefers-reduced-motion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 01:50:07 +02:00
Paul LütznerandClaude Opus 5 377d6026b8 feat(ai): make the language model observable, and the composer honest
"I click the AI button in the composer and nothing happens" turned out not to be
about the model at all. #editor() queried data-compose--toolbar-target while the
composer renders data-compose--compose-toolbar-target, so run() returned at its
first guard: no request, no status, no error. The feature had never worked once.
#subject() was wrong the same way and quieter — every prompt went out with an
empty subject. And the status element lived INSIDE the dropdown that the same
click closes, so even once working it would have written into a hidden subtree.

The rest of this is what makes the next such thing findable.

RECORDING
One row per model call, tagged by the workload that caused it, and nothing about
what was in it — no prompt, no completion, no message id, no subject, no
address. error_kind is a category from a closed set for the same reason: an HTTP
client's exception message routinely quotes the request body back, and here that
is somebody's mail. OllamaClient was discarding Ollama's whole timing block; it
now returns it, and AiAssistant — the only place that knows both the feature and
the model — writes the row.

DBAL and deliberately no entity, following EmbeddingStore: BackfillEmbeddings
clears the EntityManager after every chunk, so an unflushed ORM row would be
detached and silently dropped, fifty at a time, for as long as a backfill runs —
which is exactly the window the panel exists to show.

AiFeature could not carry this. A four-character search query somebody is
waiting on and two thousand characters of mail body written unattended a hundred
thousand times both went through embed() with no discriminator, and averaged
together the second buries the first — the panel would report backfill
throughput under the heading "search". AiCallFeature splits them.

THE PANEL
Live state from /api/ps, which is passive and safe to poll. Six states that look
different, and the one that justifies the work is "partly on the CPU":
size_vram < size means layers spilled, throughput collapses, and the model still
answers correctly — invisible everywhere else, so everything is simply slow
forever.

THE COMPOSER
Tokens stream into a provisional surface and commit in one operation, because
hundreds of per-token insertions destroy the undo stack and Ctrl+Z is the first
thing anyone tries. Waiting, generating, done and failed are distinct states
with reasons: outside the keep-alive window the first thirteen seconds produce
nothing while 20 GiB loads, and a featureless spinner for that IS the bug.
Cancellation propagates to the host — an abandoned draft otherwise holds the GPU
and everything else queues behind it.

SEARCH
Silent degradation to keyword-only is replaced by the reason it degraded, and
rows found by meaning say so. The semantic arm and its join are now matched on
model AND width: vectors from different models are not comparable, and after
somebody changes the search model every existing vector is dead weight. On a
pgvector install comparing different widths raises an error — a 500 on
/mail/search caused by an admin dropdown — and on the shipped loop it silently
compares the overlap, which is worse. Coverage is stated where results are shown,
because semantic search over an 8%-indexed mailbox is a trap.

BACKFILL
Progress is counted from the database, never accumulated, so a killed worker
resumes. It stands aside while anyone is using the AI and comes back on its own:
both compete for one integrated GPU, and a batch in flight when somebody presses
the button puts them straight back to "nothing happens".

Also carries the cron line for app:jobs:reap from the commit before this one.

Verified against the real host: reachable, both configured models installed,
timings parsed, 1024-dimension embeddings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 00:38:43 +02:00
Paul LütznerandClaude Opus 5 469a2e8107 feat(ai): semantic search, writing help, and the pass that embeds a mailbox
The three remaining pieces. Written to the seams the survey found, and
keeping the traps it named.

SEARCH — ANOTHER ARM OF THE SAME UNION

A vector hit is a candidate on exactly the same footing as a lexical one:
it lands in COUNT(*) OVER (), it pages correctly, and it needs no second
statement to reconcile with. Four things had to hold and each is written
down where it applies:

  · The query is embedded ONCE, in the controller. buildSearchSql() runs
    up to four times per search — the cheap pass, the body rescue, and
    twice more when a page past the end recovers its total — and one of
    those is inside a statement-timeout transaction, where a slow model
    would be reported as a database fault.
  · The candidate set is BOUNDED to the most recent 2,000 messages. The
    distance function costs 0.11ms a row; over a whole mailbox it is
    eleven seconds, worse than the pathology the UNION shape exists to
    escape.
  · A THRESHOLD, not a top-k. The total rides on the same statement, and
    "lexical matches plus up to k" is a number that is only true on page
    one.
  · The inner LIMIT is parenthesised, because these arms are joined with
    a bare ' UNION ' and an unparenthesised LIMIT binds to the whole
    union rather than to one arm.

The rank join is 1:1, which is the property the label joins lost and had
to be given back in v0.1.40. Three tests: a message found by meaning that
no lexical arm can reach, one rejected by the threshold, and one with no
embedding still found by words — that last is what proves the LEFT JOIN
does not drop rows.

WRITING HELP — THE SERVER MAKES THE CALL

connect-src 'self' is enforced, so a browser could not reach the model
host even if it should — and it should not: that address is on the
operator's private network, and putting it in a page hands it to every
script the page loads.

The route carries NO draft id. A composer opens before one exists, which
is exactly when somebody wants a first draft; a URL built at render time
with an id in it is stale in that window. Nothing is saved as a side
effect either.

Four tasks, a closed set. A free-form instruction parameter would be a
prompt an attacker could write, with the user's own mail as context and
the answer pasted into the user's own draft. The message being replied to
is read from the DATABASE by id, ownership checked — never from the page.

Generated text is INSERTED, never substituted. A rewrite that overwrote
the original would be a model deleting somebody's words with no undo they
can see. It goes in as a plain text node at the end of the body: a styled
wrapper would be subtracted by the composer's typed-length calculation
and could stop the draft autosaving, and "at the caret" is a position the
writer did not choose, because opening the menu moved focus.

The menu is behind a Twig FUNCTION, not a variable — this window is
included from four places, two of them undo streams that include it
`only`, and strict_variables makes a variable threaded through three of
four a 500 on the undo path rather than a missing button.

EMBEDDING — ARRIVALS ON INGEST, THE REST ON DEMAND

A post-ingest step for new mail, and a command for everything already
there. The backfill re-dispatches itself with a cursor rather than
looping: a hundred thousand messages at a round trip each is hours, and a
single job holding that is killed by any worker restart with nothing to
show for it.

Ascending id, because it is the one ordering nothing can change under a
walk that long — mail arriving during it gets a higher id and is met by
the pass still coming. The vectors already stored ARE the progress
record, so there is no second one to keep in step, and the cursor
advances past everything LOOKED AT rather than everything stored, so one
message the model cannot answer for does not become a wall it restarts at
forever.

On the maintenance transport, not ingest: a backfill running for hours
would otherwise sit in front of every arriving message, and new mail
would stop appearing until an old mailbox had finished being catalogued.

KNOWN FAILING, AND NOT FROM THIS

compose-send-optimistic.spec.ts:124 fails in the FULL e2e run and passes
in isolation and in pairs — verified with the AI menu both present and
removed, 24 passed either way. It is the same spec that broke in the
earlier seed race, and the row it cannot click is present but still
mid-entrance. Cross-file interference, pre-existing, and left for the
debugging pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 19:29:39 +02:00
Paul LütznerandClaude Opus 5 7065d2a192 fix(compose): "Message sent." was said two seconds before the send was attempted
A send is held for ten seconds so it can be called off. The browser
marked the end of the CANCEL window — eight seconds — by asking the
server to tidy up, and that answer carried "Message sent." So the
confirmation preceded the attempt by two seconds, every time, for
everybody.

And nothing ever corrected it. All three senders turn a failure into a
bare `false`, which SendMessageHandler treated as handled: no retry, no
failed-transport row, nothing. SmtpMailSender goes further and binds the
transport exception without using it, so a hard SMTP rejection left zero
trace anywhere in the system. The user was told the mail had gone, and
it had not.

Now the eight-second mark only tidies, and the outcome is published from
the worker when there is one — success or failure, over the live-updates
stream that is already there. The failure toast is the case that had no
surface at all.

It publishes RENDERED html, because the alternative is a toast built in
JavaScript, which is a second copy of _toast.html.twig drifting from the
first.

Best effort, and honestly so: with the hub down there is no toast, which
is a confirmation missing rather than a confirmation that lies.

WHAT THE E2E SUITE CAN AND CANNOT SHOW

It cannot show the toast. The test app runs
MESSENGER_TRANSPORT_DSN=in-memory://, so a send dispatched by a browser
request is discarded when the request ends and no worker can pick it up
— which also means those specs were only ever asserting the premature
claim. They now pin the half that was wrong: that nothing announces an
outcome it does not have. The publishing side is covered by
SendOutcomeNotifierTest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 20:49:19 +02:00
Paul LütznerandClaude Opus 5 24e27b9d50 fix: false|default(true) is true, in five templates that relied on false
Not a demo issue — every install, every send.

Twig's `default` filter substitutes when a value is undefined OR EMPTY,
and `false` is empty. So `false|default(true)` is `true`, and every
caller passing `false` to switch something off was ignored.

The one that was reported: sending a message showed "Message sent." and
"Sending… 8 · Undo" in two toasts an inch apart — the app announcing the
send was finished beside its own offer to call it off. The optimistic
response includes the settle template with `notify: false` for exactly
this reason, and that suppression had never once worked.

Four more, all the same mistake:

- compose/_attachments — `live: false` means "these chips are already on
  screen, do not animate them in", so re-rendering the compose window
  replayed the entrance stagger.
- calendar/_event_chip — the time-grid asks for no start time because
  its own axis already shows one. Every chip printed it anyway.
- _partials/_thread_row — the per-account list asks not to be treated as
  a merged one and got the account disambiguation regardless.
- settings/_appearance — the setup wizard hides the export/import block,
  which its own comment calls "not worth showing someone who has been in
  the app for ninety seconds". It showed.

`?? true` is what was meant: the null-coalescing operator defaults on
null or undefined and leaves false alone.

A guard test refuses `|default(true)` anywhere under templates/, with
the five cases written down. It blanks `{# … #}` before scanning rather
than skipping lines that look like comments — the first version reported
the comments explaining this trap, which necessarily quote it.

THE ASSERTION THAT DID NOT ASSERT

The E2E check went in as `expect(locator).toHaveCount(1)` and passed
against the unfixed code. toHaveCount RETRIES: it watched two toasts,
waited the few seconds for the stray one to auto-hide, and reported
success. Counted once, with no retry, it fails 2 against 1 — which is
the only reason this commit's fix is known to be a fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 17:00:34 +02:00
Paul LütznerandClaude Opus 5 c848bc5474 fix(compose): the suggestion panel stops moving the dialog
The reported bug, fixed the other way round from the obvious one.

The dock window was content-sized under a 90vh cap, and the dock is anchored
bottom-right — so the suggestion list appearing grew the window and the whole
thing climbed the screen, then dropped back when the list closed. Typing a
recipient made the dialog jump.

Taking the list out of the flow is one line and is wrong. Subject sits directly
under To, so an overlaid list covers it, and a click aimed at Subject lands on
whichever suggestion is under the pointer and enrols it — which once silently
added a third party to a message addressed to someone else. Three specs exist to
keep that fixed and they caught the attempt.

So the list stays in the flow and the WINDOW stops resizing: a fixed height,
with the scrollable region absorbing the panel. The list pushes the body down
inside an outline that does not move, which is what the report was actually
about. Gmail's dock window is a fixed size for the same reason, and 90vh still
caps it on a short screen.

The new test asserts both halves together, because either alone is satisfiable
by breaking the other: a fixed window with an overlaid list passes "does not
move" and covers Subject, and an in-flow list in a growing window passes "does
not cover Subject" and moves the dialog. Verified it fails with the right
message when the height is taken away.

Full Playwright suite: 564 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 15:45:58 +02:00
Paul LütznerandClaude Opus 5 67b3a93013 feat(compose): send gives the screen back, and the undo moves to a toast
"Das warten beim Absenden ist pain." Pressing Send kept the composer on screen
for the whole cancel window and made its own pill the cancel — nothing moved,
and the way back was exactly where the way forward had been. That shape is
defensible and it has the one problem that got reported: eight seconds of a
window that will not close is a long time when you have finished writing.

So the default is the other trade. The composer closes at once, the mail is
already where it will live, and the cancel moves to a toast that counts down
beside it. The held shape stays, one setting away —
User::SETTING_COMPOSE_SEND_FEEDBACK, on the compose behaviour panel next to the
forward-quote choice.

Almost nothing new was needed, which is the interesting part. The toast partial
has supported an undo button and a countdown all along, ui--toast already
renders the timer, and compose--undo-send was sitting in the tree marked
UNWIRED "while the replacement is reviewed" — with _send_toast.html.twig beside
it, kept for the same reason. This is that review coming back the other way.
The template is gone, because the toast is emitted by the stream that needs it
now, and the controller's docblock says what it is for again.

The tidying is deliberately the SAME template the end of the cancel window
renders, with its toast suppressed. A send must not settle differently
depending on which shape rendered it, and two copies of "put the message where
it belongs" is exactly how the forward bug that template documents got in.

`hide_after` on the toast IS the cancel window rather than a duration somebody
picked: the Undo goes when the toast goes, so a toast that outlived the window
would offer a cancel that silently fails, and one that died early would take
away a cancel that still worked.

Both settings post their own form. The controller writes only the key it is
sent — an absent `sendFeedback` read as its default would have stored the
default and silently undone the other setting on every save.

Also fixed here, and found by the specs rather than by me:

**Three destructive forms had stopped confirming entirely.** Revoking a trusted
device, revoking every remembered device and switching off two-factor
authentication carry `data-turbo="false"` AND `data-turbo-confirm`. Opting out
of Turbo means setConfirmMethod is never consulted, so the attribute is inert —
they had been `onsubmit="return confirm(…)"`, which worked regardless, and the
conversion silently removed the guard. templates/admin/_live_frame.html.twig
already warned about this exact combination in a comment: "an attribute that
looks like a guard but is not is worse than none."

Fixed with a capture-phase submit listener rather than by editing the three
templates, because the next form to combine the two is then covered by having
done nothing. It re-issues through requestSubmit() so validation still runs, and
the flag that stops it asking twice is compared as a string — dataset values are
strings, and `true ===` would have asked forever.

compose-send-cancel and compose-undo-reopen now put their user on "hold" first
and back to the default afterwards, because they describe that shape;
compose-send-optimistic covers the new default on both surfaces.

Verified: 4009 unit tests, PHPStan clean, both compose suites and the security,
label and confirm specs green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:17:52 +02:00
Paul LütznerandClaude Opus 5 0f80d2c43b fix(auth): a subresource is not a destination, and logging out is a full load
Three reports, one story: the application kept treating a dead session as an
ordinary navigation.

**Landing on your own profile picture after signing in.** Symfony saves the
login target in ExceptionListener::setTargetPath(), whose test is
`isMethodSafe() && !isXmlHttpRequest()` — and an `<img src="/…/avatar">` is
both. So the last unauthenticated thing the browser happened to fetch became
the place the next login went. The session ends, the page is still on screen,
the browser re-requests the avatar, that 302 saves an image URL, and login
redirects to it. Whichever subresource lost the race decided the destination,
which is why it looked intermittent rather than broken.

The entry point is where to undo it, because it runs after the save. Anything
that is not a document navigation has the saved path removed again. The test is
two-sided rather than a list of extensions: `Sec-Fetch-Dest: document` is what a
browser says about a real navigation, and an Accept mentioning text/html covers
a Turbo visit — which is a fetch, and reports `Sec-Fetch-Dest: empty`. Both
halves are pinned, so the fix cannot degrade into "never remember anything":
asking for /settings while signed out still returns you to /settings.

**Logout went through Turbo.** The shell survived it — controllers still
mounted and polling with a cookie that no longer existed, the Mercure
subscription still open, and Turbo's page cache able to put a signed-in
snapshot back with the back button. data-turbo="false".

**A session ending underneath an open page** made Turbo swap the login form
into the application shell: a password box wearing the sidebar, with every
controller on the page still running and failing. There is a bar now.

The interesting decision is in that bar. "Reload the page" is the obvious
response and it is wrong exactly when it matters: a half-written mail lives in a
contenteditable, and the autosave that would preserve it is a POST — the thing
that has just stopped working. So with nothing being written it offers a
straightforward sign-in in this tab, and with something being written it offers
a new tab instead and leaves this one untouched, text and all. The cookie is
shared across tabs, so coming back here just works and the next autosave lands.
Being wrong in that direction costs a tab; being wrong the other way costs what
somebody wrote.

Also: the compose window's close button gets an aria-label (CODE-REVIEW U-01).
Its only content is an icon, so with `title` alone the accessible name depended
on a screen reader choosing to fall back to it, and on touch it was unreachable
— while above md it is the only way out of the window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 12:33:45 +02:00
Paul LütznerandClaude Opus 5 1e09f36afb feat(ui): the interface stops appearing out of nowhere
Every animated surface, against the contract in the previous commit. Four
agents worked in parallel on the mail list, compose, the shared chrome and the
calendar; what follows is what survived review.

**The rule everything obeys: motion marks an ARRIVAL, never a redraw.** The
mail list is the case that decides the design. It is re-rendered wholesale —
`live.innerHTML = incoming.innerHTML` in mail--mail-pane#_swapRegions — on every
background sync, as often as every fifteen seconds, so every row becomes a new
node carrying the same mail. A row that animated on insertion would pulse four
times a minute on an inbox nobody is touching, and a list that shimmers at rest
is the fastest possible way to make somebody switch this off.

What tells the two apart is the id. `thread_1234` is the same conversation
whoever rendered it, so motion.js animates an element the first time it sees a
given id and never again. New mail rises into place; a redraw of the same mail
is silent; and a whole new list — a folder, a search, the next page — plays ONE
fade on the <ul> rather than fifty on the rows, because a child whose ancestor
is announcing it stays quiet.

That needed a second attribute, `data-enter-new`, and the reason is worth
stating because it was learned the hard way: a CSS animation starts the instant
an element carrying one is inserted, before any observer can object. Suppression
therefore cannot be a decision taken after insertion — an element whose entrance
is conditional has to arrive with no animation attached at all and be given one
only once its id proves new. The first implementation recorded ids inside
play() after an early return, so rows suppressed by their list's fade were never
recorded and animated in a slab on the next sync. A test caught it.

Elsewhere the agents mostly declined, which is the right ratio:

  • Search suggestions animate ONCE when the panel opens, not per keystroke —
    five characters produce one animation.
  • The calendar animates the grid WRAPPER on a view change: one gesture at one
    duration however many events are on screen, and nothing on the positioned
    blocks, where an animation would win over the inline transform the drag
    controller writes and corrupt its geometry reads. Proven: every block
    reports animation-name: none at all three tiers.
  • Compose declines minimise, expand, close, discard and pop-out. The first
    two are size changes; the rest are frequent actions where an exit is time
    between the ask and the answer.
  • The compose window uses `fade` where the contract said `pop`. Below md it
    IS the screen, so scaling draws a hairline of the mail list around all four
    edges, and a transform on it is inherited by everything measured inside it
    — the emoji picker's fit-to-window pass reads exactly that. The contract
    was wrong; motion.css now says so.

Two exits, both earned: a toast dismissing itself, and a modal, which is most
of the screen and whose backdrop is all of it.

Fixed along the way: closing a modal stopped returning focus to its trigger,
because the hide is now deferred through leave() and Turbo's
focusFirstAutofocusableElement was landing after the focus call — harmless when
the dialog was display:none by then, not harmless when it is still on screen for
a fade. Focus returns before the animation now, and the dialog goes `inert` on
its way out. And the motion setting's radiogroup labelled itself with an
sr-only span, which is 1px-clipped by definition and which preview-pane.spec.ts
correctly read as 53px of overflow; the heading is the label.

Verified with a getAnimations() harness rather than by eye: every entrance is
180ms at Full, opacity and transform only, no layout property animated
anywhere, and the three tiers resolve to 180/120/0ms with 6/0/0px of travel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:48:08 +02:00
Paul LütznerandClaude Opus 5 097f6ca943 fix(compose): an autosave the server refused no longer says "Draft saved"
`response.ok` was the wrong question. A form the server REFUSES — a stale CSRF
token, a validation failure — does not come back as an error status: it comes
back as the window re-rendered with the errors on it, HTTP 200. The autosave
read that as success, printed "Draft saved", and cleared the unsaved flag that
the unload guard depends on. So the one path where writing is actually lost was
also the one path that claimed everything was fine.

ComposeController::draft() has passed `saved => true` on the accepted branch
since it was written, and the template has never rendered it. It does now, and
the client requires it. A refusal takes the same road as a network failure:
"Could not save the draft", and `_unsaved` stays standing so leaving the page
still warns.

This is the half of the CSRF bug that made it invisible — the composer was
telling the user their reply was safe while the server discarded it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 20:14:52 +02:00
Paul LütznerandClaude Opus 5 35cf17f05d feat(compose): the send pill is the cancel, in every editor
A send used to answer by taking the window away and offering the way back
somewhere else — an Undo in a toast for the dock, a countdown bar in the
thread for an inline reply — plus a "Cancel" link that appeared beside the
pill while the request was out. Three surfaces for one act, none of them where
the user had just clicked, two of them in a window that no longer existed.

The window now stays exactly where it is for the cancel window. Its Send
button reads "Sending…" with living dots and "click to cancel" under it, and a
second click on that same button calls the send off. Both faces are stacked in
one grid cell and only toggle visibility, so the button cannot change size when
a send starts, when it ends, or on any frame of the animation.

The server's answer no longer re-renders anything: it hands the live window the
two URLs it cannot address by itself (compose/_sending.stream), and when the
offer expires the window asks ComposeController::sent() to file the message and
close it. A forward starts its own conversation, so the settle streams close the
frame unconditionally and touch the thread only when it is the thread on screen
— without that a forwarded mail left the composer reading "Sending…" for ever,
addressing ids that were nowhere in the page.

A toast now says the message went, because a composer that simply vanished was
the one moment in the flow with no feedback at all. Cancelling says nothing: the
draft coming back with everything in it is the answer, and the dock's old "Send
cancelled" toast was the last asymmetry between the two composers.

Also here, because they are the same window:

  • The inline composer gets a reduced header — the To field itself, typable,
    with From/Cc/Bcc/Subject folded behind the chevron. A forward opens needing
    exactly one thing and used to open with a label where that thing goes.
  • The suggestion panel opens on typing and on nothing else. `openOnFocus` is
    read at focus time and a forward's caret beats the code that sets it, so an
    empty query is refused at the moment the panel opens instead.
  • It overlays the body rather than reserving room, which in a header meant
    growing the header and shoving the editor down on every keystroke.
  • `mode` moved onto ComposeContext. As a render-time extra it was set by one
    action and lost by every other render, so a cancelled forward came back as a
    plain new message and asked whether you meant to send an empty mail.

compose/_send_toast, compose/_inline_send.stream, compose--undo-send and
compose--inline-send are kept, unwired and documented as such, while the
replacement is reviewed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:46:52 +02:00
Paul LütznerandClaude Fable 5 2828dc31dc feat(compose): sending with a pulse and a way out, and forwards that behave
The send button's "Sending…" becomes a living one-two-three dot cycle — a
static label reads as a hang — and a Cancel appears beside it for the
in-flight moment: every send lands in a cancel window, so a click here is
remembered and the window's own control is pressed the instant it renders.
The watcher lives at module scope because the stream that renders it
replaces the very element a controller-held watcher would die with.

A forward opens unsigned (its content is the mail being passed on), with
the address rows unfolded and a blinking caret in a typable To — Tom
Select's enhancement is awaited, not hoped for — and the quoted original
counts as content, so no forward is questioned as an empty mail. The
"show quoted text" fold stays default but becomes a choice: Settings →
General → Composing, stored only when switched off, absent key meaning
default as the settings bag always reads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 00:27:44 +02:00
Paul Lützner 85cc9e7166 refactor(mail): compose gives up the parts that were never about HTTP
1,130 lines and 93 methods, with sixteen section headers the author had already
drawn — decomposed in the head, not on disk. Four pieces come out.

ComposeAttachmentController takes the three upload actions, and they are the
one genuine seam in that class. Everything else — new, reply, forward, draft,
send, schedule, undo — is a single pipeline building the same ComposeType form
from the same request context and answering with the same window, so pulling
"sending" away from "composing" would only have moved nine shared private
helpers into a shared parent and called the result two classes. These three
share none of it. MAX_ATTACHMENT_BYTES goes with them, which is where the file
picker and the window now read it.

ComposeContext replaces `array{inline, frame, thread, replyTo, urlParams}`, a
shape written out in EIGHT docblocks because every method touching it had to
redeclare it for PHPStan — so a key added to it meant eight edits, and a key
misspelled at a read site meant null rather than an error. `urlParams` was also
a STORED key derived from the other four, so the array could carry a value that
disagreed with the fields it came from; it is a method now and cannot. A value
resolver builds it, so actions type-hint it and four of them stop taking a
Request they no longer need.

ComposeWindow and ComposeFormFactory take the rest: the default From account,
the signature seed, the picker's services, the unsendable-address check, the
discard's row arithmetic, and the one place the form is built. All of it was
reachable only through a route before, so "which account is the default From"
could not be asked without building a request.

Two template assumptions fell out of it. `ctx|merge({thread: …})` was the last
thing standing between this shape and being a type — merge is an array filter —
and is `ctx.withThread()` now. And _window.html.twig defaulted every field it
read, with fallbacks that had DRIFTED from the controller's: it defaulted
`frame` to 'compose_dock', so a window could render as dock while the server
thought it was inline. A guaranteed type needs no fallbacks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 801639173c4c5017bfa0852791eb97306ee9db39)
(cherry picked from commit 2e1d713897de293c5e7623f95e661286dd245951)
2026-08-17 11:25:39 +02:00
Paul LütznerandClaude Opus 5 d737fec583 fix(compose): make a confirmed cancellation a real one
Undo send could confirm a cancellation and deliver the mail anyway.
Reproduced in a browser against a real worker, a real ten-second
DelayStamp and a real SMTP server: cancel an inline reply at 9.9s and
the endpoint answers HTTP 200 "send cancelled" while the message
arrives at the SMTP server. 3/3 runs. The row was left holding both
statements at once — cancelled = true beside a populated sent_at.

The mechanism is a lost update, not a broken flag. SendMessageHandler
READ `cancelled` and then sent; the cancel's UPDATE committed between
those two steps. Neither side could discover what the order had been,
so the composer confirmed unconditionally. Two things were needed:

  * A condition the handler cannot lose. message.send_claimed_at, with
    the guard and the claim in ONE statement (claimForSend), and the
    cancel as its mirror (cancelSend). Postgres decides which UPDATE
    matched a row, and both sides learn whether they won. `cancelled`
    survives as the one-shot signal it always was — it is no longer
    what arbitrates.

  * The composer to stop lying. A cancel that loses now answers
    _undo_too_late.html.twig — "Too late, that message had already been
    sent" — instead of "send cancelled" plus a restored window over a
    delivered mail. The window is deliberately NOT restored: an
    editable copy of a sent mail would be autosaved into a draft
    standing beside the sent copy.

The reachable trigger was an inline reply bar counting the full ten
seconds, so it accepted a click at the instant the worker took the
message. The dock's toast was accidentally safe at eight. Both now
take CANCEL_WINDOW_MS from the controller, which is kept below the
send delay; the margin makes losing rare, the claim makes losing safe.

A claim expires after 15 minutes so a hard-killed worker cannot wedge
a draft that nothing may ever send again, and a failed send releases
its claim so the messenger retry is a real second attempt.

Also fixed, both reproduced first:

  * Scheduling forked a permanent duplicate draft. The send pill's
    buttons carry `formaction`, rendered once when the window had no
    id; autosave read the new draft/send URLs out of its response but
    never the schedule one, so scheduling posted to the id-less route
    and ComposeController::schedule() built a second Message. Measured:
    one composition, two rows in Drafts, the plain one stranded for
    good. The window now publishes its schedule URL alongside the other
    two and _adoptSavedUrls() rewrites every formaction.

  * The schedule picker kept a stale "that time has already passed"
    under a corrected date, beside an enabled confirm button — the
    dialog calling the input invalid and offering to proceed with it at
    the same time. Editing the field now withdraws the complaint.

NOT changed, because it did not reproduce: the send/undo toast is not
clipped. Measured at 1280x800 and 414x851 in both themes — 16px clear
of the bottom, fully on screen, undo link comfortably hittable. The
only overlap is the Symfony debug toolbar in dev, which the report
itself attributed to dev.

Regression cover in tests/Controller/Mail/UndoSendTest.php ends at a
recording sender, not at the flag: the old tests asserted the composer
reopened and `cancelled` was set, both of which were true while a mail
was going out. Two of the five fail against the pre-fix code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 16:19:44 +02:00
Paul LütznerandClaude Opus 5 ea43b1fe30 fix(compose): the schedule that went nowhere, and eight other things a person hit
Scheduled send did nothing when a time was picked by hand, and it did it
silently. ScheduledSendResolver refuses anything closer than MIN_SECONDS (60s);
the picker only checked whether a time had PASSED, so the entire band between
now and a minute from now sailed through the browser. The server then reported
the refusal with $form->addError() — a ROOT error — and _window.html.twig
rendered only form.toAddresses's errors, so the message went nowhere at all.
Turbo replaced the compose frame with a re-render identical to what was already
on screen: menu closed, composer unchanged, no toast, one request in the
profiler, console clean.

Which is exactly the click a person makes to test the feature — they type the
next whole minute. Every green test used a PRESET; the custom picker appeared
in one test that drives it to a refusal, so "pick a time and have it actually
schedule" was asserted nowhere.

  * the picker mirrors MIN_SECONDS and refuses below it, locally and in words
    that are true of the time chosen ("past" and "too soon" are now two
    different things to be told, in three catalogues)
  * root-level form errors have somewhere to render, so no refusal from
    schedule() can be swallowed again
  * the spec drives the custom picker to a real schedule, all the way to a
    persisted badge in Drafts — and pins the floor case as a local refusal
    with no round trip at all

And the rest of the pass:

  * the signature no longer eats the first paragraph. Clicking the empty space
    BELOW it put the caret at the end of the sign-off, so typing welded onto
    it; those clicks now land in the writing space above. The emptiness check
    counted the signature as not-the-user's-writing unconditionally, so text
    that had landed inside it was subtracted before the count and Send claimed
    a visibly full message had none — the block is skipped only while it is
    untouched.
  * the subject/body confirmations are the in-app panel the plain-text switch
    already uses, not window.confirm(), which froze a test session three times.
    Both reasons are one question asked once, and the retry goes back through
    the same submitter so a scheduled send stays scheduled.
  * the To field: the refusal clears when a recipient is added, names the
    invalid address rather than blaming the count, and keeps what was typed
    (Tom Select wiped the box on blur before submit ever saw it). The
    suggestion panel no longer reopens over the Subject row on every focus.
  * aria-invalid and aria-describedby tie the refusal to the input; the From
    select and the Tom Select original are named; the picker's refusal is a
    role="alert".
  * a subject typed on its own is worth autosaving — the threshold counted the
    body alone, so "TEST-RELOAD halbfertig" was lost on reload with no warning
    and nothing in Drafts. Below the threshold, beforeunload interrupts.
  * the emoji panel is capped against the compose WINDOW rather than the
    viewport (its top edge sat 33px outside it), the grid's scrollbar is thin
    rather than a 15px native bar with arrows, and the skin-tone button is off
    the panel outline.
  * vergrößern, Schriftgröße, groß — the compose labels were the only corner
    of the UI spelling it the Swiss way, and a spec was asserting it.

phpunit OK (3427 tests, 8849 assertions); composer stan [OK] No errors;
120 compose e2e passed, plus 48 adjacent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:56:29 +02:00
Paul LütznerandClaude Opus 5 263785f20a fix(compose): the send pill in the header on a phone, where the old button was
Below md the Send pill now sits in the compose window's header — the spot the
plain Send ICON held two rounds ago — instead of at the bottom of the screen.
Above md nothing moves: it stays first in the action bar.

One partial, `compose/_send_pill.html.twig`, included twice: `md:hidden` in the
header, `hidden md:flex` in the action bar. Not copy-pasted, because the shape
this replaces was exactly a copy that drifted — a header icon that could only
send beside a bar pill that could also schedule, two Send buttons disagreeing
about what Send meant. Exactly one is ever in the accessibility tree, which is
what the specs assert at each width.

Three things the second placement needed:

  • The menu flips. The bar's opens upward and anchors `left-0`, because it is
    the first thing in the bar. In the header the pill is the LAST thing on the
    row, so the menu opens DOWNWARD (`top-full` — `bottom-full` would clear the
    top of the phone) and anchors `right-0` (a left-anchored `w-64` runs off a
    320px screen). Both are a `placement` parameter, and both are measured.

  • Two `schedule_at` fields, one form. They share a name and `display: none`
    does not keep a control out of a submission, so PHP would have kept the
    LAST in document order — the pill nobody touched — and every schedule set
    from the header would have posted an empty time. Both ship `disabled`;
    compose--schedule arms only its own as it writes to it. No ids were
    involved: the block had none to collide.

  • The header at 320px. The right-hand group lost its `shrink-0` and the save
    status is capped at 30vw below md, so the title yields and the pill does
    not. Measured: header 61px tall, pill 115px wide ending 8px from the right
    edge, no horizontal overflow, back arrow still at x=0.

compose--compose#_handleSubmit already read `sendBtnTargets` plural, so both
copies are disabled and relabelled on send; its comment now says why there are
two rather than claiming there is one.

Tests: ScheduledSendTest counts the pair and asserts the two placements carry
opposite breakpoint classes, opposite menu directions and disabled fields;
compose-mobile and compose-scheduled-send assert the pill is in the header,
that the menu opens below it and fits at 393px and 320px, and that the count of
Sends on screen is still one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 09:48:19 +02:00
Paul LütznerandClaude Opus 5 9c37bc3a8f fix(compose): a toolbar that wraps, a pill on the phone, and a way back out of plain text
Five reports from one window, and four of them turn out to be the same
mistake: the two toolbar rows were sideways scrollers.

  • The emoji panel opened off screen on a desktop. It was anchored to its
    button, which sits near the right edge of a 520px dock. It is centred on
    the compose WINDOW now, at every width and in both compose modes — the
    same treatment a phone already got by being pinned to the viewport, and
    one rule instead of two.

  • Leaving plain-text mode did not bring the formatting bar back. Going
    plain wrote `display: none`; coming back wrote nothing, so a rich-mode
    window sat there with no bar and no reason why. The switch is symmetric
    now, and restores the bar to what it WAS rather than simply opening it —
    a bar folded on purpose stays folded across the round trip. Same for the
    link, emoji, image and signature buttons, which are greyed while plain
    (they write into an editor that is hidden) and restored on the way out.

  • Neither toolbar row scrolls horizontally any more; both wrap onto a
    second row. `overflow-x: auto` computes `overflow-y: auto` with it, which
    is what clipped the more-options menu to nothing on a phone last round —
    so removing the scrollers also let the `max-md:fixed` viewport-pinning
    workarounds be deleted rather than kept. Every popover now anchors to the
    toolbar block, which spans the window. Two rows at 393px and at 320px:
    the group dividers go `hidden md:block` below md, which is the 25px
    between two rows and a third holding nothing but Redo.

  • The send pill is rendered at every width, so scheduling exists on a phone
    for the first time; the header's separate Send icon is gone rather than
    left to make a second, less capable Send button. Both halves of the pill
    take a 44px minimum below md — the chevron was an 11px hit area.

  • The formatting bar is shown by default on a phone as well as a desktop.
    "Aa" still folds it away for whoever wants the room.

Measured at 320x568 with all of it up: 62px of format bar, 84px of action
bar, 349px left for the body. At 393x851, 632px.

Tests: compose-mobile gains the no-sideways-scroll assertion, a
both-ends-on-screen assertion and a body-height floor; compose-scheduled-send
inverts its "the pill is desktop-only" block into measured pill-and-menu
geometry at 393px and 320px plus a preset set from the phone itself;
compose-signature-and-options gains the round trip that would have caught
this — a one-way test is what let it through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 09:07:33 +02:00
Paul LütznerandClaude Opus 5 cf9ac15e35 Merge fix/compose-editor-polish: in-app popovers, and a picker that fits
Replaces the native prompt and confirm with popovers: a link editor with URL
and text prefilled from the selection, a link viewer with Open/Change/Remove,
and a plain-text warning. Preset times now honour the user's 12/24 setting,
which formatWallClock never consulted.

Two real display bugs behind the emoji picker's black borders: --rgb-line was
spent without its alpha, so every hairline resolved to pure black, and the
component set color-scheme from the OS rather than the app theme. Also fixes
a more-options menu that was clipped to nothing on a phone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:05:00 +02:00
Paul LütznerandClaude Opus 5 832050b304 fix(compose): a link popover, a themed emoji picker, and clocks that follow the setting
Five reported defects in the compose editor, and one the mobile specs were
never going to catch.

The clock. formatWallClock() asked Intl for an hour and never said which
kind, so the send-later presets followed the LOCALE's default rather than the
user's twelve-or-twenty-four-hour setting — "8:00 AM" beside server-rendered
timestamps reading 08:00. The setting is now stamped on <html> once per render
and read back through assets/clock_format.js, which is a module rather than a
Stimulus value because the snooze menu had the identical bug and shares no
template with compose. Both pass hour12 explicitly now.

The link button. It raised prompt('Enter URL:') — one unthemed field, no way
to say what the link should read as, and a bare "example.com" going in as a
relative link to a path inside the app. It is now a popover with a URL field
and a text field, the text prefilled from the selection. The selection
survives because the toolbar's saved range is FROZEN while the popover is
open: focusing the URL field blurs the editor, and a blur allowed to re-read
the selection would record the collapsed one. URLs are normalised and screened
against MailBodySanitizer's own allowLinkSchemes list, so nothing the editor
accepts is silently dropped on save, and "example.com" becomes https://.

Links in the editor. A click in a contenteditable places a caret and nothing
else, so a link just written was unreachable. Clicking one now shows the same
popover's other face — the address, Open, Change, Remove — and it dismisses on
Escape, on an outside click, and when the caret moves off the link. It does
not take focus and does not restore any range, because that face appears
beside a caret the user placed themselves. Anchors in the editor are also
styled as links again: Tailwind's preflight resets `a` to inherit, which left
a freshly inserted link indistinguishable from the words around it.

The emoji picker's "weird black borders". --rgb-line is `0 0 0` in every light
theme and `255 255 255` in every dark one — a hue whose weight lives in
--line-alpha — and the picker was handed it with no alpha, so its hairlines
resolved to pure black. Its search field and scrollbar were black for a second
reason: emoji-picker-element declares color-scheme on its own :host from
prefers-color-scheme, the OS setting this app uses for nothing. Both fixed,
the second with `color-scheme: inherit` so a runtime theme switch carries the
picker with it.

The plain-text warning. window.confirm() becomes a popover over the editor,
with the same rule (only when there is formatting to lose) and the same
reversibility. Refusable by Cancel, Escape or an outside click.

And the one nobody reported: the More-options menu was UNREACHABLE on a phone.
Its button sits in .compose-scroll-x, whose overflow-x:auto computes
overflow-y:auto too, so a menu opening upwards out of a 36px strip was clipped
to nothing — plain text, priority and read receipt could be tapped open and
never seen. Pinned to the viewport below md, as the emoji panel already was.

compose-mobile.spec.ts now measures every one of these against the viewport at
393px AND 320px, in both axes and with a minimum size, because the last round
shipped an off-screen emoji panel past a spec that only asserted the panel
existed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:03:48 +02:00
Paul LütznerandClaude Opus 5 a28f9da47c feat(compose): call a scheduled send off from the row it sits on
The hold was already announced — the composer closes and a toast names the
time — but calling it off meant opening the draft and then opening the send
menu, and the Drafts row itself offered no way out. On a phone that was the
only way out at all: the send pill is desktop-only, so the row is the sole
surface a scheduled draft has there.

  • The scheduled badge grows a cancel. Badge and × are one pill rather than
    a badge with a button parked beside it — the × means nothing on its own.
    It sits ON TOP of the row's overlay anchor exactly as the star and the
    hover actions do (z-10 on a flex item, plus stopPropagation in
    mail--message-row), so no nested <form> in a shared partial and no click
    reaching the link underneath.

  • POST /compose/unschedule/{id} answers with the row redrawn, not with the
    composer reopened: undo() exists to catch a send you regretted while you
    were still looking at it, and a hold set on Friday is called off on
    Monday from a list of forty rows. Both now share callOffSend(); the
    sentAt guard is in one place instead of two. A message that has already
    gone is left entirely alone — submissionSendAt is by then the record of
    when it was due — and the row is redrawn anyway, because what was on
    screen was stale.

  • The toast's action is relabelled "Cancel send". "Undo" is the wrong verb
    for something that has not happened yet, and the long form pushed the
    message onto four lines; _toast.html.twig takes an optional undo_label.

  • On a stacked row a scheduled draft shows the badge instead of the
    preview, which at 393px was two characters and an ellipsis. `hidden
    @xl:inline-block`, not `@xl:block`: Tailwind emits `.hidden` after
    `.block`, so the container variant does not outrank it.

Every string translated (en/de/en_PI), including the sr-only label. The
mobile spec measures the pill's bounding box and the tap target rather than
asserting presence, after the target came out 11px tall.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 21:32:51 +02:00
Paul Lützner 0620de10ab feat(compose): signatures, a more-options menu, and an honest encrypt button
The last three placeholder controls in the compose toolbar. The
`placeholderTools` loop is gone; nothing in that window is a picture of a
button any more.

SIGNATURES. Per account and per alias, in the existing jsonb settings bag —
no migration. The two levels are read by the key's PRESENCE, not its value,
exactly as the read-receipt setting next to it: an alias with no key inherits
the account signature, and an alias holding the empty string signs with
nothing. Collapsing those would make an unsigned personal address on a signed
work mailbox impossible, which is the case the per-alias setting exists for.

Written through MailBodySanitizer on every path in — the settings panel and
"Identity/set" — because a signature is HTML injected verbatim into every
outgoing message. The allow-list drops class and data- attributes, so the
`data-pl-signature` marker is added around the sanitised value and a signature
can never smuggle in a `data-cid` for InlineImageRewriter to mistake for one
of its own inline images.

Auto-inserted above the quote on new, reply and forward, with an empty
paragraph in front of it: the composer puts the caret in the body's first
editable node, so a body starting with the signature block starts with the
caret INSIDE the signature. The toolbar button replaces the block in place
rather than appending a second, and so does the From switch, which swaps only
the block — a paragraph already typed survives changing sender.

Exposed through Identity/get and accepted by Identity/set, so a phone and the
browser sign with the same thing. `plMail stores no signature` is no longer
true and no longer says so.

MORE OPTIONS. A ui--dropdown rendering the priority and readReceiptRequested
fields ComposeType already mapped — until now the window rendered with
render_rest:false and bound them to null and false on every autosave. Plus
plain-text mode, which needs no schema either: a message with text and no HTML
IS a plain-text message, which is the same pair MessageSendService branches
on, so the mode survives a reload with nothing storing it. Switching to it
warns first and is reversible until the draft is saved that way.

ENCRYPT. Disabled, named, and it says why. There is no encryption yet and a
live-looking lock is the one lie a mail client must not tell.

Every string in EN, DE and en_PI; the JS ones through the i18n value.
2026-08-12 18:26:18 +02:00
Paul LütznerandClaude Opus 5 c47eef8b5e Merge feat/compose-emoji-inline-image: a real emoji picker and inline images
Wires two of the placeholder toolbar buttons. The emoji picker is the full
Unicode set with search, skin tones and recents, vendored locally so nothing
is fetched from a CDN, and it never interprets typed text -- emoji arrive
only by an explicit click. Inline images upload as cid: parts the send path
already knew how to embed, including paste and drag-drop.

Conflicts in ComposeController were additive on both sides (a constructor
dependency and an import each) and both were kept.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 17:53:12 +02:00
Paul LütznerandClaude Opus 5 81fa3d6aa0 feat(compose): wire the send-options chevron to scheduled send
The chevron beside Send was a type="button" with no data-action at all —
named for the accessibility audit, inert to the click. It now opens a menu:
Tomorrow morning / Tomorrow afternoon / Monday morning, a separator, and
"Pick date & time" revealing an inline <input type="datetime-local">. Same
ui--dropdown shape as the integration picker, so outside-click and Escape
come free.

Message::$submissionSendAt and MessageSendService already honoured a hold,
and JMAP has implemented delayed send end to end for a while. The web
composer was the only surface that never set it.

POST /compose/schedule[/{id}] runs the same path as send() — recipient
validation, firstUnsendableAddress(), DraftPersister::save() — then records
the hold and dispatches SendMessageMessage with a DelayStamp for its
distance rather than the fixed ten seconds that IS the undo window. Each
menu item is a real submit button with `formaction`, so a schedule goes
through the form exactly as a send does, guards and CSRF included, rather
than down a second quieter path.

Timezone: the menu computes wall clocks against the user's *configured*
zone (ClockGlobal::getTimezone, resolved exactly as TwigTimezoneSubscriber
resolves it) and posts "2026-08-13T09:00"; ScheduledSendResolver turns that
into an instant in PHP, where the tz database knows what to do with a
spring-forward Sunday. No offset arithmetic in JavaScript at all. The
browser's own zone is deliberately not consulted — a laptop still on New
York time must not move a Berlin user's morning.

Ceiling: ScheduledSendResolver::MAX_SECONDS imports
SubmissionEnvelope::MAX_HOLD_SECONDS, the value the session advertises as
maxDelayedSend, so web and JMAP cannot disagree about what a client was
told. A floor of one minute as well: below it "schedule" is "send" with a
worse undo.

Cancel reuses the undo endpoint — `cancelled = true`, which
SendMessageHandler reads and clears — and now also clears submissionSendAt,
because that column with sentAt null is exactly what EmailSubmission/get
reports as `pending`; leaving it made a cancel invisible on every other
device, and permanent.

Surfaced in the Drafts list and on a draft's row inside a thread, since the
toast has faded by the time anyone goes looking. The schedule menu also
carries "Scheduled to send … / Cancel scheduled send" on a draft already
held, which is the affordance that is still there tomorrow.

While in ComposeController: adopt RendersTurboStreams and delete the
byte-for-byte private turboStream() copy.

Tests: ScheduledSendResolverTest (zone, spring-forward pair, floor,
ceiling, delay), ScheduledSendTest (hold recorded in the user's zone and
demonstrably not the server's, delay, cancel, past/over-ceiling/unsendable
refused), DraftRowRenderTest (badge), and compose-scheduled-send.spec.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 17:39:35 +02:00
Paul Lützner 07253c8bb7 feat(compose): wire the emoji picker and inline images
Two of the five inert buttons in the compose toolbar's placeholder loop now
do what they have always claimed to: the smiley opens a real picker, and the
image button puts a picture in the body rather than beside it. The other
three are left in the loop untouched.

Emoji — the full Unicode set, via `emoji-picker-element` in a `ui--dropdown`
shaped like the integration picker beside it, so Escape and outside-click
come free. Two things the component does by default are not acceptable here
and are configured out: its data source is a jsdelivr URL, replaced by
emojibase JSON vendored per locale under assets/emoji (so search works in
German); and its labels are English, replaced by the same Twig-side i18n
object the rest of the window uses. A Playwright assertion pins the rule the
whole feature is built around — emoji enter the body when somebody picks one
and at no other time, so `:)` and `:smile:` survive typing byte for byte.

Noto Color Emoji (OFL) ships with it, COLRv1, as the ten unicode-range woff2
subsets Google Fonts itself serves: 2.0 MB on disk, but a body with three
smileys fetches only the ~140 KB slice they live in. Scoped to the picker and
the compose body, so the grid matches the phone keyboard people know it by
instead of changing shape per operating system.

Inline images — the read and send sides already existed (MessagePart carries
contentId/isInline; buildEmail() already embeds), so this adds the two
missing halves. DraftAttachmentService::attachInline() stores the bytes down
the same path as an attachment and describes them differently: inline
disposition, a generated content id, isInline — which keeps syncFlag() from
putting a paperclip on a mail whose only "attachment" is a picture the reader
can already see. InlineImageRewriter is the inverse of resolveCids(): the
editor holds the attachment route because a browser cannot render `cid:`, the
wire holds `cid:` because a recipient cannot resolve anything else, and
`data-cid` is the identity that bridges them. It runs inside
DraftPersister::save(), so autosave, send and plainTextBody() all see the
same body.

Paste and drag-drop go through the same endpoint. Without them a pasted
screenshot becomes a data: URI that every later autosave posts back as
base64.

Refusal messages from DraftAttachmentService were spelled in English in the
method that produced them — the one thing a German compose window said in
English. They go through the translator now, in all three locales.
2026-08-12 17:39:16 +02:00
Paul LütznerandClaude Fable 5 c60c57131a fix(compose): undo reopens the window where the send came from
Cancelling a send has to put the compose window back — and the floating
dock has to survive it. Two symptoms, one cause.

The dock's placement rode on the <turbo-frame id="compose_dock"> element
itself. But the undo stream reopens the window with action="replace" on
that frame, swapping the whole element and throwing its
`fixed bottom-4 right-6 z-50` away with it. The reopened window then
rendered in normal document flow, below the fold — so the send was
cancelled but the window looked like it never came back. Worse, that
classless frame stayed in the page, and it is the very frame the Compose
button and every draft row navigate into: every later dock window landed
below the fold too, until a full page reload restored the markup. That is
the "draft won't open until I reload" report — the same bug, one step on.

Move the dock's positioning onto a stable wrapper div, so replacing the
frame can no longer move the dock.

The inline reply had its own copy of the older failure: _inline_undo.stream
renders the same compose window with `only`, and never passed
pickerIntegrations — which under strict_variables is a 500. The inline
cancel flushed and then failed to reopen, leaving the draft with no way
back to it, exactly as the dock undo used to. Pass it through.

Pinned by tests/e2e/compose-undo-reopen.spec.ts: a dock send reopens on
screen with its fields intact and the next Compose still opens on screen;
an inline reply reopens inline, not in the dock; and a drafts-list row
opens on the first click when it arrived by a turbo navigation. The
placement assertions check where the window actually is — an element in
normal flow below the fold is still "visible" to a toBeVisible() check,
which is why the existing undo test passed through the regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 12:36:47 +02:00
Paul Lützner 110cfb298c Merge fix/search-routing-i18n
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

# Conflicts:
#	migrations/Version20260811160000.php
#	templates/calendar/_event_modal.html.twig
#	templates/mail/_message_details.html.twig
#	templates/mail/print.html.twig
2026-08-11 18:41:17 +02:00
Paul LütznerandClaude Fable 5 17c21df702 Merge fix/compose-safety
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:39:11 +02:00
Claude 1836e673e7 fix(a11y): name account controls per account, stop hardcoding white on accent
A-04: every account rendered a folder toggle labelled "Toggle account folders",
so three accounts gave three controls a screen reader reads identically. The
address is in the label now, and the account link is named explicitly rather
than by a `truncate` span that CSS can cut mid-address.

A-07 (second half): computing --rgb-accent-ink by luminance achieves nothing
while 63 call sites paint `text-white` on `bg-accent` directly. They now use
text-accent-ink and inherit the measured choice. The two left behind are white
over a black gradient and over a danger fill, which are not the accent.

H-05: the search box kept a stale query when a folder was clicked — the input
survives the Turbo visit and a folder page has no `q` to overwrite it with, so
the box claimed a filter the list below it had not applied. It now tracks `?q=`
in the address bar, which is also right on the way back in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 17:20:54 +02:00
Paul LütznerandClaude Fable 5 af5439fe3f Stop Enter sending mail, and make undo bring the window back
Six items from an external bug report against v0.0.30, all in compose.

Enter in the To field sent the message. The mechanism is implicit form
submission: Tom Select calls preventDefault on Enter only when it has a use for
the key — a suggestion to commit, or a createItem() that succeeds. Type
something with no "@" and neither applies, so the keydown reached the <form>,
whose action is the send URL. The mail went out to nobody, with no subject and
no body. Every existing test missed it because they all type valid addresses,
which Tom Select always swallows.

Enter is now claimed unconditionally and then asked what it should have meant:
a chip in an address row, a move to the body elsewhere, and a refusal that says
so when the text cannot be an address. Ctrl/Cmd+Enter is the one deliberate
send.

Undo was a 500. The undo response rendered the compose window without
pickerIntegrations, and the cancel flag was flushed before that render — so the
send was called off, the toast faded, and the draft was filed with no way back
to it. It now renders as a turbo-stream with every variable the window needs
and replaces the frame, so cancelling reopens what was being written, with
recipients, subject and body intact.

Recipients are validated at the chip, at the send, and on the server: a Count
in the form's existing `send` group (so a draft may still have none), plus an
address-format check on what is about to go on the wire. An empty subject or
body asks first, Gmail-style.

The From default and the PRIMARY badge named different accounts. isPrimary is
positional on the user's own arrangement (AccountCreator::resequence), but the
settings page listed alphabetically and badged loop.first — so the badge was a
statement about the alphabet. The badge reads the flag now, the list is ordered
the way the reorder endpoint already re-renders it, and compose's fallback for
installs with no flag at all is ordered rather than arbitrary.

The window was English inside a German UI. Every string is translated in all
three catalogues, including the ones the Stimulus controller writes, which
travel as an i18n value the way ui--select's already do.

Accessible names: the recipient comboboxes, the typeface and size pickers, and
the six unnamed icon buttons. The text-colour "A" was a label wrapping a 0x0,
pointer-events:none, tabindex="-1" input — a picture of a button, in the
accessibility tree not at all; the input now covers the control and is focusable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 17:15:37 +02:00
Paul LütznerandClaude Fable 5 917a508566 fix(mail): two attachment clicks that did nothing and said nothing
Both are in the message view, and neither left a trace anywhere.

The download link was a plain same-origin anchor, so Turbo Drive intercepted the
click, fetched the URL itself and dropped the response for not being HTML. The
request went out, the app answered 200 with the file, the access log looked
perfect, and the browser saved nothing — no console error, no exception, no
failed request to find. `download` is what tells Turbo to leave a link to the
browser; the backup export, the ICS subscribe link and the appearance export all
already carry it for exactly this reason, and these two call sites were missed.
The draft attachment list had the same link and the same silence.

"Save to…" opened a menu positioned `top-full` against an element carrying
`overflow-hidden` — the clipping that rounds the chip's two halves inside one
border. The menu opens below that box, so it was clipped away entirely: it had
a bounding rect and no paint, and hit-testing at its own centre found whatever
happened to be behind it. Clipping now belongs to an inner box and positioning
to the outer one, which is the only arrangement where both are true at once.

The spec covering save-to passed throughout, which is the more useful half of
this: toBeVisible() is satisfied by a rect, and Playwright clicks at coordinates
rather than at what a person can reach, so a menu painted nowhere tested exactly
like a menu on screen. Both specs now assert what someone would actually notice
— a download event carrying the right filename and bytes, and a menu whose own
centre hit-tests to itself — plus the POST that the save reaches the server
with. The upload beyond that boundary is not proven here: the test stack's
Nextcloud is unreachable by design, so what is pinned is that the click gets
out and that the answer gets back to the reader.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 13:06:44 +02:00
Claude Fable 5 e453cc9516 feat(ui): every select is a Tom Select, themed from the palette tokens
`select-field` got the closed control right — appearance:none, the app's fill,
the app's chevron — and could never get the popup, because the popup is the one
part of a form control CSS does not reach. color-scheme was the last thing left
to try and it only picks light or dark: the list is still the platform's font,
the platform's row height and the platform's highlight, sitting in front of a
themed app in five of the six themes.

Compose already had the answer for its address fields. This is that same widget
on every other single-select in the app, so there is one dropdown here instead
of two.

- assets/controllers/ui/select_controller.js progressively enhances a native
  <select>. The element stays in the DOM as the source of truth, so forms post
  identically, `onchange="…requestSubmit()"` and `data-action="change->…"` keep
  firing, and no-JS renders the native control the stylesheet still styles.
- The panel is parented to <body>: every call site is inside something that
  clips. It flips above the control when there is no room below and caps itself
  to the room there is, which Tom Select does not do on its own, and closes on
  scroll rather than being left behind by the pane it was anchored to.
- Short lists lose the search box entirely, so a five-item picker behaves like
  the native control it replaced instead of blinking a text caret.
- tom-select.css now skins .single as the twin of `select-field` — same fill,
  border, radius, chevron and height, the last one measured per call site so
  h-8 rows stay h-8. No per-theme rules: the six themes differ only in what the
  tokens resolve to.
- The rule editor builds five selects in JavaScript and was missed by the last
  sweep; it stamps the attributes itself now.
- Compose's font pickers are converted too, frameless so the format bar keeps
  its character. Their labels were the lookup keys, which is why they were
  English in a German UI; the values carry the keys now.
- "No results found" arrives from Twig |trans (EN/DE/en_PI).

Left native, deliberately: compose's multi-select address fields, which have a
richer Tom Select of their own; the provider preset, enhanced by UX Autocomplete
because it fetches its choices; and compose's sr-only account select, which is
never shown. PageRendersTest now sweeps every page for a select that is none of
those and carries no widget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 15:33:09 +02:00
Paul LütznerandClaude Opus 5 9bc3cf0097 Send the compose context whole instead of unpacking it four times
The undo action listed seven template variables, five of which were the same
context every other render already had, and urlParams was recomputed at three
call sites from the same input. Now composeContext carries urlParams and the
renders pass ctx.

The templates read ctx.frame and ctx.inline rather than taking them apart
again. _window keeps its defaults, so it still renders standalone.

One trap in doing it: _window already had a local named ctx meaning the query
params, and four route calls used it. Renaming the incoming variable to ctx
without noticing would have put inline, replyTo and a nested array into every
draft and send URL -- a break with no error, since path() takes extra
parameters as a query string. The local is params now.

Also fixes the From alias, which never survived a save. The composer wrote the
address the user picked onto the message and markAsDraft replaced it with the
account's own one line later, so every mail sent from an alias went out as the
primary address. The sender is a parameter now, defaulting to the account.

That one is worth reading with the Android notes open: SERVER_REQUESTS.md files
the identity gap as JMAP-only, reasoning that "the web composer does honour the
choice, through ComposeController::resolveFromAddress()". That method does not
exist anywhere in src/. Both surfaces ignored the alias, so the ask is wider
than it is filed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 13:22:13 +02:00
Paul Lützner 122006460d onboarding. this is a lot 2026-07-30 16:40:31 +02:00
Paul Lützner f514bea724 mobile compose rework 2026-07-30 13:01:19 +02:00
Paul Lützner 92593a756a small fixes. 2026-07-30 12:30:15 +02:00
Paul Lützner 54e3ae3b3b stimulus controller reorder 2026-07-30 11:04:15 +02:00