Skip to content

Commit aea46ac

Browse files
committed
feat: detect servers that reject rescheduling a recurrence series with exceptions
testEditSingleRecurrence broke on OX App Suite with a 409 Conflict at its final step: save(all_recurrences=True) after changing dtstart/dtend. This re-anchors the whole series (moves the master VEVENT DTSTART) while two detached exceptions (RECURRENCE-ID) are attached, shifting their RECURRENCE-IDs to match. Investigation showed this is a distinct, previously-uncaptured limitation: * It is NOT save-load.mutable.if-match-optional - OX returns 409 even with a matching If-Match etag. * It is NOT save-load.event.recurrences.exception - OX stores master+exception together fine. * Shifting the DTSTART of an exception-free recurring event works on OX; only the combination (reschedule + existing exceptions) is rejected. Add a new compatibility flag save-load.event.recurrences.exception.reschedule (default full), mark it unsupported for OX, and gate only the final reschedule block of the test on it - the earlier steps work on OX and keep their coverage. prompt: In ~/caldav, the test ...::TestForServerOx::testEditSingleRecurrence breaks with a 409 from the server. This seems like some kind of compatibility-problem. Please investigate and consider if it's needed to add a new feature and a new check to catch this, or perhaps one of the features already checked covers it and all we need to do is to add some guard to the test skipping it for servers like Ox. followup-prompt: (scope) Also add checker probe [in caldav-server-tester] Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> AI Prompts: claude-sonnet-4-6: E3. Making it "opt-in" doesn't sound like a fix, I think it's needed to know about relationships when doing a "top down" hierarchical task list. Please consider and come with suggestions claude-sonnet-4-6: Is 6.1 still relevant, or was it already removed during one of the deduplication sessions?
1 parent 1f0c6bc commit aea46ac

3 files changed

Lines changed: 31 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ This project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0
1414

1515
## [Unreleased]
1616

17+
### Added
18+
19+
* New compatibility flag `save-load.event.recurrences.exception.reschedule`: whether the server accepts re-anchoring a whole recurring event (moving the master `DTSTART`) while detached exceptions (`RECURRENCE-ID`) are attached. OX App Suite rejects this with `409 Conflict` even with a matching `If-Match` etag, although rescheduling an exception-free recurring event works there. `testEditSingleRecurrence` now gates its final `save(all_recurrences=True)` dtstart/dtend step on this flag.
20+
1721
### Fixed
1822

1923
* `jmap/client.py` and `jmap/async_client.py` `update_event()`: to honour RFC 8620 PatchObject merge semantics, the update null-injects every optional property absent from the new iCalendar so removed properties are actually cleared server-side. Some servers (observed with Stalwart) reject a property they do not support — e.g. `recurrenceRules`/`excludedRecurrenceRules` — as `invalidProperties` even when it is being set to `null`, which made every `update_event()` against such a server fail. Nulling an absent property is harmless cleanup, so the update now drops the server-rejected null-cleanup keys and retries (looping, since some servers report only one offending property per response) until the update succeeds. A rejection of a property the client actually assigned a value still surfaces as `JMAPMethodError`.

caldav/compatibility_hints.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ class FeatureSet:
213213
## information was simply discarded, and the current search behaviour would in
214214
## such a case be incorrect if the exception is simply discarded.
215215
"save-load.event.recurrences.exception": {"description": "When a VCALENDAR containing a master VEVENT (with RRULE) and exception VEVENT(s) (with RECURRENCE-ID) is stored, the server keeps them together as a single calendar object resource. When unsupported, the server splits exception VEVENTs into separate calendar objects, making client-side expansion unreliable (the master expands without knowing about its exceptions)."},
216+
"save-load.event.recurrences.exception.reschedule": {"description": "The server accepts a PUT that reschedules an entire recurring event - changing the master VEVENT's DTSTART (re-anchoring the whole series) while detached exception VEVENT(s) (with RECURRENCE-ID) are present and their RECURRENCE-IDs are shifted to line up with the new series. This is unsupported for Ox, the server rejects such a PUT with 409 Conflict even when a matching If-Match etag is supplied. Rescheduling a recurring event that has no exceptions still works. Exercised by save(all_recurrences=True) after changing dtstart/dtend.", "default": {"support": "full"}},
216217
"save-load.todo": {
217218
"description": "it's possible to save and load tasks to the calendar",
218219
"default": { "support": "full" }
@@ -1772,6 +1773,11 @@ def dotted_feature_set_list(self, compact=False):
17721773
'search.recurrences.includes-implicit.infinite-scope': {'support': 'unsupported'},
17731774
'search.recurrences.expanded.event': {'support': 'unsupported'},
17741775
'search.recurrences.expanded.todo': {'support': 'unsupported'},
1776+
## Rescheduling the whole series (changing the master DTSTART) is rejected with
1777+
## 409 Conflict once detached exceptions exist - even with a matching If-Match
1778+
## etag. Shifting the DTSTART of an exception-free recurring event still works.
1779+
## Confirmed by direct probe 2026-06-14.
1780+
'save-load.event.recurrences.exception.reschedule': {'support': 'unsupported'},
17751781
## OX ignores the time-range on VTODO queries and returns every task
17761782
'search.time-range.todo.strict': {'support': 'broken'},
17771783
## OX silently ignores the is-not-defined prop-filter and returns the whole

tests/test_caldav.py

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4401,23 +4401,27 @@ def summary_on(offset):
44014401
assert summary_on(3) == "daily testing"
44024402
assert summary_on(7) == "six months of daily testing"
44034403

4404-
## Last ... let's change the dtend and dtstart of the recurrence
4405-
recurrence = search(9)
4406-
recurrence.icalendar_component.pop("dtstart")
4407-
recurrence.icalendar_component.add("dtstart", day_start(9).replace(hour=8))
4408-
recurrence.icalendar_component.pop("dtend")
4409-
recurrence.icalendar_component.add("dtend", day_start(9).replace(hour=10))
4410-
recurrence.save(all_recurrences=True)
4411-
4412-
recurrence = search(8)
4413-
assert (
4414-
recurrence.icalendar_component.start.astimezone()
4415-
== day_start(8).replace(hour=8).astimezone()
4416-
)
4417-
assert (
4418-
recurrence.icalendar_component.end.astimezone()
4419-
== day_start(8).replace(hour=10).astimezone()
4420-
)
4404+
## Last ... let's change the dtend and dtstart of the recurrence.
4405+
## This reschedules the whole series (moves the master DTSTART) while the
4406+
## two exceptions above are still attached - some servers (e.g. OX) reject
4407+
## that re-anchoring with a 409 Conflict, so it is gated on its own flag.
4408+
if self.is_supported("save-load.event.recurrences.exception.reschedule"):
4409+
recurrence = search(9)
4410+
recurrence.icalendar_component.pop("dtstart")
4411+
recurrence.icalendar_component.add("dtstart", day_start(9).replace(hour=8))
4412+
recurrence.icalendar_component.pop("dtend")
4413+
recurrence.icalendar_component.add("dtend", day_start(9).replace(hour=10))
4414+
recurrence.save(all_recurrences=True)
4415+
4416+
recurrence = search(8)
4417+
assert (
4418+
recurrence.icalendar_component.start.astimezone()
4419+
== day_start(8).replace(hour=8).astimezone()
4420+
)
4421+
assert (
4422+
recurrence.icalendar_component.end.astimezone()
4423+
== day_start(8).replace(hour=10).astimezone()
4424+
)
44214425

44224426
def testOffsetURL(self):
44234427
"""

0 commit comments

Comments
 (0)