100 Commits
Author SHA1 Message Date
Paul LütznerandClaude Opus 5.5 ff87ee92d7 Release v0.3.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:08:49 +02:00
Paul LütznerandClaude Opus 5.5 12faf4d8bd Keep help text from following an input, so Bitwarden stops reading it as a label
Scopes on Admin → SSO still opened Bitwarden's menu after its placeholder
went, offering a "New identity".

Bitwarden takes the text after a field as part of the field's label, and in
the form theme the help paragraph was the input's next sibling. The Scopes
help explains the `email` scope and says the word twice, which made the
field an identity's email address — whatever autocomplete and data-bwignore
said on the input itself. The diagnosis came from inspecting the live page;
the placeholder removed in the previous commit was a second place the word
stood, not the one that mattered.

form_row now puts the widget in an element of its own, so nothing follows an
input as a sibling. `display: contents` on the wrapper, so the row lays out
exactly as before: measured 8px above and below the input, full width,
unchanged. This covers every field with help text, not this one.

Not checked against Bitwarden itself, which this environment does not have.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:59:15 +02:00
Paul LütznerandClaude Opus 5.5 28cce0c9c3 Drop the placeholder that made Bitwarden take Scopes for an email field
Reported from Admin → SSO after the last fix: every other field was left
alone, and Scopes still opened Bitwarden's menu, now offering a "New
identity".

Its placeholder read "openid email profile". Bitwarden classifies identity
fields by keywords in a field's attributes, the placeholder among them, and
"email" there is enough — with autocomplete="off" and data-bwignore both on
the field. It was the only field on the page with that word in an attribute.

The placeholder goes; the help text under the field already names the
default scopes.

Not checked against Bitwarden itself, which this environment does not have.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:52:21 +02:00
Paul LütznerandClaude Opus 5.5 855d0faedc Draw pasted secrets as masked text fields instead of password inputs
Reported from Admin → SSO after the previous fix: Chrome no longer filled
the saved login in on load, but a click in the client secret still listed
every saved password, Bitwarden offered to generate a password into it, and
Bitwarden opened its menu in "Name on the button" and "Scopes" below.

All of it follows from the field being type="password", and three rounds of
attributes each moved the symptom. `off` is ignored by Chrome on a password
field. `new-password` stops the fill and makes Bitwarden read the form as a
sign-up. The vendor opt-outs stop extensions filling and not Bitwarden's
menu opening.

So client secrets, API tokens and mail-server passwords are a new SecretType:
a text input drawn as dots by CSS, never rendered back, not trimmed. With no
password input in the form there is nothing to classify. The sign-in form,
the first administrator's password and the change-password form stay real
password inputs, where offering to save is the point.

Bitwarden's menu in the two text fields had a second cause: they were the
only ones with no `autocomplete` at all, and the two that said `off` were
left alone. The form theme now says `off` for every text field that asks
for nothing.

PasswordManagerIgnore::SECRET goes; nothing uses it. The test added with the
previous fix is replaced by two: a secret renders as masked text and comes
back empty, and no form outside the three login ones uses PasswordType.

Checked in a browser that the field masks and the frame holds no password
input. Not checked: Bitwarden and Chrome's password manager themselves,
which this environment does not have.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:46:49 +02:00
Paul LütznerandClaude Opus 5.5 cc7b00311e Send new-password on the secret fields that were overriding it
Reported from Admin → SSO: Chrome put the administrator's own mail address
in the client ID and their password in the client secret.

PasswordManagerIgnore::SECRET exists to stop exactly that, and carries
autocomplete="new-password". Six credential fields wrote
'autocomplete' => 'off' in the array they unioned SECRET onto, and with `+`
the left side wins — so the earlier fix reached the Admin → AI token field,
which spreads the constant, and none of the others. Chrome ignores `off` on a
password field. The new single sign-on form copied the pattern.

The fields no longer set an autocomplete of their own. The two mail account
password fields set new-password twice and lose the duplicate, so one rule
holds everywhere.

A test reads every form for an array that sets `autocomplete` and is unioned
with SECRET, because the mistake is in how the array is written and a new
form can make it without being in any list. Shown to fail with the line put
back.

Not run: the full suite. tests/Form passes apart from the one test that
needs a database, which this run did not have.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:33:53 +02:00
Paul LütznerandClaude Opus 5.5 3d1cccfa77 Release v0.2.53
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:27:37 +02:00
Paul LütznerandClaude Opus 5.5 e6868482c1 feat(calendar): answering an invitation shows the meeting in the calendar
Yes or Maybe redrew the card and put a toast in the corner, and the
calendar pane beside it went on showing the week without the meeting
until a reload: the response replaced the card and nothing told the
pane.

calendar--invite makes the calendar part of the answer, in three layers
that each work without the next:

- Preview. Hovering or focusing Yes or Maybe outlines the slot the
  meeting would take, dashed, in its calendar's colour, at every motion
  level. The slot is worked out in the browser from the card's UTC
  instants on the grid's own clock (data-grid-zone), full width; the
  real block takes its lane when it arrives.
- Flight. At Full motion the pressed answer arcs to that slot and turns
  into the meeting on the way, or shrinks into a dot on the calendar
  button when the pane is closed. It reads --motion-travel, a new
  MotionLevel token: 580ms at Full, zero at Minimal and None and under
  prefers-reduced-motion. DESIGN.md names it the second exception to the
  260ms ceiling, beside new mail.
- Redraw. Once the server has answered, the pane is fetched and morphed
  in place with Idiomorph, since a pane embedded at page load has no src
  to reload, and the real block is lit where the placeholder stood. A
  screen reader hears where it went.

Nothing waits on the iTIP reply the server sends first: the button takes
its answer at once and the flight lands on a placeholder the real block
replaces. A failed request takes all of it back.

A meeting answered Maybe is striped in its calendar's colour with a
dashed edge, in every view, and says "Maybe" in its accessible name.
Nothing drew a tentative meeting differently before. Only the reader's
own answer stripes it, not an event's STATUS.

