Skip to content

Commit 4398935

Browse files
tobixenclaude
andcommitted
Add Stalwart CalDAV compatibility hints and fix expansion assertions
Stalwart has several non-RFC-conformant behaviours uncovered during integration testing: * VALUE=DATE (all-day) recurring events are not returned by time-range searches even though datetime recurring events are. Marked as search.recurrences.includes-implicit.event: fragile (broken for VALUE=DATE events). * Recurring VTODOs are returned in search results but without the RRULE, so client-side expansion cannot find specific occurrences. Marked as search.recurrences.includes-implicit.todo: fragile. * Server-side CALDAV:expand is broken for events with exceptions (returns 3 items instead of 2, because exceptions are stored as separate objects). Marked as search.recurrences.expanded.exception: unsupported. * Master+exception VEVENTs are stored as separate CalendarObjectResources rather than as a single multi-VEVENT object (RFC violation). New feature flag save-load.event.recurrences.exception tracks this; when unsupported, CalDAVSearcher now falls back to server-side CALDAV:expand (if available) so client-side expansion of the master alone does not yield duplicates. * VTODO date searches with no DTSTART on the task are skipped; open-ended VTODO date searches return no results. Covered by existing old-flags vtodo_datesearch_nodtstart_task_is_skipped and new no_search_openended. Test assertions in testRecurringDateWithExceptionSearch and testTodoDatesearch are now guarded by the appropriate feature flags so the tests pass (or skip gracefully) on Stalwart. Also fix a KeyError crash in setUp when a rate-limit features dict lacks 'interval' or 'count' keys (e.g. Stalwart's rate-limit config uses default_sleep/max_sleep, not interval/count). Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 1210694 commit 4398935

3 files changed

Lines changed: 57 additions & 9 deletions

File tree

caldav/compatibility_hints.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,16 @@ class FeatureSet:
130130
"save-load.event": {"description": "it's possible to save and load events to the calendar"},
131131
"save-load.event.recurrences": {"description": "it's possible to save and load recurring events to the calendar - events with an RRULE property set, including recurrence sets"},
132132
"save-load.event.recurrences.count": {"description": "The server will receive and store a recurring event with a count set in the RRULE", "default": {"support": "full"}},
133+
## This was Claude's suggestion and it works as of today, the
134+
## "unsupported" description matches the behaviour of the Stalwart server.
135+
## Stalwart apparently (in a breach with the RFC) stores the exception
136+
## information as a separate CalendarObjectResource.
137+
## Currently the search logic will do server-side expansion
138+
## if this flag is set to "unsupported", which is the correct behaviour for Stalwart.
139+
## The problem is that logically, this feature would also be "unsupported" if the exception
140+
## information was simply discarded, and the current search behaviour would in
141+
## such a case be incorrect if the exception is simply discarded.
142+
"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)."},
133143
"save-load.todo": {"description": "it's possible to save and load tasks to the calendar"},
134144
"save-load.todo.recurrences": {"description": "it's possible to save and load recurring tasks to the calendar"},
135145
"save-load.todo.recurrences.count": {"description": "The server will receive and store a recurring task with a count set in the RRULE", "default": {"support": "full"}},
@@ -1014,6 +1024,7 @@ def dotted_feature_set_list(self, compact=False):
10141024
'auto-connect.url': {'basepath': '/ucaldav/'},
10151025
'save-load.journal': {'support': 'ungraceful'},
10161026
'save-load.todo.recurrences.thisandfuture': {'support': 'ungraceful'},
1027+
'save-load.event.recurrences.exception': False,
10171028
## search.time-range.alarm: not checked by the server tester
10181029
'search.time-range.alarm': {'support': 'unsupported'},
10191030
## Huh? Non-deterministic behaviour of the checking script?
@@ -1365,8 +1376,27 @@ def dotted_feature_set_list(self, compact=False):
13651376
},
13661377
'create-calendar.auto': True,
13671378
'principal-search': {'support': 'ungraceful'},
1368-
'search.recurrences.expanded.exception': False,
13691379
'search.time-range.alarm': False,
1380+
## Stalwart supports implicit recurrence for datetime events but not for
1381+
## all-day (VALUE=DATE) recurring events in time-range searches.
1382+
'search.recurrences.includes-implicit.event': {'support': 'fragile', 'behaviour': 'broken for all-day (VALUE=DATE) events'},
1383+
## Stalwart returns the recurring todo in search results but doesn't return the
1384+
## RRULE intact, so client-side expansion can't expand it to specific occurrences.
1385+
'search.recurrences.includes-implicit.todo': {'support': 'fragile'},
1386+
## Stalwart doesn't handle exceptions properly in server-side CALDAV:expand:
1387+
## returns 3 items instead of 2 for a recurring event with one exception
1388+
## (the exception is stored as a separate object and returned twice).
1389+
'search.recurrences.expanded.exception': False,
1390+
## Stalwart doesn't store master+exception VEVENTs correctly as a single resource
1391+
## (returns 3 VEVENTs instead of 2 when the master+exception event is expanded).
1392+
## Since server-side expansion is also broken, both paths give wrong results.
1393+
'save-load.event.recurrences.exception': {'support': 'unsupported'},
1394+
'old_flags': [
1395+
## Stalwart does not return VTODO items without DTSTART in date searches
1396+
'vtodo_datesearch_nodtstart_task_is_skipped',
1397+
## Stalwart does not return results for open-ended date searches on VTODOs
1398+
'no_search_openended',
1399+
],
13701400
}
13711401

