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>
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>
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>
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>
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>
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>
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>
`/\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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
'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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Each rendered message lazy-loaded its labels and asked the allowlist and
the contact table on its own: three queries per message. forThreadView()
fetch-joins the labels and loads parts for attachment messages in one
more statement; ThreadSenderFacts (reset per request) answers trusted
sender and correspondent for the whole thread with two IN() reads.
ThreadViewQueryBudgetTest: 2 messages 20 -> 16 queries, 10 messages
44 -> 16.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dozen sidebar/list aggregates per page and the per-minute snooze sweep
had only account_id to work with. Adds partial indexes for unread, starred
and snoozed threads and (account_id, last_message_at DESC, id DESC) for the
list ORDER BY. Applied against the test database in a rolled-back
transaction.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
walk() kept every message of every batch managed, so memory grew with the
mailbox and each flush re-checked all earlier batches. Clear after each
batch and re-find the rule, like RunBulkStatusHandler.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every list page fetch-joined every message of every thread on it, bodies,
headers and search vector included, to read a sender and a date off each
and a snippet off the last. ThreadRows now fetches only the row fields
of all messages plus the two body columns of each row's latest message;
the snippet is flattened once per row. Rendering is unchanged.
Measured by ThreadListQueryBudgetTest at 50 rows: Message entities
hydrated 171 -> 0; queries 24 -> 25 (inbox visit), 13 -> 14 (frame).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
207 keys existed in messages.en.yaml and not in messages.en_PI.yaml —
the config backup, push, reset and restarting admin screens, the
session-expired and confirm dialogs, notification devices and more —
so en_PI users read them in plain English. All translated, placeholders
kept, inserted where English has them. A parity test now fails when a
new English key is missing from de or en_PI.
The template asking for account.oauth.wip is dropped rather than the key
added: its text said OAuth was in progress, and the key was removed on
purpose when Google and Microsoft sign-in shipped.
26 keys nothing references any more are removed from all three
catalogues, each re-checked for dynamic lookups first. calendar.soon.now
is kept: unused, but added with the clock feature and possibly wanted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The integration picker's status line, the connection test's request
failure, the layout's default "Welcome!" title and the demo pages'
operator warnings were English in every locale. The picker and the
connection test get their words as Stimulus values from Twig; the demo
warnings keep the variable names as identifiers beside a translated
sentence. Also adds demo.privacy.processing_title, which the privacy
page used and no locale had.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The copy handler read event.currentTarget after an await, when it is
already null, so it copied and then threw instead of confirming. The
button is held from the start and a failure says so. The summary table's
Message ID / Created at / From / To / Subject headers were English in
every locale.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Inside a conversation the panel was painted under the navbar: the pane's
backdrop-filter traps it whatever its z-index. It now opens in the top
layer as popover="manual", positioned by hand, the way the account menu
does; manual because auto's light dismiss is what broke the options the
first time. disconnect() runs the whole close, so a menu re-rendered
while open no longer leaves scroll/resize listeners on window, and the
check target is declared. The skipped spec is a test again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The chevron's rotation was the only sign whether a message was open; the
button said the same thing either way. It now carries aria-expanded and
a translated Expand/Collapse label that follows the state.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Translated off-screen, the drawer stayed tabbable and readable, so a
keyboard or screen-reader user on a phone went through the whole sidebar
invisibly. It is inert while closed, opening moves focus into it, closing
returns focus to the burger, and the burger reports aria-expanded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The queue browser dropped a filter change while a page loaded, so the
input and the list disagreed; it now aborts the request it replaces. The
rule preview draws only its newest answer. Search's contact lookup
returned [] when superseded, which wiped the newer suggestions; it now
returns null and the caller bails. The account order posts one write at
a time with only the latest order, and puts the rows back when a write
fails instead of leaving the page contradicting its own toast.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
open() added a keydown listener on the opener's instance and close()
removed the closer's, usually #modal-backdrop's, so every dialog left one
behind that called preventDefault on every Escape afterwards. One
module-level listener now, removed by whichever instance closes, and
Escape is only claimed while there is a dialog to close.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The toolbar passed the ids as _bulkPost's first argument, so it posted to
/status/bulk/[object Array]; and the bulk route never allowed "snooze"
anyway. Both answered 404 behind a toast. The route now snoozes (or,
without a wake time, wakes) each conversation through ThreadSnoozeService,
and a whole-view snooze runs as a background job carrying its wake time,
with a Snooze and a Wake kind for the indicator.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>