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>
The enforced script-src drops an unnonced inline script silently. The
booking form's hours, the booking page's visitor zone and the restart
page's way back become Stimulus controllers (they arrive through Turbo,
where no nonce can follow); the public layout's theme bootstrap gets the
nonce. The public layout and the error page also called importmap('app')
without the nonce and linked no stylesheets since app.js stopped
importing CSS, so they ran unstyled and without JavaScript. Layouts now
carry a csp-nonce meta for Turbo. A static test keeps it that way.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
putResource looked the event up by the href's name after importing it,
so a client that named a new resource anything but its UID got a 409 for
a write that had already happened. The document's UID is now read before
writing: a create under another name is stored and answered 201 with a
Location, and only a UID change or a UID clash is refused - before any
write. If-None-Match: * and If-Match on a missing resource answer 412.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Recurring all-day events wrote EXDATE and RECURRENCE-ID (and a moved
instance's times) as UTC date-times, which match no instance of a DATE
series; they are now VALUE=DATE, in CalDAV pushes and .ics downloads.
- Files named TZIDs without VTIMEZONE components, which RFC 5545
requires. VTimeZoneBuilder writes one from PHP's zone table for each
TZID used, covering the span the events do; a calendar download writes
one per zone at the end of the stream.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A VEVENT with RECURRENCE-ID shares the series' UID and was reconciled as
an update of the series: a CANCEL for one date cancelled every date, and
the extraction runner deduplicated a master and its exceptions down to
whichever came first. Instance claims now carry their recurrence id, are
kept apart by the runner, and are filed on the series as a recurrence
override - an exclusion for a cancellation, a start/duration/title patch
otherwise. Links queued earlier in the same message are reused so the
unique (event, message, extractor) constraint holds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three ways CalendarEventWriter rebuilt away what it should have kept:
- Saving or dragging a series replaced its rule with the dropdown's bare
frequency and dropped properties no field carries (freeBusyStatus, a
verbatim plmail:rrule), and pushed that to the remote. A choice matching
the stored frequency, or the new 'keep the current rule' option shown
for rules the dropdown cannot express, now keeps the stored rule, and
the stored object is passed as the base of the rebuild.
- Moving a series left its overrides keyed at the old start, so cancelled
and moved instances came back. Keys and patch starts now shift by the
same wall-clock delta; keys the rule no longer visits are dropped.
- Drivers only stated alerts/participants/overrides when non-empty, and
the writer kept stored values when a key was absent, so removals made
remotely never arrived. Drivers now state them as [] where the remote
object is authoritative, and an explicit [] clears the stored value.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The alert sweep read one page of 500 candidates ordered by start over a
month-long, instance-wide window and stopped, so an alert set days ahead
sat behind the next day's short reminders until it aged out of the
lookback. The candidates are now read with keyset paging to the end of the
window on every sweep.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>