diff --git a/caldav/compatibility_hints.py b/caldav/compatibility_hints.py index 9fc2fa3e9..dce8d56b9 100644 --- a/caldav/compatibility_hints.py +++ b/caldav/compatibility_hints.py @@ -381,6 +381,23 @@ class FeatureSet: "default": {"support": "full"}, "links": ["https://datatracker.ietf.org/doc/html/rfc4918#section-15.2"], }, + "propfind.limit": { + "description": "A Depth: 1 PROPFIND on a calendar lists every member, however many there are. When 'unsupported', the server stops at a limit of its own and answers 507 Insufficient Storage for the collection (Stalwart, at its WebDav.maxResults setting, 2000 by default); the behaviour names the number of members seen. The probe can only see a limit below the number of objects it stores, which the CALDAV_SERVER_TESTER_LIMIT_OBJECTS environment variable sets; without it the feature is not probed.", + "default": {"support": "full"}, + }, + "multiget": { + "description": "The calendar-multiget REPORT (RFC 4791 section 7.9), which fetches calendar objects by href. The library uses it in load()'s fallback and to fetch the objects a sync report names.", + "default": {"support": "full"}, + "links": ["https://datatracker.ietf.org/doc/html/rfc4791#section-7.9"], + }, + "multiget.missing-href": { + "description": "A calendar-multiget naming an href that does not exist answers it with a 404 entry in the multistatus. RFC 4791 section 7.9 and the multistatus of RFC 4918 section 13 give every requested href a response. When 'unsupported', the href is left out (Baikal), so a client cannot tell a missing object from one the server forgot to answer for.", + "default": {"support": "full"}, + }, + "multiget.limit": { + "description": "A calendar-multiget answers every href it names, however many. When 'unsupported', the server answers at most a number of its own and a 507 for the collection, even when the request asked for no more (Stalwart, at its WebDav.maxResults setting, 2000 by default); a client should then fetch in batches. As for propfind.limit, the probe only sees a limit below CALDAV_SERVER_TESTER_LIMIT_OBJECTS.", + "default": {"support": "full"}, + }, "delete-calendar": { "description": "RFC4791 says nothing about deletion of calendars, so the server implementation is free to choose weather this should be supported or not. Section 3.2.3.2 in RFC 6638 says that if a calendar is deleted, all the calendarobjectresources on the calendar should also be deleted - but it's a bit unclear if this only applies to scheduling objects or not. Some calendar servers moves the object to a trashcan rather than deleting it. 'fragile' is a negative status and additionally switches on Calendar.delete()'s retry-and-poll loop, which re-issues the DELETE. A delete that always goes through but takes a measurable time to settle is not fragile: that is synchronous-write.delete-calendar", ## Independent feature (directly probed): the default marks it so the @@ -487,12 +504,22 @@ class FeatureSet: "save-load.event.timezone": { "description": "The server accepts events with non-UTC timezone information. When unsupported or broken, the server may reject events with timezone data (e.g., return 403 Forbidden). Related to GitHub issue https://github.com/python-caldav/caldav/issues/372." }, + "save-load.event.floating": { + "description": "An event with a floating DTSTART and DTEND (a local time with neither TZID nor a UTC 'Z', RFC 5545 section 3.3.5) is stored floating, meaning the same wall-clock time in whatever time zone the user is in. When 'unsupported', the server stores the time in a time zone of its own: SOGo adds a VTIMEZONE for Etc/UTC and gives DTSTART and DTEND TZID=Etc/UTC, so the wall-clock time is pinned to UTC. SOGo keeps tasks floating. 'broken' when the stored time has both a TZID and a 'Z', which section 3.3.5 forbids. The probe reads the server's own text: the icalendar library (7.3.0) itself writes a TZID=Etc/UTC time back with a 'Z', so a re-serialised object can look broken when the server's is not.", + "default": {"support": "full"}, + "links": ["https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.5"], + }, "save-load.icalendar": {"description": "Is it possible to save icalendar data to the calendar? (Most likely yes - but we need a parent to collect all icalendar compatibility problems that aren't specific to one kind of object resource types"}, "save-load.icalendar.related-to": { "description": "The server preserves RELATED-TO properties (RFC5545 section 3.8.4.5) when saving and loading calendar objects. When 'unsupported', the server may typically silently strip all RELATED-TO lines", "default": {"support": "full"}, "links": ["https://datatracker.ietf.org/doc/html/rfc5545#section-3.8.4.5"], }, + "save-load.icalendar.attendee-params": { + "description": "The parameters of an ATTENDEE property (RFC 5545 section 3.8.4.1) are stored as sent. A parameter added with the value RFC 5545 gives as its default (PARTSTAT=NEEDS-ACTION, ROLE=REQ-PARTICIPANT, CUTYPE=INDIVIDUAL, RSVP=FALSE) changes nothing, nor does SCHEDULE-STATUS, which RFC 6638 section 7.3 has the server set; those are 'full', with the additions named in the behaviour. 'unsupported' when a parameter is dropped or changed, or one is added that changes the meaning: SOGo adds RSVP=TRUE to every attendee, while the default is FALSE. The probe stores an event with attendees but no ORGANIZER, so it is not a scheduling object resource and no iTIP processing applies. See also save-load.mutable.attendee-partstat.", + "default": {"support": "full"}, + "links": ["https://datatracker.ietf.org/doc/html/rfc5545#section-3.8.4.1", "https://datatracker.ietf.org/doc/html/rfc5545#section-3.2"], + }, "save-load.mutable": { "description": "A saved calendar object resource can be modified and PUT back to the server; the server accepts the update and returns the modified data on the next GET/REPORT. When 'unsupported', the server treats calendar objects as immutable after initial creation (e.g. Google Calendar's legacy CalDAV API). Replaces the old 'no_overwrite' compatibility flag.", "default": {"support": "full"}, @@ -696,6 +723,21 @@ class FeatureSet: "sync-token.delete": { "description": "Server correctly handles sync-collection reports after objects have been deleted from the calendar (solved in Nextcloud in https://github.com/nextcloud/server/pull/44130)" }, + "sync-token.delete.created-and-deleted": { + "description": "A member created and deleted again between two sync reports is reported as removed in the second one, as RFC 6578 section 3.5.2 requires. A client that created the object itself learns that it is gone. When 'unsupported', it is not mentioned at all (Xandikos, Stalwart), so a client waiting for its own create to show up waits forever.", + "default": {"support": "full"}, + "links": ["https://datatracker.ietf.org/doc/html/rfc6578#section-3.5.2"], + }, + "sync-token.recreated": { + "description": "A member deleted and re-created at the same href between two sync reports is reported once, as changed (RFC 6578 sections 3.5.1 and 3.2: a member URL appears only once). Not under sync-token.delete: Bedework 5 does not report deletions, yet reports a re-created member correctly. 'quirk' when it is reported twice, as removed and then as changed (Stalwart): the later entry is the one that counts. 'broken' when it is reported as removed only (Nextcloud, Synology), so a client drops an object that exists.", + "default": {"support": "full"}, + "links": ["https://datatracker.ietf.org/doc/html/rfc6578#section-3.5.1"], + }, + "sync-token.initial-sync": { + "description": "A sync report from an empty token lists exactly the current members of the collection: RFC 6578 section 3.4 says it MUST NOT return removed members. 'quirk' when it lists more, which a client can filter away (keep only hrefs below the collection, answered 200): Radicale lists deleted members with a 404, Stalwart also lists the collection itself. The behaviour says which. The limit on an initial sync is sync-token.limit.initial-sync, which inherits from sync-token.limit rather than from this.", + "default": {"support": "full"}, + "links": ["https://datatracker.ietf.org/doc/html/rfc6578#section-3.4"], + }, "sync-token.invalid-token": { "description": "A sync-collection report with a token the server never issued is refused with the DAV:valid-sync-token precondition and a 403, which RFC 4918 section 16 suggests for a request that will always fail, so the client knows to start over with a full sync. 'quirk' means the precondition comes with another status (Xandikos: 412). 'ungraceful' means an error without the precondition (Xandikos and CCS answer 500 for a malformed token). 'broken' means the report succeeds (Stalwart returns a full listing), so a client can't tell a refused token from an ordinary report", "links": ["https://datatracker.ietf.org/doc/html/rfc6578#section-3.2"], @@ -794,6 +836,11 @@ class FeatureSet: "default": {"support": "full"}, "links": ["https://datatracker.ietf.org/doc/html/rfc9110#section-8.8.3"], }, + "save.etag.matches-stored": { + "description": "A strong ETag in a PUT answer belongs to what the server stored. RFC 4791 section 5.3.4 lets a server store an object otherwise than sent (add DTSTAMP or CLASS, reorder properties), but then the PUT MUST NOT answer with a strong ETag, so that a client holding the data it sent next to that ETag holds what the server has. When 'broken', the server rewrites the object and still answers with a strong ETag - merely reordering properties included, since a strong ETag promises identical bytes (Radicale, Xandikos, CCS and Infomaniak reorder, SOGo adds CLASS, Zimbra and Bedework 5 add properties); the data the library keeps is then not what a GET returns. A server storing the object as sent, or rewriting it and answering with a weak ETag or none (Bedework 3, OX), is 'full'. Not probed where save.etag is broken, since an unusable ETag matches nothing.", + "default": {"support": "full"}, + "links": ["https://datatracker.ietf.org/doc/html/rfc4791#section-5.3.4"], + }, "save.if-none-match": { "description": "A PUT carrying If-None-Match: * to an existing object is refused with 412 Precondition Failed, and creates a missing object. RFC 9110 section 13.1.2 has '*' hold only when the resource has no current representation, so it means 'create, but never overwrite'; RFC 4791 section 5.3.2 tells CalDAV clients to send it when creating. save(no_overwrite=True) sends it, so the server enforces 'do not overwrite' in the same request rather than after a search. When 'unsupported', the server overwrites the existing object (SOGo ignores the header; Zimbra compares '*' as a literal etag, so the condition always holds) - the library then does not send it, nor anywhere else this is not supported ('full' or 'quirk'), and relies on the UID search before the PUT, as before. The default is 'full': RFC 4791 section 2 requires a CalDAV server to support ETags as RFC 2616 defines them, and RFC 2616 section 14.26 (now RFC 9110 section 13.1.2) says the server MUST NOT perform the method when '*' is given and the object exists.", "default": {"support": "full"}, @@ -804,6 +851,21 @@ class FeatureSet: "default": {"support": "unknown"}, "links": ["https://datatracker.ietf.org/doc/html/rfc4791#section-5.3.2.1"], }, + "delete": {"description": "Deleting a calendar object resource. Deleting a whole calendar is delete-calendar."}, + "delete.if-match": { + "description": "A DELETE carrying If-Match with an etag the object no longer has is refused with 412 Precondition Failed, and one carrying the current etag goes through. RFC 9110 section 13.1.1 says the server MUST NOT perform the method when the condition does not hold; delete(if_match=True) relies on it to avoid deleting a version someone else has saved since it was loaded. When 'broken', the condition is ignored and a stale If-Match deletes the object anyway (SOGo, while it honours a stale If-Match on PUT; Zimbra, Bedework 3 and 5, Posteo). When 'unsupported', a DELETE with the current etag is refused too. The default is 'full', as for save-load.mutable.if-match-wildcard: RFC 4791 section 2 requires ETag support as RFC 2616 defines it.", + "default": {"support": "full"}, + "links": ["https://datatracker.ietf.org/doc/html/rfc9110#section-13.1.1", "https://datatracker.ietf.org/doc/html/rfc4791#section-2"], + }, + "delete.missing": { + "description": "A DELETE of a calendar object that does not exist answers 404 Not Found (RFC 9110 section 15.5.5). When 'unsupported', it succeeds anyway (2xx), so a client cannot tell that someone else deleted the object first.", + "default": {"support": "full"}, + }, + "delete.missing.if-match": { + "description": "A DELETE carrying If-Match on an object that does not exist answers as a failing plain DELETE of it does, normally 404; where a plain DELETE of a missing object succeeds, the condition is evaluated and 412 is right. RFC 9110 section 13.2.1 has a server ignore the preconditions when the request would fail without them, so the missing object wins over the failed condition. When 'unsupported', the server answers 412 Precondition Failed (Baikal, Davis, Nextcloud, CCS, OX, Robur, Purelymail, Infomaniak), and delete(if_match=True) on an object someone else already deleted raises an etag mismatch rather than NotFoundError. Independent of delete.if-match: SOGo ignores a stale If-Match, yet answers a missing object with 404.", + "default": {"support": "full"}, + "links": ["https://datatracker.ietf.org/doc/html/rfc9110#section-13.2.1"], + }, "save.duplicate-uid": {}, "save.duplicate-uid.cross-calendar": { "description": "Server allows events with the same UID to exist in different calendars and treats them as separate entities. Support can be 'full' (allowed), 'ungraceful' (rejected with error), or 'unsupported' (silently ignored or moved). Behaviour 'silently-ignored' means the duplicate is not saved but no error is thrown. Behaviour 'moved-instead-of-copied' means the event is moved from the original calendar to the new calendar (Zimbra behavior)" @@ -1407,6 +1469,9 @@ def compare(self, observed): ## Probed 2026-10-21 by caldav-server-tester "calendar-color": {"support": "full"}, "calendar-order": {"support": "full"}, + ## Probed 2026-10-24 by caldav-server-tester (sync, multiget and DELETE probes) + "sync-token.delete.created-and-deleted": {"support": "unsupported", "behaviour": "a member created and deleted between two reports is not reported"}, + "save.etag.matches-stored": {"support": "broken", "behaviour": "stored with its properties reordered, yet the PUT answered with a strong ETag"}, } ## This seems to work as of version 3.5.4 of Radicale. @@ -1442,6 +1507,9 @@ def compare(self, observed): "calendar-order": {"support": "full"}, ## Probed 2026-10-11 by the server tester "sync-token.limit": {"support": "unsupported", "behaviour": "DAV:limit is ignored"}, + ## Probed 2026-10-24 by caldav-server-tester (sync, multiget and DELETE probes) + "sync-token.initial-sync": {"support": "quirk", "behaviour": "lists deleted members with a 404"}, + "save.etag.matches-stored": {"support": "broken", "behaviour": "stored with its properties reordered, yet the PUT answered with a strong ETag"}, } ## Nextcloud can be configured a lot. What particularly affects the compatibility @@ -1520,6 +1588,10 @@ def compare(self, observed): ## Probed 2026-10-20 by caldav-server-tester "calendar-color": {"support": "full"}, "calendar-order": {"support": "full"}, + ## Probed 2026-10-24 by caldav-server-tester (sync, multiget and DELETE probes) + "delete.missing.if-match": {"support": "unsupported", "behaviour": "a DELETE with If-Match on a missing object answered 412"}, + "multiget.missing-href": {"support": "unsupported", "behaviour": "a missing href is left out of the multistatus"}, + "sync-token.recreated": {"support": "broken", "behaviour": "a member deleted and re-created is reported as removed only"}, } ecloud = nextcloud | { @@ -1668,6 +1740,9 @@ def compare(self, observed): "calendar-color": {"support": "full"}, "calendar-color.name": {"support": "unsupported"}, "calendar-order": {"support": "full"}, + ## Probed 2026-10-24 by caldav-server-tester (sync, multiget and DELETE probes) + "delete.if-match": {"support": "broken", "behaviour": "a DELETE with a stale If-Match deleted the object (204)"}, + "save.etag.matches-stored": {"support": "broken", "behaviour": "stored with STATUS, CLASS, TRANSP, LAST-MODIFIED and SEQUENCE added, yet the PUT answered with a strong ETag"}, } ## Measured against the `ioggstream/bedework:latest` docker image, which is @@ -1737,6 +1812,8 @@ def compare(self, observed): ## Probed 2026-10-21 by caldav-server-tester "calendar-color": {"support": "full"}, "calendar-order": {"support": "unsupported"}, + ## Probed 2026-10-24 by caldav-server-tester (sync, multiget and DELETE probes) + "delete.if-match": {"support": "broken", "behaviour": "a DELETE with a stale If-Match deleted the object (204)"}, } ## Bedework 5.0.0, measured 2026-09-12 with caldav-server-tester against the @@ -1886,6 +1963,10 @@ def compare(self, observed): ## One account is configured, so the cross-user half of scheduling is ## untested; the server advertises scheduling and the mailboxes are there. "scheduling.auto-schedule": {"support": "unknown"}, + ## Probed 2026-10-24 by caldav-server-tester (sync, multiget and DELETE probes) + "delete.if-match": {"support": "broken", "behaviour": "a DELETE with a stale If-Match deleted the object (204)"}, + "sync-token.delete.created-and-deleted": {"support": "unsupported", "behaviour": "a member created and deleted between two reports is not reported"}, + "save.etag.matches-stored": {"support": "broken", "behaviour": "stored with CREATED and LAST-MODIFIED added, PRODID and DTSTAMP changed, yet the PUT answered with a strong ETag"}, } baikal = { ## version 0.10.1 @@ -1914,6 +1995,9 @@ def compare(self, observed): ## is honoured, on an initial sync it is ignored "sync-token.limit": {"support": "full"}, "sync-token.limit.initial-sync": {"support": "unsupported", "behaviour": "DAV:limit is ignored"}, + ## Probed 2026-10-24 by caldav-server-tester (sync, multiget and DELETE probes) + "delete.missing.if-match": {"support": "unsupported", "behaviour": "a DELETE with If-Match on a missing object answered 412"}, + "multiget.missing-href": {"support": "unsupported", "behaviour": "a missing href is left out of the multistatus"}, } ## TODO: testPrincipals, testWrongAuthType, testTodoDatesearch fails ## Some unknown version of baikal has this @@ -2056,6 +2140,8 @@ def compare(self, observed): ## extra properties not specified in RFC4791/RFC5545 "calendar-color": {"support": "full"}, "calendar-order": {"support": "full"}, + ## Probed 2026-10-24 by caldav-server-tester (sync, multiget and DELETE probes) + "multiget.missing-href": {"support": "unsupported", "behaviour": "a missing href is left out of the multistatus"}, } ## Synology Calendar (the DSM package) embeds a modified DAViCal, hence @@ -2081,6 +2167,8 @@ def compare(self, observed): ## requests to the attendee inbox. Pin it again only with a real observation.) ## Test bookkeeping rather than a server feature. 'test-calendar': {'cleanup-regime': 'wipe-calendar'}, + ## Probed 2026-10-24 by caldav-server-tester (sync, multiget and DELETE probes) + "sync-token.recreated": {"support": "broken", "behaviour": "a member deleted and re-created is reported as removed only"}, } sogo = { @@ -2148,6 +2236,13 @@ def compare(self, observed): "behaviour": "If-Match: * is ignored: it created a missing object (201); If-None-Match: * overwrote an existing object", }, "save.if-none-match": {"support": "unsupported", "behaviour": "If-None-Match: * is ignored: it overwrote an existing object"}, + ## Measured 2026-10-23. A stale If-Match on PUT is refused with 412, + ## but on DELETE it is ignored. + "delete.if-match": {"support": "broken", "behaviour": "a DELETE with a stale If-Match deleted the object (204)"}, + ## Tasks are kept floating; events are not. The stored text is valid - + ## the 'Z' after the TZID seen client-side comes from the icalendar library. + "save-load.event.floating": {"support": "unsupported", "behaviour": "a floating time is stored with TZID=Etc/UTC"}, + "save-load.icalendar.attendee-params": {"support": "unsupported", "behaviour": "RSVP=TRUE is added; PARTSTAT=NEEDS-ACTION is added"}, ## Measured 2026-09-15. "create-calendar.with-supported-component-types": { "support": "unsupported", @@ -2156,6 +2251,8 @@ def compare(self, observed): # Ephemeral Docker container: wipe objects (delete-calendar fragile) 'test-calendar': {'cleanup-regime': 'wipe-calendar'}, + ## Probed 2026-10-24 by caldav-server-tester (sync, multiget and DELETE probes) + "save.etag.matches-stored": {"support": "broken", "behaviour": "stored with CLASS added, yet the PUT answered with a strong ETag"}, } ## Old notes for sogo (todo - incorporate them in the structure above). ## The commented-out lists here and below name flags of the old-style @@ -2236,6 +2333,8 @@ def compare(self, observed): "calendar-color": {"support": "full"}, "calendar-order": {"support": "full"}, 'well-known': {'support': 'full'}, + ## Probed 2026-10-24 by caldav-server-tester (sync, multiget and DELETE probes) + "delete.missing.if-match": {"support": "unsupported", "behaviour": "a DELETE with If-Match on a missing object answered 412"}, } posteo = { @@ -2276,6 +2375,9 @@ def compare(self, observed): "calendar-color": {"support": "full"}, "calendar-order": {"support": "full"}, 'well-known': {'support': 'unsupported'}, + ## Probed 2026-10-24 by caldav-server-tester (sync, multiget and DELETE probes) + "delete.if-match": {"support": "broken", "behaviour": "a DELETE with a stale If-Match deleted the object (204)"}, + "multiget.missing-href": {"support": "ungraceful", "behaviour": "a multiget naming a missing object failed: calendar-multiget answered 404"}, } #calendar_mail_ru = [ @@ -2320,6 +2422,9 @@ def compare(self, observed): ## token the limit is honoured, on an initial sync it is ignored "sync-token.limit": {"support": "full"}, "sync-token.limit.initial-sync": {"support": "unsupported", "behaviour": "DAV:limit is ignored"}, + ## Probed 2026-10-24 by caldav-server-tester (sync, multiget and DELETE probes) + "delete.missing.if-match": {"support": "unsupported", "behaviour": "a DELETE with If-Match on a missing object answered 412"}, + "multiget.missing-href": {"support": "unsupported", "behaviour": "a missing href is left out of the multistatus"}, } ## Apple CalendarServer (CCS) - archived 2019, Python 2/Twisted. @@ -2380,6 +2485,10 @@ def compare(self, observed): ## Probed 2026-10-20 by caldav-server-tester "calendar-color": {"support": "full"}, "calendar-order": {"support": "full"}, + ## Probed 2026-10-24 by caldav-server-tester (sync, multiget and DELETE probes) + "delete.missing.if-match": {"support": "unsupported", "behaviour": "a DELETE with If-Match on a missing object answered 412"}, + "sync-token.initial-sync": {"support": "quirk", "behaviour": "lists the collection itself"}, + "save.etag.matches-stored": {"support": "broken", "behaviour": "stored with its properties reordered, yet the PUT answered with a strong ETag"}, } ## Stalwart - all-in-one mail & collaboration server (CalDAV added 2024/2025) @@ -2485,6 +2594,15 @@ def compare(self, observed): ## Probed 2026-10-20 by caldav-server-tester "calendar-color": {"support": "full"}, "calendar-order": {"support": "full"}, + ## Probed 2026-10-24 by caldav-server-tester (sync, multiget and DELETE probes) + "sync-token.initial-sync": {"support": "quirk", "behaviour": "lists the collection itself"}, + "sync-token.recreated": {"support": "quirk", "behaviour": "reported twice: as removed, then as changed"}, + "multiget.limit": {"support": "unsupported", "behaviour": "stops at WebDav.maxResults with a 507"}, + "propfind.limit": {"support": "unsupported", "behaviour": "the whole PROPFIND answers 507 above WebDav.maxResults"}, + "sync-token.delete.created-and-deleted": { + "support": "unsupported", + "behaviour": "not reported, unless a member it already had changed in the same interval", + }, } ## Lots of transient problems with purelymail @@ -2526,6 +2644,8 @@ def compare(self, observed): "scheduling": {"support": "unsupported"}, ## Known, not a breach of standard "get-supported-components": {"support": "unsupported"}, + ## Probed 2026-10-24 by caldav-server-tester (sync, multiget and DELETE probes) + "delete.missing.if-match": {"support": "unsupported", "behaviour": "a DELETE with If-Match on a missing object answered 412"}, } gmx = { @@ -2611,6 +2731,9 @@ def compare(self, observed): }, ## Probed 2026-10-20 by caldav-server-tester 'search.recurrences.includes-implicit.infinite-scope': {'support': 'fragile', 'behaviour': 'unsupported in one run, full in the next (2026-10-20/21)'}, + ## Probed 2026-10-24 by caldav-server-tester (sync, multiget and DELETE probes) + ## save.etag is a quirk (weak on PUT, strong on GET); nothing is rewritten + "save.etag.matches-stored": {"support": "full"}, } ## https://ox.io/ @@ -2751,6 +2874,8 @@ def compare(self, observed): "calendar-color": {"support": "full"}, "calendar-color.name": {"support": "unsupported", "behaviour": "colour names are accepted but ignored: set 'blue' or 'green', '#CEE7FFFF' comes back"}, "calendar-order": {"support": "unsupported"}, + ## Probed 2026-10-24 by caldav-server-tester (sync, multiget and DELETE probes) + "delete.missing.if-match": {"support": "unsupported", "behaviour": "a DELETE with If-Match on a missing object answered 412"}, } ## Infomaniak (https://www.infomaniak.com/) - kSuite calendar, CalDAV served @@ -2811,17 +2936,12 @@ def compare(self, observed): ## Principal search is effectively unsupported (lists nothing / errors out). 'principal-search': {'support': 'ungraceful'}, 'principal-search.by-name.self': {'support': 'unsupported'}, - ## This was added 2026-08-28. I believe the compatibility tests have passed - ## before. I don't think this part of the test suite has changed. It could - ## be that the behaviour has changed at the server side. 418 was originally an - ## April joke and may mean anything ... but it's sometimes used as a rate-limit - ## response. However, it seems to consistently break exactly here. - ## The removed member comes back with status 418 inside the multistatus, which - ## caldav's _validate_status turns into a ResponseError - the sync raises rather - ## than silently coming back wrong, hence "ungraceful". - 'sync-token.delete': {'support': 'ungraceful', 'behaviour': "418 I'm a teapot"}, ## Probed 2026-10-13 by the server tester 'sync-token.limit': {'support': 'unsupported', 'behaviour': 'DAV:limit is ignored'}, + ## Probed 2026-10-24 by caldav-server-tester (sync, multiget and DELETE probes) + "delete.missing.if-match": {"support": "unsupported", "behaviour": "a DELETE with If-Match on a missing object answered 412"}, + "multiget.missing-href": {"support": "unsupported", "behaviour": "a missing href is left out of the multistatus"}, + "save.etag.matches-stored": {"support": "broken", "behaviour": "stored with its properties reordered, yet the PUT answered with a strong ETag"}, } ## Yahoo Calendar (https://caldav.calendar.yahoo.com/), a hosted service. diff --git a/caldav/response.py b/caldav/response.py index 928957fe4..e68cd7751 100644 --- a/caldav/response.py +++ b/caldav/response.py @@ -640,6 +640,11 @@ def _parse_response(self, response: _Element) -> tuple[str, list[_Element], Any ## 404s; flag it in debug mode if a server pairs them with some ## other status so we notice and revisit this handling. error.assert_("404" in status) + if status and " 404 " in status: + ## The resource is gone, so whatever its propstats say is moot. + ## Infomaniak adds an empty propstat with 418 to a removed member + ## of a sync-collection reply, which would otherwise raise. + propstats = [] return (cast(str, href), propstats, status) def _parse_scheduling_response_objects(self, parent) -> dict: diff --git a/changes/+sogo-rewrites.added.md b/changes/+sogo-rewrites.added.md new file mode 100644 index 000000000..bef78ffa1 --- /dev/null +++ b/changes/+sogo-rewrites.added.md @@ -0,0 +1 @@ +Compatibility features `delete.if-match` (a DELETE with a stale If-Match is refused with 412; SOGo deletes the object anyway, while it refuses a stale If-Match on PUT), `save-load.event.floating` (an event with a floating DTSTART/DTEND is stored floating; SOGo pins it to TZID=Etc/UTC) and `save-load.icalendar.attendee-params` (ATTENDEE parameters are stored as sent; SOGo adds RSVP=TRUE, which changes the meaning, as the default is FALSE). The SOGo profile has all three. diff --git a/changes/+sync-404-propstats.fixed.md b/changes/+sync-404-propstats.fixed.md new file mode 100644 index 000000000..546502ccf --- /dev/null +++ b/changes/+sync-404-propstats.fixed.md @@ -0,0 +1 @@ +`Calendar.get_objects_by_sync_token()` no longer raises when a server answers a removed member with an extra propstat carrying an unexpected status: the propstats of a response whose own status is 404 are now ignored. Infomaniak adds an empty propstat with "418 I'm a teapot" to every removed member. diff --git a/changes/+sync-multiget-features.added.md b/changes/+sync-multiget-features.added.md new file mode 100644 index 000000000..2bc941c12 --- /dev/null +++ b/changes/+sync-multiget-features.added.md @@ -0,0 +1,8 @@ +Compatibility features for behaviours a sync client depends on, as found by calendaring-sync (https://github.com/PyCalendar/calendaring-sync/blob/c40782bc7afe7b350fe63bf27ef13374c280ea8b/docs/reference/server-compatibility.rst), each with a probe in caldav-server-tester: + +- `delete.missing` and `delete.missing.if-match`: a DELETE of a missing object answers 404, and one with If-Match answers as the plain one does (Baikal, Davis, Nextcloud, CCS, OX, Robur, Purelymail and Infomaniak answer 412 with it). +- `save.etag.matches-stored`: a strong ETag in a PUT answer belongs to what was stored (Radicale, Xandikos, SOGo, CCS, Zimbra, Bedework 5 and Infomaniak rewrite the object and still answer with one). +- `sync-token.initial-sync`, `sync-token.delete.created-and-deleted` and `sync-token.recreated`: which members a sync-collection report lists. +- `multiget`, `multiget.missing-href` and `multiget.limit`, and `propfind.limit`: a calendar-multiget answers a missing href with a 404, and a multiget or a Depth: 1 PROPFIND answers every href (Stalwart stops at its maxResults, with a 507). + +The Stalwart test container now runs with a maxResults of 50 (`STALWART_MAX_RESULTS`), so the limit can be probed with few objects. diff --git a/tests/docker-test-servers/stalwart/setup_stalwart.sh b/tests/docker-test-servers/stalwart/setup_stalwart.sh index effb1b14e..c4cd9b011 100755 --- a/tests/docker-test-servers/stalwart/setup_stalwart.sh +++ b/tests/docker-test-servers/stalwart/setup_stalwart.sh @@ -159,6 +159,43 @@ create_user "user1" "caldavtest1" create_user "user2" "caldavtest2" create_user "user3" "caldavtest3" +MAX_RESULTS="${STALWART_MAX_RESULTS:-50}" +echo "" +echo "Lowering WebDav.maxResults to ${MAX_RESULTS}..." +# Stalwart caps PROPFIND, sync-collection and calendar-multiget answers at +# WebDav.maxResults (2000 by default), with a 507 for the collection. A low +# cap lets caldav-server-tester see it with few objects +# (CALDAV_SERVER_TESTER_LIMIT_OBJECTS). The setting applies on a settings +# reload, which refuses the image's default SpamDnsblServer entries +# ("bit_and"); each entry it names is removed, in this container only. +RESULT=$(jmap_call "{ + \"using\": [\"urn:ietf:params:jmap:core\"], + \"methodCalls\": [[\"x:WebDav/set\", { + \"accountId\": \"d333333\", + \"update\": {\"singleton\": {\"maxResults\": ${MAX_RESULTS}}} + }, \"0\"]] +}") +if ! echo "$RESULT" | grep -q '"updated"'; then + echo "Warning: setting maxResults returned: $RESULT" +fi +for i in $(seq 1 40); do + RESULT=$(jmap_call '{"using":["urn:ietf:params:jmap:core"],"methodCalls":[["x:Action/set",{"accountId":"d333333","create":{"r":{"@type":"ReloadSettings"}}},"0"]]}') + if echo "$RESULT" | grep -q '"created"'; then + echo "maxResults is ${MAX_RESULTS}" + break + fi + BAD=$(echo "$RESULT" | python3 -c "import json,sys; b=json.load(sys.stdin)['methodResponses'][0][1]['notCreated']['r']['objectId']; print(b['object'], b['id'])" 2>/dev/null || true) + if [ -z "$BAD" ] || [ "$i" -eq 40 ]; then + echo "Warning: settings reload returned: $RESULT" + break + fi + read -r BAD_OBJECT BAD_ID <<< "$BAD" + jmap_call "{ + \"using\": [\"urn:ietf:params:jmap:core\"], + \"methodCalls\": [[\"x:${BAD_OBJECT}/set\", {\"accountId\": \"d333333\", \"destroy\": [\"${BAD_ID}\"]}, \"0\"]] + }" > /dev/null +done + echo "" echo "Verifying CalDAV access..." # v0.16+: CalDAV path encodes the @ in the email address as %40. diff --git a/tests/test_protocol.py b/tests/test_protocol.py index 9a796b6e5..5732e2fc0 100644 --- a/tests/test_protocol.py +++ b/tests/test_protocol.py @@ -341,6 +341,36 @@ def test_parse_sync_collection_generic_error(self): assert result.deleted == ["/cal/gone.ics"] assert result.changed == [] + ## Infomaniak (SabreDAV) answers a removed member with an empty propstat + ## carrying 418 next to the 404 of the response itself + removed_with_teapot_xml = b""" + + + /cal/gone.ics + + + HTTP/1.1 418 I'm a teapot + + HTTP/1.1 404 Not Found + + tok + """ + + def test_parse_propfind_ignores_propstats_of_a_404(self): + results = DAVResponse.from_bytes(self.removed_with_teapot_xml).parse_propfind() + + assert [(r.href, r.properties, r.status) for r in results] == [("/cal/gone.ics", {}, 404)] + + def test_expand_simple_props_ignores_propstats_of_a_404(self): + """The legacy parser used by Calendar.get_objects_by_sync_token.""" + from caldav.elements import dav + + response = DAVResponse.from_bytes(self.removed_with_teapot_xml) + props = response.expand_simple_props([dav.GetEtag()]) + + assert props == {"/cal/gone.ics": {dav.GetEtag.tag: None}} + assert response.statuses == {"/cal/gone.ics": "HTTP/1.1 404 Not Found"} + def test_parse_nested_properties(self): """Issue https://github.com/python-caldav/caldav/issues/795: properties nested deeper than one level gave the raw lxml element."""