app:test:seed-invite hangs an unanswered invitation on seed-mail's
"E2E Read Me". calendar-invite.spec.ts answers it at Full (Yes flies,
no reload) and under reduced motion (Maybe outlines a striped slot,
nothing flies, the block is striped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:27:37 +02:00
Paul LütznerandClaude Opus 5.5 cf89599968 docs(design): add DESIGN.md, the design system in design.md format
The Paper + Flat tokens — 30 colours, 13 type scales, radii, spacing and
41 components — in the front matter of google-labs' design.md format,
and prose on how plMail is drawn: correspondence on warm paper, the ink
ramp and its floors, the accent as a fill and as text, the message
sheet, themes and layouts, type, elevation, components, icons, motion,
and the do's and don'ts. The prose names the app's own utilities and
roles (bg-surface, text-ink-muted, rounded-pane) rather than hex values,
since every theme restates the same roles.

It lints with no errors and one warning, the orphaned field-border
token, which is deliberate: text fields are filled wells, not outlined
boxes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:31:59 +02:00
Paul LütznerandClaude Opus 5.5 c4420101f4 fix(mail): the reading sheet's link and status colours hold 4.5:1
@utility mail-sheet re-points the ink onto the always-light sheet a
message body is read on. It carried its own link and danger colours
there, which nothing had measured on the sheet, and it took warning,
success and info straight from the theme. Both fell short:

- nord's link (#5e81ac) and danger (#bf616a) read 3.50:1 and 3.55:1 on
  its snow-storm sheet. Solar's link (#268bd2) read 3.41:1 on base3,
  and its red 4.29:1 there and 3.70:1 inside the spam warning.
- On every dark theme the warning, success and info the sheet borrowed
  were the ones lightened for a near-black surface: 1.4–2.4:1 on the
  light sheet. They are text in there — the read-receipt line, "Draft
  saved" in an inline reply, the scheduled badge.

Every block now declares --rgb-sheet-warning, -success and -info, and
mail-sheet re-points the three roles to them as it did danger. A light
theme's are its page values, which already read 5.3:1 or better on its
sheet. A dark theme's status colours, and nord's and solar's link and
danger, are the old colour mixed toward black in oklab, hue kept, only
as far as 4.5:1 needs: 27–40% for the dark themes' status colours.

ThemeInkContrastTest holds the link on the sheet, and each of the four
status colours on the sheet and on a 10% wash of itself: the spam
warning's bg-danger/10 and the scheduled badge's bg-info/10 are the
heaviest the sheet draws. ThemeVariableCompletenessTest lists the three
new channels. With the old colours back, the test fails solar and all
eleven dark blocks, nord among them.

Measured on the painted pixels in Chromium, the templates' markup under
all 39 themes: nothing below 4.50:1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:30:51 +02:00
Paul LütznerandClaude Opus 5.5 be665c93ae fix(theme): every theme's ink, status colours and accent text hold 4.5:1
ThemeInkContrastTest held each ink tier on its own surface, and nothing
else. Three things were never measured, and each was failing:

The page. In the Flat layout the sidebar and the top bar sit on
--app-bg, not on a pane. Paper's ink-faint was 4.57:1 on its surface
and 4.34:1 there, Light's 3.77:1 in its indigo-100 corner, and nord's
3.17:1 on the #434c5e end of its gradient. Every ink tier is now held
on every colour stop of the page as well.

The status colours. They are text — a badge, an alert, a failed step —
and nearly every light theme still wore Tailwind's 600s: Paper's warning
was 2.70:1 inside its own badge. They are held to 4.5:1 on the surface,
on the page and on a 12% wash of themselves, the wash computed the way
Chromium paints it (eight-bit alpha, Skia's integer SrcOver), because
values that cleared the exact sum at 4.51:1 painted at 4.49:1. Each
failing value is its old colour mixed toward black in oklab (toward
white on nord and aurora), hue kept, only as far as the floor needs.

The logo colourways. The palette regex was `[a-z]+`, so the nineteen
hyphenated themes were skipped without a word while two of them shipped
a reading sheet whose ramp ran backwards. It is `[a-z-]+`, and every
block is held now, so a theme added later is measured from its first
commit.

The accent, as text, is derived from whatever accent is set: on a light
scheme it is mixed toward black in oklab until its lightness is at most
0.47, on a dark one toward white until it is at least 0.70, and an
accent that already reads is left alone. `text-accent` and its variants
resolve to it through --text-color-accent, and Tom Select's chips and
options use it; fills keep the accent itself. Dark's green was 2.95:1
as text and Paper's clay 3.97:1 on its own wash. AccentTextContrastTest
sweeps the whole RGB cube against the two bounds on Paper's and Dark's
surface, page, hovered row and the accent's own washes.

Paper and solar each declared --field-border-alpha twice, and the later
0.4 won, so the firmer border their comments described never painted.
ThemeVariableCompletenessTest now refuses a variable declared twice in
one block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:30:20 +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 1ad2a46689 fix(calendar): the topbar switch is drawn in its remembered position
The mailbox shell renders its layout in the remembered position, but
the topbar's calendar switch went out as `mail` whatever the preference
said, and ui--split corrected it on connect. By then the page had been
styled, and the switch carries transition-colors for its hover, so
every desktop load and every Turbo visit with a remembered split or
calendar painted the mail icon, swapped in the right one and faded the
accent in over 150ms. 994d0a9c fixed the same fade on the sidebar rows
and named this one as left over.

The server cannot know the width, and below lg ui--split opens every
position on the mail, so the shell's topbar now draws both answers and
lets the breakpoint pick: the mail icon `lg:hidden`, the remembered
one `hidden lg:flex`, and the switch `lg:bg-accent-soft lg:text-accent`
over its muted ink. On connect, once it has settled the position for
this window, ui--split takes those classes off again, leaving the
`hidden` attribute and the plain classes it always wrote. Each icon has
a wrapper, because Font Awesome's display outranks a utility on the <i>
itself. Pages outside the shell include the bar without
`calendarRestored` and still draw `mail`.

CalendarSwitchFirstPaintTest reads the switch out of the raw response
for every remembered mode, at both widths the way the stylesheet does,
and holds its classes to the lists ui--split removes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:29:11 +02:00
Paul LütznerandClaude Opus 5.5 16de5372e2 test(e2e): the account-reorder spec counts from where it started
"reordering accounts changes neither the primary nor the dots" assumed
the worker held exactly two accounts, its own and seed-mail's. The
worker also holds whatever other seeders and specs left beside them —
once, seed-demo's account — and then the count, "move the second row
up" and the expected order were all wrong for a reason that had nothing
to do with reordering.

It now counts the rows before adding its account and expects one more,
moves only its own account, a step at a time until it is on top, and
expects everyone else in the order they had. The explicit primary
control is pressed on its own account too: past two accounts, "the
first row that is not primary" can be one of the worker's, and it would
have stayed primary after the finally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:28:55 +02:00
Paul LütznerandClaude Opus 5.5 9e70fa0095 fix(ai): mail about the reader's own application or account is an update
The job-match fix taught the categoriser that recommendations are
promotions, and it took more than job matches with it: every mail about
an application the reader had sent went to Promotions too — "Deine
Bewerbung bei …" from a platform, a recruiter's team confirming one had
arrived — and so did "Schalten Sie jetzt Ihren Zugang frei" for an
account the reader had just opened. The tie-break did that last one on
its own: "promotions wants you to act" is true of an activation link.

Updates now names the reader's own job applications and account
activation, and the tie-break asks who started it rather than whether
it asks for a step: updates if the reader started it, even when it asks
for one; promotions if it offers something new.

app:ai:categorise-check gains the three reported shapes as fixtures, two
of them with no plain-text part at all as the reported ones had, so it
now sorts ten messages, and CONTRIBUTING's command table says so. The
docblock's cap is corrected to AiPrompts::MAX_LENGTH; the prompt is
1,103 characters of its 1,200.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:28:44 +02:00
Paul LütznerandClaude Opus 5.5 994d0a9c0d fix(sidebar): the open row no longer fades in from black on every load
Every sidebar row went out with no colour class, and ui--sidebar gave
the open one bg-accent-soft and text-accent (and every other row
text-ink-muted) on connect. By then the browser had already styled the
page, and the rows carry transition-colors for their hover, so on every
full load and every Turbo visit the open row faded in over 150ms from
the inherited black and a transparent pill, and the rest faded from
black to their muted ink. Measured in headless Chromium against the
test stack: app.css loaded at ~155ms, first paint at ~200ms with the
rows black, the controller's pass at ~250ms, transitionrun on every
visible row in the next frame. The stylesheet order was never the
cause, and neither was the per-frame sampler: plain transitionrun
listeners see the same thing.

The server now renders the state. SidebarRowStateExtension restates
ui--sidebar#_matches against the main request, and every link row in
the sidebar, the account list and an expanded account's folders takes
its classes from sidebar_row_state(). The controller's pass is then a
no-op on everything the server drew, the first style the browser
resolves is the final one, and the hover transitions are untouched
(still ~130ms in and out). The pass stays for frame navigations and
for rows that arrive later.

After: the open row's first sample is already its final colour, and no
sidebar row runs a transition on load, on a Turbo visit or in the
mobile drawer. Two transitions remain at load and neither is a sidebar
row: the Mercure dot, which is neutral until the stream reports, by
design, and the topbar calendar toggle, which is still rendered as
"mail" and corrected on connect.

SidebarRowStateTest pins the rendered state, the matching rule, and
that the class lists agree with the controller's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:37:33 +02:00
Paul LütznerandClaude Opus 5.5 ca2498f6e9 fix(settings): the accounts row's status icons use the status tokens
_push_control drew its failed, degraded and on states in literal amber
and emerald, each with a dark: override, and its focus ring in indigo.
The account toggle beside it was green-500, so one row showed two
different greens. The states now use text-warning and text-success,
with a /10 wash of the same token on hover. The ring uses ring-accent,
as it does across the app. That follows CODESTYLE rule 32, and the
toggle and push-on now share one green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:32:19 +02:00
Paul LütznerandClaude Opus 5.5 add73ebbca fix(mail): the unsignable-PDF strip gets its warning wash
The strip that says why a PDF cannot be signed had bg-warning-soft, and
there is no warning-soft token (danger and info have one, warning does
not). Tailwind generates nothing for a utility it cannot resolve, so
the strip has painted no tint since the signing feature shipped.

It takes bg-warning/10, the wash the sender-mismatch warning already
uses, rather than a new token for one strip. Painted on pane-flat, it
is a visible tint in Paper and in Dark, with ink-soft above 6.5:1 on it
in both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:18:25 +02:00
Paul LütznerandClaude Opus 5.5 27429f77fa chore(dead-code): delete the push card nothing includes
settings/accounts/_push_toggle.html.twig had no includer: the accounts
list renders the compact _push_control, and so does the controller's
Turbo response. The card was also drawn entirely in literal palette
colours, against CODESTYLE rule 32.

What only it read goes with it: PushHealth::badgeClasses(), literal
colours again; PushStatus::$expiresAt; and the renews and reregister
keys under settings.accounts.push, in all three catalogues. The
managers' expiresAt() stays, since AccountHealthInspector reads it. The
two comments that pointed at the card now say the row control is the
only one.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:07:39 +02:00
Paul LütznerandClaude Opus 5.5 673a8bac5d fix(deploy): containers no longer walk the whole data directory on start
The php container took about a minute to start on TrueNAS, and 58 of
those 61 seconds were one line of the entrypoint: setfacl -R over all of
var/. On the TrueNAS layout var/data is the whole data dataset - the
Postgres files, one .eml per stored message, every attachment - and the
dataset refuses POSIX ACLs, so the walk read the metadata of every file
only to be told no. The second pass took 0.3s because the first had
warmed the cache. Every service running the entrypoint did the same walk
at the same moment: seven of them in that layout.

The ACLs were for www-data, which nothing in the image runs as - the
line came with the Symfony Docker template. They are set on var/cache
and var/log alone now, the two directories Symfony writes.

Measured in the released image: the step took 1.4-1.9s with 100,000
message files under var/data on RAM-backed storage against 0.2s with
none; it now takes 0.2s either way, and the mail files are left without
ACL entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:07:39 +02:00
Paul LütznerandClaude Opus 5.5 d68ad90572 Release v0.2.51
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:25:13 +02:00
Paul LütznerandClaude Opus 5.5 6d5c8c57b7 docs: the README's drop targets include Starred and Unread
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:23:43 +02:00
Paul LütznerandClaude Opus 5.5 c3a3c5fcbe feat(mail): dropping a conversation on Starred stars it
Starred takes a drop the way Unread does: the conversation is starred
and stays where it was. It was not a drop target at all, and there was
no way to star several conversations at once - the row's own star
button is the only star control.

- A new explicit-ids-only bulk action, `star`, beside `read`. Drop-only
  like move, label and category, so a whole-view selection is refused.
- ThreadStatusUpdater::starThreads() switches stars on and never off.
  The row button's star() toggles, reading the answer off the first
  message; a drop is a destination, so a conversation that is already
  starred - by the thread's starredAt, which the Starred view lists by -
  is left starred. The write is star()'s: the first message carries the
  flag, the thread the timestamp, the whole conversation is pushed.
- mail--dnd: a data-dnd-star target, lit like every other target.

BulkStarTest drops a starred and an unstarred conversation together and
finds both starred; dnd.spec drags a row onto Starred with a real
pointer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:22:51 +02:00
Paul LütznerandClaude Opus 5.5 ad0c61bd25 feat(mail): an opt-in Unread view gathers every unread conversation
A new system label, Unread, off until it is switched on in Settings ->
Labels. Its sidebar row sits under Inbox and opens every unread
conversation in one list with no category tabs - archived and labelled
mail included, the bin, spam and snoozed mail left out. Dropping a
conversation on the row marks it unread and leaves it where it is.

- The label is never bound to an account (LabelResolver::
  userSystemLabel()), so it is not a JMAP Mailbox and no provider push
  can reach it. The labels settings page creates it, the one place it
  can be switched on.
- The list is read off the unread counts (MessageThreadRepository::
  findForUnread()) rather than off the label, which nothing attaches.
  Its sidebar badge counts the same rows.
- A drop posts the bulk "read" action with read: false through a new
  data-dnd-unread target in mail--dnd - the toolbar's Mark as unread,
  by pointer. Moves into it are refused (LabelRole::acceptsMoves()).
- The list refreshes on a sync of any folder, as "*" does, since it
  gathers mail from all of them.

Web only for now: the Android app does not list it.

UnreadViewTest pins which mail the list holds and that the row is
opt-in; dnd.spec drags a read conversation onto the row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:42:24 +02:00
Paul LütznerandClaude Opus 5.5 906352ee3b fix(labels): hiding a system label in settings takes it out of the sidebar
The eye beside Inbox, Sent, Drafts, Trash or Archive in Settings ->
Labels saved the change, greyed the label out in the list, and left the
row in the sidebar. The sidebar only asked about Spam; every other
system row was drawn unconditionally. Even Spam changed only after a
full page load, because the toggle's answer redrew the custom label tree
and the settings list and nothing above them.

- Each system row now asks SidebarCounts::isRoleHidden(), filled by the
  role-label read the sidebar already makes. A role with no label yet
  counts as shown - system labels are created lazily, and a fresh
  install must not lose its Inbox for that. "More" is not drawn when
  everything in it is switched off.
- Toggling a system label answers with both sidebar copies re-rendered,
  for the tab that clicked only: the toggle lives on the settings page
  alone, and another tab sitting in Archive would have its "More"
  snapped shut by a re-render.
- Archive is created visible. It was created hidden from when it was
  IMAP bookkeeping, which is why 6edf33fe stopped the sidebar asking.

Upgrading: Archive labels that already exist keep the hidden flag they
were created with, so Archive leaves the sidebar until it is switched on
in Settings -> Labels - which is what that list has been showing all
along.

testHidingTrashTakesItsRowAwayAndShowingItBringsItBack fails against the
old sidebar and stream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:42:01 +02:00
Paul LütznerandClaude Opus 5.5 684d438ad2 fix(gmail): archiving on the phone no longer logs "Invalid label name"
Archiving a conversation on the phone that was only in the Inbox put an
error in the log every time: "ApplyGmailLabelsHandler: remote label
creation failed", Gmail refusing a label called Archive as "Invalid
label name".

The app archives such a conversation by attaching the Archive mailbox
as it removes the Inbox one, because an email in no mailbox at all is
refused. The Gmail push then treated plMail's Archive label like one the
user had made and asked Gmail to create it. Gmail has no Archive label -
it archives by removing INBOX - and reserves the name. The INBOX removal
is a job of its own and always reached Gmail, so the archive itself
worked; the half that failed had nothing to tell Gmail. Snoozing a Gmail
thread went the same way with the Snoozed label.

A system label is now resolved to the Gmail id GmailLabelSyncer bound it
to - INBOX, SENT, DRAFT, TRASH, SPAM - and never created. Archive and
Snoozed have none and drop out of the push. A label made in plMail is
still created on first use, which the second new test pins.

testArchivingFromThePhoneNeverAsksGmailForAnArchiveLabel logs the
production error line when the guard is taken out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:40:28 +02:00
Paul LütznerandClaude Opus 5.5 ebf1c47ce0 Release v0.2.50
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:32:03 +02:00
Paul LütznerandClaude Opus 5.5 fe42240bd4 docs: the README's calendar draws the two-day birthday as one bar
Retaken after the all-day band learned to span days: the shot still
showed "Jonas - birthday" as a chip on each of its two days.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:31:51 +02:00
Paul LütznerandClaude Opus 5.5 7824821c0a feat(calendar): an event of a day or more is one bar, its hours shaded in the grid
A trip from Friday 17:00 to Sunday 21:00 was a block down each of its
three days, and pointing at one lifted that day alone: Saturday's,
titled at a midnight nobody had scrolled to, rose as a grey column with
nothing on it. The blocks also took a lane from every other meeting of
the weekend.

A timed event of 24 hours or more - the line Google and Outlook draw -
now leaves the hours for the all-day band, as one bar across the days
it covers ("17:00 Bodensee ... until Sun 21:00"), and the hours it takes
are shaded under each day's blocks, which stay full width on top of
them. The bar and every day's shading carry one key, and
span_link_controller lights all of them when the pointer is on any. An
overnight meeting under a day stays in the grid, and its two halves
light together too.

- DayGridLayout::band() lays the band out once over the range, in
  lanes; DayGrid::$shaded carries the hours. The shared page's builder
  lays out the same band, so its grid draws the same bars.
- The band draws bars across columns, so an all-day event over several
  days is one bar too, and every bar is tinted like a block in the grid:
  bare, a bar's start and end sat days apart with nothing between.
- A bar prints its times on the full clock. The band has no axis, so a
  compact "5:00" was two times on a twelve-hour clock.
- A stacked chip aligns its rows to the top. A half-hour meeting in a
  96px column shrank its rows below their text, and a centred title
  spilled up over the time above it.

calendar-multiday.spec.ts drives one through the dialog and hovers it
from both ends; DayGridLayoutTest pins the placement, the lanes and the
24-hour line from both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:30:30 +02:00
Paul LütznerandClaude Opus 5.5 40b5419614 fix(calendar): a copy put on Google or Outlook no longer syncs back as a second event
An event ticked onto a Google calendar showed twice once Google had
synced it: a merged chip in two colours, and a second chip beside it
that nothing merged.

A sync run pushes, then pulls. Google and Microsoft mint their own id
and UID for an event plMail creates, and the pull of the same run hands
it straight back under them. CalendarPusher had set the new remote id on
the entity only, and the pull finds its rows by querying the table - by
remote id, then by UID - so the echo matched nothing and was inserted as
a second row. Every create path did it: the editor, JMAP, bookings,
proposals, .ics import. CalDAV keeps plMail's UID, so its UID fallback
had hidden it there.

- CalendarSyncService flushes the push before the pull reads anything.
  A pushed create also keeps its remote id if the pull then fails, so
  the next run does not push it twice.
- applyOne() looks in the rows its own window wrote before asking the
  table, for the same reason one step later.
- Two rows under one remote id - the pairs already made - are folded
  when the provider next reports the event: the row under the provider's
  UID is kept, the meeting's local copies are carried to it so the
  merged chip stays merged, and the other is removed locally, never at
  the provider. Left as they were, the remote-id lookup could re-key the
  older one into the UID its twin held, which the unique index refuses
  on every sync after.

testACreateEchoedBackUnderTheProvidersOwnUidStaysOneRow fails with two
rows when the flush is taken out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:30:30 +02:00
Paul LütznerandClaude Opus 5.5 25de5a4401 Release v0.2.49
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:25:37 +02:00
Paul LütznerandClaude Opus 5.5 2b8261eebc docs: the README's calendar shows 7 days, from today
Retaken after 66b9701b: the picture still showed a Monday-to-Sunday week
labelled Week. The spec's own events move two days on, because
app:test:seed-demo already fills the first days from today - on top of
it they took the same slots, a "Standup" beside its "Stand-up" at nine,
and left the last two columns empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:24:52 +02:00
Paul LütznerandClaude Opus 5.5 878229fb89 fix(deploy): a database that answers late no longer puts an error in the log
The admin log showed

  CRITICAL console (web) Error thrown while running command
  "dbal:run-sql -q 'SELECT 1'". Message: "... could not translate host
  name "database" to address: Name or service not known"

for a php container that had started while the database container did
not exist yet. The entrypoint waited for the database with a shell loop
that ran `dbal:run-sql -q "SELECT 1"` once a second, and every miss was
an uncaught console exception, which Symfony logs as CRITICAL. Only a
miss that fell just before the database answered could be stored - the
log is a table in that database - so the admin log showed exactly the
misses that did not matter, and never a database that stayed away.

app:db:wait is that loop in one process. It asks through
PostgresStatusRepository::whyUnreachable(), retries a miss without
logging it, and logs once, with the last error, when all sixty attempts
have failed; the container log reads as before. isReachable() uses the
same probe, so /healthz also hangs up after a failed check and dials
afresh on the next one.

Measured against a Postgres that refuses every other login, which makes
"answered a moment later" deterministic: the old probe stored exactly
the reported row, app:db:wait missed once, answered, and stored nothing.
The real entrypoint in the app image, started before its database,
waited through two misses and came up with nothing logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:24:52 +02:00
Paul LütznerandClaude Opus 5.5 66b9701bab feat(calendar): the week view starts today, and is called 7 days
The week ran Monday to Sunday, so by Friday the docked pane spent four of its
columns on days that were over and pushed today towards its right edge, where
seven columns do not fit and the rest scrolls out of sight. It is now the
seven days from the anchor: today on arrival and after Today, the day you
moved to otherwise, and Previous and Next still step seven days. That is no
longer a calendar week, so it is labelled "7 days" ("7 Tage"); the URL segment
and the stored preference stay `week`, so bookmarks and remembered views keep
working.

Month keeps its Monday, as a grid of weeks. The public shared page keeps a
real Monday-to-Sunday week and the label "Week": it builds its own range, and
the toolbar now takes per-view label keys so the two can differ.

The grid fixtures' daily series starts today rather than on Monday, so its
five occurrences stay in view, and the README screenshot lays its events out
from today for the same reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:05:30 +02:00
Paul LütznerandClaude Opus 5.5 13385fd976 fix(ai): Stop on a summary stops the model and keeps nothing
Since a summary whose connection drops is finished and stored anyway
(932c3f1f), Stop did neither thing it says. The abort it sends looks, from
the server, exactly like a dropped connection, so the model ran to the end
and the summary was stored under a card reading "Stopped. Nothing was saved."

So Stop is now also said. The card names each run with a random id, and Stop
posts that id to /mail/thread/{id}/summary/stop, with the same per-action
CSRF token and ownership check as the run. The flag lives in a DBAL cache
pool, because the stop request need not land on the container streaming the
run. The run looks for it before it starts, on every heartbeat and at most
once a second while words arrive, when it finds its reader gone, and once
more before it stores. Finding it returns from the loop, which frees the
generator and drops the call to the model host; the AI panel records it as
cancelled.

Only the button says no. Opening another thread or closing the tab still
counts as leaving, and that summary is still finished and waiting. A page
loaded before this sends no run id, and its run is finished the old way
rather than refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:52:40 +02:00
Paul LütznerandClaude Opus 5.5 77fa3f496d fix(ai): a summary no longer reports a lost connection at its first heartbeat
Every summary that took more than ten seconds to start writing ended with
"The connection to plMail was lost … the model host is not involved", ten
seconds in, while the server went on to finish and store it: reload and it
was there.

The connection was fine. The first heartbeat frame renders the elapsed time,
and it called this.constructor.#clock(). Stimulus registers a subclass of
every controller, so this.constructor is that subclass, and a private static
is not reachable through it: TypeError "Receiver must be class default". The
fetch's catch took every error for a network error and said so. Broken since
the heartbeat arrived on 2026-09-03; the "connection dropped 40 s into a full
run" measured that day was very likely this too.

- #clock is an instance method.
- readFrames() no longer lets a throwing frame handler end the read. The
  error goes to reportError(), and so to the admin browser-errors list with
  its file and line, and the frame is skipped. Both readers put a failed read
  down to the network, which is what hid this one.
- The reading line said "Nothing appears until it has finished", which reads
  as "until the summary is finished". It meant the reading: the words stream
  as they are written, and the line now says so. EN, DE, en_PI.

The new browser test serves the server's frames without a model: a heartbeat
before the first word must neither end the run nor throw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:52:40 +02:00
Paul LütznerandClaude Opus 5.5 51bf4c2ca1 fix(imap): moves and purges find folders with non-ASCII names
Trashing a mail on GMX failed every time:

  ImapProtocol::moveMessage(): Argument #1 ($folder) must be of type
  string, null given

Mailbox::$fullPath is stored as the server lists it, modified UTF-7, so
GMX's "Gelöscht" is "Gel&APY-scht". webklex re-encodes a path unless
told it is UTF-7 already: Message::move() looked the destination up as
"Gel&-APY-scht", found nothing and passed null on. getFolder() does the
same, which also made the destination checks miss (ensureDestinationExists
issued a CREATE on every move) and left PurgeRemoteMessagesHandler
reporting non-ASCII folders "not found on the server", so emptying one
never purged anything there. ASCII names encode to themselves, which is
why "Trash" always worked.

ImapFolderLocator::atPath() is the one lookup for a stored path now, and
the move passes utf7: true. A move refused before this is not replayed:
the next sync puts the mail back where the server kept it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 00:13:55 +02:00
Paul LütznerandClaude Opus 5.5 13a175a118 fix(mail): long addresses stay inside the details panel, and replies reach GitHub
A GitHub notification's details panel ran its Reply-To out past the
panel's edge. The value column was a bare 1fr, which never goes narrower
than its longest unbreakable word, so the reply address widened the
whole column, every row with it, to 831px inside a 446px panel, and
break-words never got to wrap anything.

  _message_details    minmax(0,1fr), and min-w-0 on the address text,
                      which is a flex item with the same floor; the
                      comma between two addresses rides with the first,
                      so it no longer wraps onto a line of its own
  message_details_    the panel moves left, and narrows only if it must,
  controller          to stay inside the reading pane: beside the docked
                      calendar it ran 60px past it and was clipped
  AddressHelper       isValidEmail() holds a local part past 64
                      characters to the dot-atom instead of refusing it

That last one is a bug found on the same message. filter_var() enforces
RFC 5321's 64-character local part, and GitHub's reply addresses run to
about ninety. Refused, the reply fell back to the From address,
notifications@github.com, which accepts nothing; with the parser fixed
alone, the address still could not become the contact the To field
needs, and the reply opened with nobody to send to. Every caller asks
the same question, so the one rule changed. Reply-To in the panel is
now parsed like the rows around it, with a copy button.

The new E2E case fails on the old front end (60px past the pane, 382px
past the panel) and passes now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 00:13:55 +02:00
Paul LütznerandClaude Opus 5.5 5effde6498 Release v0.2.48
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:21:23 +02:00
Paul LütznerandClaude Opus 5.5 fd5dc915a6 fix(sync): one mail the database refuses no longer stops a folder
A fresh install with one GMX mailbox stopped importing two folders, each
on a batch it fetched again on every sync:

  invalid byte sequence for encoding "UTF8": 0xe4 0x72 0x75   ($2)
  invalid byte sequence for encoding "UTF8": 0x80             ($30)

$2 of a MessagePart INSERT is its filename: "Erklärung.pdf" in raw
latin-1. The IMAP path stored webklex's name as it came, where
GmailMessageBuilder already decodes it. $30 of a Message INSERT is
body_html: a part labelled UTF-8 carrying a windows-1252 "€". webklex
converts nothing when the label already names the target, UTF-8 and
us-ascii both, and Utf8AwareMessageDecoder only corrected the opposite
lie.

  MessageSyncer            decodes the attachment name before storing
                           it, and writes a refused batch again one
                           message per transaction, so only the refused
                           one is held back by holdForRetry()
  Utf8AwareMessageDecoder  puts whatever the library returns past
                           ensureUtf8()
  CharsetHelper            ensureUtf8() repairs byte by byte and drops
                           NUL

Each stopped a folder because the batch was refused at its flush, not
while building, and holdForRetry() had only ever seen build failures.
The mark stayed below the batch, and the next sync fetched the same
fifty.

ensureUtf8() read a whole invalid string as cp1252, so a UTF-8 body
with one stray "€" had every umlaut turned into "ü". Now only the
invalid bytes are read that way, at about 130ms for a 6 MB body. NUL is
valid UTF-8 that Postgres refuses in text, which made "unconditionally
storable" untrue.

The uid cast in storeMessages() retires a baseline entry: webklex
declares getUid() only in a docblock that PHPStan cannot read as an int.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 23:21:13 +02:00
Paul LütznerandClaude Opus 5.5 048494eaeb Release v0.2.47
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 22:40:43 +02:00
Paul LütznerandClaude Opus 5.5 5d3cae5779 fix(sync): two mails about one parcel in a batch no longer stop the sync
A fresh install with one GMX mailbox logged, during its first sync:

  Failed to build message; it is asked for again next sync
  "error": "The EntityManager is closed."

The first sync reads the INBOX fifty messages at a time, and
ExtractInsightsStep harvests the whole batch before one flush. Two mails
carrying the same fact (the shipping and the delivery notice for one
parcel) each looked for the card in the table, found nothing, and each
inserted. The flush broke uniq_mail_insight_account_dedupe, Doctrine
closed the entity manager, PostIngestPipeline logged the step as failed
and went on, and every persist of the next batch threw
EntityManagerClosed, blamed on its own message. The folder's sync then
died on the flush after it.

  MailInsightRepository  findOneByDedupe() also finds a row persisted
                         earlier in the same unit of work, so the
                         second mail refreshes the first one's card.
  MessageSyncer          reopens a closed manager between batches;
                         clear() alone left it closed.
  ImapAccountSyncer      reopens it after a failed folder, and finds
                         each folder again by id before syncing it.

That last lookup fixes a bug found on the way. MessageSyncer clears the
entity manager between batches, so in an account sync every folder
after the first one with new mail was handed over detached, and what
the sweep wrote onto it (sweptAt, the folder's UIDVALIDITY) was flushed
into nothing. Without a recorded UIDVALIDITY, a folder rebuilt on the
server is not recognised as rebuilt.

No mail was lost: a failed batch was never committed, the mark did not
move past it, and the attempt counter was never saved either. The cards
of the failed batches were lost; app:backfill insights brings them back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 22:39:56 +02:00
Paul LütznerandClaude Opus 5.5 6edc6c324c fix(deploy): the cache table exists before any worker reads it
A fresh install logged a warning about once a second from the worker,
until an administrator happened to press "Restart workers":

  Failed to fetch key "workers.restart_requested_timestamp": ...
  relation "cache_items" does not exist

The restart signal and the notice throttle live in cache pools on
Symfony's DBAL adapter, which creates cache_items on its first write.
Every Messenger consumer reads the signal on every loop, long before
anything writes it, and each failed read is logged. The migrations
leave the table out on purpose, because the adapter owns its DDL.

app:db:migrate now has each DBAL adapter create its table when it is
missing, after the migrations and inside the advisory lock, so php and
the worker cannot race each other to it. The adapter still owns the
DDL: it names its table through configureSchema() and creates it with
createTable(). Best effort, like the statement statistics: a failure is
a warning, not a container that will not start. Existing installs get
the table on their next boot.

The adapters are collected by class in a compiler pass rather than
injected by pool id. In dev and test the profiler wraps every pool in a
TraceableAdapter, which has no createTable(). It is the lookup
DoctrineBundle uses for its schema listener, and it covers any DBAL
pool added later.

Checked on two fresh compose.yaml + compose.demo.yaml stacks: 95
warnings in 75 seconds without this, none with it, and Restart workers
still reaches every consumer through the table created at boot.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 14:57:14 +02:00
Paul LütznerandClaude Opus 5.5 9e3c890b29 Release v0.2.44
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 13:30:45 +02:00
Paul LütznerandClaude Opus 5.5 3a965b47dc docs: the README's inbox shows the calendar beside the mail
The first picture now shows what a desktop opens on, and what the
README's first line promises. The fixture user stays on the mail alone
for the regression suite, so the capture asks for split and restores
the mail afterwards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 13:30:45 +02:00
Paul LütznerandClaude Opus 5.5 a11ce99007 fix(calendar): the docked pane's date fits beside its buttons
In the pane's agenda and day views the title had about seventy pixels
and read "Sep 24, 20…", now on the surface every desktop opens on. The
pane drops the year while it is this one and keeps it for any other.
Week and month widen the pane to fit them, and the full page has room.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 13:30:45 +02:00
Paul LütznerandClaude Opus 5.5 d9eb6f2863 feat(calendar): the pane starts open beside the mail on a desktop
Anyone who has not moved the switch now gets Split: mail and calendar
side by side, which is what plMail is for and what a demo visitor, who
never chooses anything, should see. A pane shut under the old boolean
setting stays shut; only a missing key means nobody chose.

Phones keep opening on the mail. ui--split already demoted every
remembered position there on connect, but the first paint came before
it: the server rendered the stored mode, the rule for split below lg
hid the mail, and the pane itself is hidden below lg, so the frame was
blank until JavaScript put the mail back. With Split the default that
would have been every phone on every page load. The shell now carries
data-calendar-restored until connect, and below lg a restored position
does not step the mail aside.

The E2E users stay on the mail alone (app:test:seed-user, as the mail
seeder already did), since the suite was written against a list with the
whole row. The list query budget closes the pane for the same reason:
an open pane embeds its body, a fixed few queries that are not the
list's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 13:30:45 +02:00
Paul LütznerandClaude Opus 5.5 9ec01b4a93 fix(push): only inbox changes count against Gmail push, and re-registering clears the card
The watch asks Gmail to announce changes to INBOX alone, and Gmail
pushes only for changes relating to it. The health check read the
unfiltered history feed and took any record in it as a change push
should have announced: the copy of a sent message, an archived thread,
mail a filter kept out of the inbox, spam. So a working push was
reported as broken whenever one of those came more than fifteen minutes
after the last inbox mail. The next inbox push cleared the card and the
next reply raised it again.

gmailHistoryAdvancedAt is now written only for a record whose message is
in the inbox after the change, the label taken from one constant on
GmailWatchService so the watch and the evidence cannot drift. Mail moved
out of the inbox is left out on purpose: whether Gmail announces that is
not knowable here, and leaving it out only means a dead push is noticed
at the next inbox mail.

A change recorded before the current watch was registered no longer
counts against it. "Turn instant delivery back on" re-registers, and the
card used to outlive that repair until the next push arrived.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 13:30:45 +02:00
Paul LütznerandClaude Opus 5.5 c732dcfcfa Release v0.2.43
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:23:27 +02:00
Paul LütznerandClaude Opus 5.5 3cbf346b9f docs: every screenshot is retaken with the bare top-bar logo
The two icon captures now show the horn in the top bar without its
tile, and the rest show the pl mark at its new size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:23:27 +02:00
Paul LütznerandClaude Opus 5.5 a3336fbf6a fix(branding): the tab icon draws what its URL names; the top bar's logo stands bare
The tab icon was drawn from the session, and the appearance pane points
the tab at a new choice the moment it is clicked, before the save lands.
So it drew the choice from before the click, and the browser kept that
drawing under the new URL for a week: every tab icon was the previous
pick, except Classic, whose URL the page had already loaded correctly.
/branding/favicon/{motif}/{paint}.svg now draws what its path says, like
the icon tiles, and is public and cached the same way.

The top bar's logo is the glyph without its tile, from
/branding/logo/{motif}/{paint}.svg: the tile is the phone's app icon,
and in a web page's corner it read as one. The @-horn keeps its tile,
because its cream @ is nothing without the field behind it. Every
top-bar logo, the pl mark included, sits in one 36px box, up from 32.

On a dark bar only the parts that would vanish change: the pl mark and
the horn read the colourway's dark-chrome strokes, and the mailbox post
turns grey. The page renders a light and a dark image and the stylesheet
shows one, so a theme that follows the system is right as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:23:27 +02:00
Paul LütznerandClaude Opus 5.5 dea6932901 Release v0.2.42
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 23:14:13 +02:00
Paul LütznerandClaude Opus 5.5 00e6cd90fb docs: the README shows the icon picker, and every screenshot is retaken
Two new captures — an icon chosen in its own colours, and the same icon open
on the colourway grid — with the topbar in both frames, since where the icon
ends up is the point of choosing it. The rest are retaken against the demo
mailbox as usual before a tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 23:14:13 +02:00
Paul LütznerandClaude Opus 5.5 7e1c40cb0d refactor(branding): one paint vocabulary, and an edge for the topbar tile
LogoMotif::paintWires() is now what both the icon route's requirement and
the export walk, so `original` plus the colourways is spelled once. The
export's output is unchanged, byte for byte.

The topbar's tile gets the settings tiles' hairline: a pale tile on pale
chrome had no edge and read as a glyph that had lost its square.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:57:45 +02:00
Paul LütznerandClaude Opus 5.5 2f25d00544 docs: the logo's two steps, and logoMotif/logoPaint for clients; hashes
The appearance page describes choosing an icon and then its colour, with
Original as the motif's default, what a colourway leaves alone, and two new
traps (Follow the theme on a classic theme; picking a motif resets to
Original). The client guide documents the two read-only JMAP properties,
logoMotifs, and the paint table behind app:branding:export-paints. German
prose follows both; translated-from hashes recomputed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:53:47 +02:00
Paul LütznerandClaude Opus 5.5 b99d6ebe43 feat(jmap): publish the logo's icon and paint, read-only
Appearance/get gains logoMotif (effectiveLogoMotif()) and logoPaint
(`original` or a colourway; always the colourway logoStyle reports for the
pl mark). logoStyle keeps its meaning for older clients. Appearance/set
treats both like logoStyle — a change is refused, an echo accepted and
dropped — through one SERVER_SET list. The Session's appearance capability
publishes logoMotifs beside logoStyles, and neither value joins the compact
hint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:53:41 +02:00
Paul LütznerandClaude Opus 5.5 872cdbd73d feat(settings): pick the logo in two steps, icon then colour
Settings → Appearance → Logo shows the ten icons first, each in its own
design, then the colour: Original (offered only off the pl mark, and what
picking a motif selects), Follow the theme, or Choose independently from a
grid showing the chosen icon in all thirty-two colourways. Picking a motif
swaps the grid's pictures in place; every click repaints the topbar and the
tab icon the way effectiveLogoPaint() resolves them, and saves through the
existing debounced save(). Tiles are real buttons with aria-pressed and the
theme tiles' ring. EN, DE and pirate labels; the long German compounds carry
soft hyphens so they break inside a narrow tile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:53:34 +02:00
Paul LütznerandClaude Opus 5.5 21216f3159 feat(branding): serve every icon as a tile, in the topbar and the tab
GET /branding/icon/{motif}/{paint}.svg draws one icon on its rounded tile,
in Android's adaptive-icon geometry; both requirements are read off the
enums, so anything else 404s at the router. The URL carries a fingerprint
of the drawing's sources (LogoIcons), and at that version the response is
public and immutable for a year; without it, an hour and an ETag.

The route has its own `security: false` firewall rather than a
PUBLIC_ACCESS rule: under `main` the appearance cookie listener reads the
session, and Symfony turns the response private with max-age=0.

The favicon serves the tile for every motif and the bare mark for pl; its
link is versioned by the choice (v=<motif>.<paint>) and the drawing (d).
The topbar renders the inline mark and the tile image, one of them hidden,
so the appearance pane can switch between them live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:53:28 +02:00
Paul LütznerandClaude Opus 5.5 24d7bdfc07 feat(appearance): the logo is an icon, then its paint
Appearance gains logoMotif (default pl) and logoOriginal (default false),
with Version20260923223000 adding both columns so every account keeps the
mark it has. effectiveLogoPaint() says what the chosen icon wears: for the
pl mark it is effectiveLogoStyle(), unchanged; a motif wears its own design
while logoOriginal holds, then the theme's namesake colourway while linked
and the chosen one when not. A linked motif on a classic theme falls back to
its original rather than to Berry. Export, import and the settings save
carry both fields; an unknown motif keeps the current one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:53:20 +02:00
Paul LütznerandClaude Opus 5.5 562c385978 feat(appearance): nine logo motifs and the recipes that paint them
LogoMotif names the ten icons (the pl mark and nine motifs) and holds each
one's parts, its original design, and the recipe that dresses it in any of
LogoStyle's thirty-two colourways. A recipe repaints only the livery: the
love letter's heart, par avion's stripes, the snail and the mailbox flag
keep their colours in every colourway. Colourway reads a LogoStyle as roles
(one, two, ground, ramp) once, for all nine recipes; ColourHelper is the
board's arithmetic, half-up rounding and accumulating float steps included.

app:branding:export-paints prints the whole table for the Android build.
Its output is byte-identical to the approved fixture, and LogoMotifTest
holds the decoded two equal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:53:14 +02:00
Paul LütznerandClaude Opus 5.5 b4305576b6 Release v0.2.41
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:11:35 +02:00
Paul LütznerandClaude Opus 5.5 68e4323721 test(mail): the head comparison masks the per-request CSP nonce too
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:43:48 +02:00
Paul LütznerandClaude Opus 5.5 040986e344 docs: German client guide carries both of today's changes; hash
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:38:08 +02:00
Paul LütznerandClaude Opus 5.5 1aadc53218 fix(security): prune finished jobs and expired trusted devices nightly
Both tables grew forever: TrustedDeviceRepository::pruneExpired() and
BackgroundJobRepository::findFinishedBefore() had no caller. The nightly
app:monitoring:prune now deletes expired trusted devices and background
jobs finished more than 30 days ago (--job-days). The job lookup became a
bulk DELETE, since hydrating would scale with the history and trip over any
row whose kind the enum no longer lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:37:29 +02:00
Paul LütznerandClaude Opus 5.5 655e09e4a4 chore: drop the baseline entry for the deleted ArrayShape attribute
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:37:29 +02:00
Paul LütznerandClaude Opus 5.5 ffbed453cd test(security): pin the JMAP firewall's refusals
A revoked app password, an app password under another address over Basic,
and an unsigned JWT claiming a user are all refused on /jmap/session; an
active app password is accepted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:37:29 +02:00
Paul LütznerandClaude Opus 5.5 710a9a9b27 refactor(security): delete the Guard-era login authenticator methods
checkCredentials, getCredentials, getPassword and getUser belong to the
Guard API nothing has called since the authenticator system replaced it;
with them go their three unused dependencies and the unused error-message
block and TODO in SecurityController::login(). The JWT login on /jmap is
kept: setup mints its keypair, config backups carry it and the JMAP README
documents it, so it gets a guard test instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:37:29 +02:00
Paul LütznerandClaude Opus 5.5 2361c14e41 fix(security): validate mail hosts and stop echoing server replies from the connection test
The SMTP host went into the mailer DSN unencoded, so a host such as
`mail.example.com?verify_peer=0` added transport options; the credentials
used urlencode against a rawurldecode parser. Hosts are now checked to be a
hostname or IP (form constraint, connection test, DSN and IMAP factories),
credentials are rawurlencoded and IPv6 is bracketed. The connection test
returned the whole exception chain, i.e. the remote server's banner, to
anyone who could type a host; it now answers with a translated category
(auth, unreachable, TLS, timeout, invalid host) and logs the redacted chain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:37:29 +02:00
Paul LütznerandClaude Opus 5.5 d3eb835a47 fix(security): narrow the relative-URL sanitiser's attribute list type
phpstan flagged the nullable return that never returns null.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:37:16 +02:00
Paul LütznerandClaude Opus 5.5 4611242536 fix(security): keep only the inline-attachment route among relative URLs in mail
The sanitiser allowed every relative link and media URL, so a sender could
make the print view (which puts the body in an app page) request any path in
plMail with the reader's session. A custom attribute sanitiser now keeps a
relative URL only when it is /mail/attachment/{id}, the one resolveCids()
writes, in src/href and in inline-style url(). Applies to new ingests; the
bodies stored before this keep what they had until re-sanitised.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:37:16 +02:00
Paul LütznerandClaude Opus 5.5 f7f591ad3f fix(security): refuse control characters in the calendar returnTo path
`/\t/evil.test` passed the `//` prefix check, and browsers strip tabs and
newlines before parsing a URL, so it redirected to another host. The check
is now one rule: a slash, not followed by a slash or backslash, and no
control character, whitespace or backslash anywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:37:16 +02:00
Paul LütznerandClaude Opus 5.5 047db65450 refactor(security): move hand-rolled CSRF checks onto ChecksCsrf
Forty copies of the isCsrfTokenValid/createAccessDeniedException pair across
24 controllers now call assertCsrf() with the same token ids; each still
answers 403. The account connection test keeps its own check because it has
always answered 400. Compose controllers and CalendarController are left
to the agents working there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:37:16 +02:00
Paul LütznerandClaude Opus 5.5 0c17074215 fix(security): require a CSRF token on the appearance and list-marker POSTs
The appearance save, background upload, theme import and reset, and the
new-mail marker endpoint changed state on any POST. They now check the
layout's `ajax` token via ChecksCsrf; the appearance controller sends it as
X-CSRF-Token (the marker controller already did). Tests and E2E helpers that
posted without it send it now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:37:15 +02:00
Paul LütznerandClaude Opus 5.5 ebeaf8cffa fix(security): add a per-username login limit
Symfony's default login throttle counts per username+IP and per IP, so a
guesser on many addresses got five tries per address at the same account,
while the comment in security.yaml claimed it was per username. A custom
LoginRateLimiter keeps both and adds a username-only limit (20 per 15
minutes, set above what a mistyping person reaches since it can also be
spent by someone else). Comment and handbook corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:37:15 +02:00
Paul LütznerandClaude Opus 5.5 1065ae2aed fix(security): require a CSRF token to log out
A plain GET /logout signed the user out, so any page could do it with an
image tag. The firewall now checks the stateless `logout` token, and both
sign-out controls (user menu, 2FA prompt) are POST forms carrying it. The
E2E helpers that navigated to /logout submit the page's form instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:37:15 +02:00
Paul LütznerandClaude Opus 5.5 3c0fb20e16 fix(security): drop the dead CSRF double-submit script, pin the origin check
The double-submit script was lazy and nothing rendered its controller name,
so it never ran; stateless tokens were enforced by the same-origin check all
along. Switching it on would make the token manager demand double-submit for
the rest of the session, which breaks the paths that post without a submit
event (compose autosave, ui--auto-submit). Removed, with the reasoning in
csrf.yaml and a test that a cross-site POST with the page's token is refused.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:37:15 +02:00
Paul LütznerandClaude Opus 5.5 1285e62b7c fix(security): key the 2FA code throttle on the user, not the session
The throttle counted per session id, and a fresh password login is a fresh
session, so a stolen password bought unlimited guesses at the code. It now
listens to scheb's ATTEMPT event and keys on the pending token's user
identifier; the reset on success uses the same key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:36:59 +02:00
Paul LütznerandClaude Opus 5.5 75750e3ebc fix(security): publish every Mercure update as private
All notifiers published public updates, which the hub delivers to any
subscriber that names the topic, so the per-user topic grant in the
subscriber cookie protected nothing: a signed-in user could read another
user's mail/user/<id> stream. Updates now go through UserUpdate::create(),
which always sets private; the cookie already grants exactly that topic.
Corrects CODE-REVIEW.md S-06, which claimed the isolation held.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:36:59 +02:00
Paul LütznerandClaude Opus 5.5 b9246c8b47 fix(gmail): a message built for a Gmailify sibling records its carrier
enrichExisting() recorded which Gmail account carries a row when an
existing IMAP row was recognised, but build() did not when it created
one for a sibling. LabelChangePropagator asks the row's own account,
which is the IMAP one, so archive, star and label changes on those rows
never reached Gmail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:31:08 +02:00
Paul LütznerandClaude Opus 5.5 961611a587 fix(i18n): two form errors rendered as raw keys; drop an unused key
The AI base URL and avatar type messages are constraint messages, which
Symfony translates in the validators domain; they lived in messages, so
a failed check showed the key. calendar.soon.now went unused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:29:41 +02:00
Paul LütznerandClaude Opus 5.5 52aa28c9de fix(imap): open folders by their path, not their leaf name
Mailbox::$name is the leaf ("Invoices"), and webklex's getFolder() looks
a delimiter-less name up by leaf and takes the first match. With
"Archive/Invoices" and "Projects/Invoices" on one account, both rows
opened the same server folder: one synced twice, one never, and flags or
moves meant for one landed in the other. Sync, flag changes, the Sent
append and the UID presence check now go through ImapFolderLocator,
which matches the stored path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:28:50 +02:00
Paul LütznerandClaude Opus 5.5 7d69af0871 fix(mail): bulk snooze's fallback wake time is a valid date
'in 1 day' is not a date PHP parses, so the fallback for an unreadable
wake time threw instead of falling back. The job view's type also gains
the optional until key the snooze job stores.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:26:17 +02:00
Paul LütznerandClaude Opus 5.5 41bb3f8d4f test(e2e): un-fixme the single-target Label-as test
The blocker is gone: _thread_content renders the label menu with the
thread as its target. The test opens a conversation, attaches the seeded
label from the reading pane's menu and asserts the POST, the chip on the
list row and the tick. Not run here (no Playwright on this box).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:25:12 +02:00
Paul LütznerandClaude Opus 5.5 bcd332edf1 chore(dead-code): one shared toast turbo-stream partial
Twenty-one stream responses each spelled out the same append-to-
toast-region wrapper around _toast.html.twig. They now include
_partials/_toast.stream.html.twig with the same message and type, so the
markup is unchanged. Compose streams (owned elsewhere) and
_send_outcome (passes extra hide_after) are left as they are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:25:12 +02:00
Paul LütznerandClaude Opus 5.5 c0b969ebb6 chore(dead-code): one ownedAlias() for the three alias settings controllers
AliasController, SignatureController and ComposeDefaultsController each
carried the same lookup; it is now the FindsOwnedAlias trait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:25:12 +02:00
Paul LütznerandClaude Opus 5.5 5ccfd1d519 chore(dead-code): #[CurrentUser] instead of three private currentUser() helpers
HappeningSoonController, InviteController and InsightPaneController each
carried the same getUser()-instanceof-User helper, used only inside
actions. The attribute's resolver does the same narrowing and throws
AccessDenied the same way. Controllers whose private stream/render
helpers also need the user keep theirs for now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:25:12 +02:00
Paul LütznerandClaude Opus 5.5 12b4554d42 chore(dead-code): correct stale comments
eraseCredentials() carried recipe comment-out code for a plainPassword
that no longer exists; ChangeSet said the /changes methods had yet to
land; Provider::transKey() documented a whole key but returns a segment;
the sync button was still marked TEMPORARY in the topbar and its
controller although SyncController made it permanent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:25:12 +02:00
Paul LütznerandClaude Opus 5.5 7887b1bc37 chore(dead-code): drop the untouched notifier recipe config
Nothing in src uses the Notifier component; the file was the recipe's
sample (admin@example.com as admin recipient, email-only channel policy)
and configured nothing plMail relies on. The package stays in
composer.json: removing it means a lock update, which is better done on
its own than inside a parallel merge. The container compiles and lints
in test and prod without the file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:25:12 +02:00
Paul LütznerandClaude Opus 5.5 e767c2ce3b chore(dead-code): remove the UX Turbo broadcast samples and an unused toast
No entity carries #[Broadcast], so the five broadcast/*.stream templates
(recipe samples) were never rendered; the commented attribute on Account
and the unused import on MessagePart go with them. _toasts/generic had no
include anywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:25:12 +02:00
Paul LütznerandClaude Opus 5.5 8944d4fd68 chore(dead-code): delete the unused MailboxRole and ConnectionEncryption enums
MailboxRole was replaced by LabelRole and has no references left.
ConnectionEncryption was never adopted (the code compares raw 'ssl' /
'tls' strings); deleting it rather than adopting it, which would touch
the SMTP/connection-test code owned elsewhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:25:12 +02:00
Paul LütznerandClaude Opus 5.5 87bc044557 chore(dead-code): drop public methods nothing calls
Grep across src, templates, config, tests and assets (including the
getter/property forms Twig and forms resolve) found no caller for:
Account add/removeMailbox, add/removeMessageThread, isGmailWatchActive;
MailboxRepository find{Trash,Archive,Draft}MailboxForAccount,
getIdsOfActiveInboxMailboxesForUser; MessageThreadRepository
countForLabels; MessageRepository existsWithFromAddressInThread,
iterateForRecategorization; LabelRepository findVisibleTreeForUser;
ClientErrorRepository findOneByFingerprint; ImapFolderProvisioner
ensurePath; MailPresetProvider findByEmail; UserEntityModel plainPassword
(set/get and the property: every form field is mapped: false);
OnboardingStep requiresAdmin; MailProvider usesImap; ShareWindow
usesDates; ShareDetail jsCalendarProperty; HealthReport worstSeverity;
PushDeliveryOutcome reachedTheDevice; PushTransport addressedByUrl;
CategorySource helpKey; AiPrompts customisedCount; ResetReport
skippedTables; TwoFactorEnrolment qrContent; MicrosoftOAuthErrorTranslator
isAdminConsentRequired; IntegrationsGlobal forDownload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:25:12 +02:00
Paul LütznerandClaude Opus 5.5 4b885ec4b7 fix(snooze): cap the due-snoozed batch by thread, not by joined row
findDueSnoozed() put setMaxResults() on a query fetch-joining the messages,
so the limit counted message rows and the last thread in a batch could
arrive with part of its conversation missing. Select the capped ids first,
then load those threads whole. Covered by WakeSnoozedCommandTest (fails
on the old query).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:25:12 +02:00
Paul LütznerandClaude Opus 5.5 bbb3403b46 perf(rules): reset FilterDescriber's name caches between requests
The label and integration name caches lived as long as the worker, so a
renamed label kept its old name in rule sentences. Implements
ResetInterface.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:25:12 +02:00