13721402
## Lots of transient problems with purelymail

caldav/search.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,17 @@ def _search_impl(
273273
if not self.expand and not server_expand:
274274
split_expanded = False
275275

276+
## If the server stores exception VEVENTs as separate calendar objects, client-side
277+
## expansion is unreliable (the master expands without knowing its exceptions, yielding
278+
## duplicate occurrences). Fall back to server-side expansion when it handles exceptions.
279+
if (
280+
self.expand
281+
and not server_expand
282+
and not calendar.client.features.is_supported("save-load.event.recurrences.exception")
283+
and calendar.client.features.is_supported("search.recurrences.expanded.exception")
284+
):
285+
server_expand = True
286+
276287
if self.expand or server_expand:
277288
if not self.start or not self.end:
278289
raise error.ReportError("can't expand without a date range")

tests/test_caldav.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -762,7 +762,7 @@ def setup_method(self):
762762

763763
foo = self.is_supported("rate-limit", dict)
764764
if foo.get("enable"):
765-
rate_delay = foo["interval"] / foo["count"]
765+
rate_delay = foo.get("interval", 0) / foo.get("count", 1)
766766
self.caldav.request = _delay_decorator(self.caldav.request, t=rate_delay)
767767
foo = self.is_supported("search-cache", dict)
768768
if foo.get("behaviour") == "delay":
@@ -2642,9 +2642,9 @@ def testTodoDatesearch(self):
26422642
todos2 = c.search(start=datetime(2025, 4, 14), todo=True, include_completed=True)
26432643
todos3 = c.search(start=datetime(2025, 4, 14), todo=True)
26442644

2645-
assert isinstance(todos1[0], Todo)
2646-
assert isinstance(todos2[0], Todo)
26472645
if not self.check_compatibility_flag("no_search_openended"):
2646+
assert isinstance(todos1[0], Todo)
2647+
assert isinstance(todos2[0], Todo)
26482648
assert isinstance(todos3[0], Todo)
26492649

26502650
## * t6 should be returned, as it's a yearly task spanning over 2025
@@ -3283,12 +3283,19 @@ def testRecurringDateWithExceptionSearch(self):
32833283
server_expand=True,
32843284
)
32853285

3286-
assert len(r) == 2
3287-
if self.is_supported("search.recurrences.expanded.event"):
3288-
assert len(rs) == 2
3286+
## Only assert exact count and RRULE-free output when exception handling
3287+
## is reliable (either client-side or server-side expansion works correctly).
3288+
if self.is_supported("save-load.event.recurrences.exception") or self.is_supported(
3289+
"search.recurrences.expanded.exception"
3290+
):
3291+
assert len(r) == 2
3292+
assert "RRULE" not in r[0].data
3293+
assert "RRULE" not in r[1].data
32893294

3290-
assert "RRULE" not in r[0].data
3291-
assert "RRULE" not in r[1].data
3295+
if self.is_supported("search.recurrences.expanded.event") and self.is_supported(
3296+
"search.recurrences.expanded.exception"
3297+
):
3298+
assert len(rs) == 2
32923299

32933300
asserts_on_results = [r]
32943301
if self.is_supported("search.recurrences.expanded.exception"):

0 commit comments

Comments
 (0)