Commit Graph
1053 Commits
Author SHA1 Message Date
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
Paul LütznerandClaude Opus 5.5 a39675bc59 perf(thread-view): batch the per-message lookups of an open conversation
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>
2026-09-23 19:25:12 +02:00
Paul LütznerandClaude Opus 5.5 cf7b971d0d perf(db): index message_thread for badges, list order and snooze sweep
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>
2026-09-23 19:25:12 +02:00
Paul LütznerandClaude Opus 5.5 77ed36117f perf(rules): clear the EntityManager between apply-rule batches
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>
2026-09-23 19:25:12 +02:00
Paul LütznerandClaude Opus 5.5 67935f0043 perf(mail-list): rows read message fields, not whole messages
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>
2026-09-23 19:25:12 +02:00
Paul LütznerandClaude Opus 5.5 dd6bd5a62b fix(ui): drop the compose window's undeclared formErrors target
compose--compose never declared or read it, so the attribute promised a
hook that did not exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:18:25 +02:00
Paul LütznerandClaude Opus 5.5 17001e105f fix(ui): pirate English catches up, and dead keys go
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>
2026-09-23 19:18:25 +02:00
Paul LütznerandClaude Opus 5.5 6494c7ea47 fix(ui): translate the remaining hardcoded English
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>
2026-09-23 19:18:25 +02:00
Paul LütznerandClaude Opus 5.5 35a712b514 fix(ui): Show original says Copied, in the reader's language
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>
2026-09-23 19:18:25 +02:00
Paul LütznerandClaude Opus 5.5 7b9a33515f fix(ui): the label menu escapes the conversation pane and cleans up
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>
2026-09-23 19:18:25 +02:00
Paul LütznerandClaude Opus 5.5 ffb6af496f fix(ui): the message expand toggle reports its state
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>
2026-09-23 19:18:25 +02:00
Paul LütznerandClaude Opus 5.5 766b5642fb fix(ui): the closed mobile drawer leaves the tab order
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>
2026-09-23 19:18:25 +02:00
Paul LütznerandClaude Opus 5.5 3660977754 fix(ui): stale responses no longer overwrite newer ones
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>
2026-09-23 19:18:25 +02:00
Paul LütznerandClaude Opus 5.5 f6813dadb8 fix(ui): the modal's Escape listener no longer leaks and swallows Escape
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>
2026-09-23 19:18:25 +02:00
Paul LütznerandClaude Opus 5.5 d647561d1c fix(ui): bulk snooze from the list toolbar works
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>
2026-09-23 19:18:25 +02:00
Paul LütznerandClaude Opus 5.5 889130b447 fix(ui): inline scripts the production CSP refused now run
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>
2026-09-23 19:18:25 +02:00
Paul LütznerandClaude Opus 5.5 4570fa647d test(calendar): satisfy phpstan in the export test and the editor save
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:16:22 +02:00
Paul LütznerandClaude Opus 5.5 5ec009251e fix(calendar): the CalDAV server stores a PUT whose name is not its UID
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>
2026-09-23 19:16:22 +02:00
Paul LütznerandClaude Opus 5.5 b8149e4eae fix(calendar): exports define their time zones and date all-day exceptions
- 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>
2026-09-23 19:16:22 +02:00
Paul LütznerandClaude Opus 5.5 d5c408fa61 fix(calendar): an invitation about one occurrence no longer rewrites the whole series
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>
2026-09-23 19:16:22 +02:00
Paul LütznerandClaude Opus 5.5 35f9159242 fix(calendar): edits keep a series' rule, overrides and unknown properties; remote removals arrive
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>
2026-09-23 19:16:22 +02:00
Paul LütznerandClaude Opus 5.5 7b3a52d0f9 fix(calendar): long-lead reminders fire on busy installs
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>
2026-09-23 19:16:22 +02:00