diff --git a/caldav/compatibility_hints.py b/caldav/compatibility_hints.py index 3fe8a546..91f3bdde 100644 --- a/caldav/compatibility_hints.py +++ b/caldav/compatibility_hints.py @@ -2877,8 +2877,11 @@ def __getattr__(name: str) -> Any: ## Stalwart - all-in-one mail & collaboration server (CalDAV added 2024/2025) ## https://stalw.art/ ## CalDAV served at /dav/cal// over HTTP on port 8080. -## Feature support mostly unknown until tested; starting with empty hints. -stalwart = { +## Measured against the stalwartlabs/stalwart docker image up to v0.16. v1.0 +## has a new database layout, so v0.16 servers need a migration rather than +## an upgrade, and v1.1 behaves differently - see stalwart_1_1. The +## comments in this dict describe v0.16, also for keys stalwart_1_1 overrides. +stalwart_0_16 = { ## Probed 2026-10-26 by caldav-server-tester: RFC 4791 section 5.2.3 makes ## storing a component type outside the set an error. "supported-components.honoured": {"support": "broken", "behaviour": "the restriction is advertised but not enforced: a VTODO-only calendar reports ['VTODO'], and a VEVENT can be saved to it"}, @@ -2996,6 +2999,42 @@ def __getattr__(name: str) -> Any: "save-load.icalendar.olson-tzid": {"support": "full"}, } +## Stalwart v1.1, probed 2026-11-12 by caldav-server-tester against the +## docker image. Only the differences from v0.16. +stalwart_1_1 = stalwart_0_16 | { + ## Text search reads an index that is updated asynchronously, so an + ## object written just before a text search may not be found yet + ## (testSearchEvent, 2026-11-12). + "search-cache": {"behaviour": "delay", "delay": 0.5}, + ## Fixed since v0.16 + "supported-components.honoured": {"support": "full"}, + "save.no-uid-conflict": {"support": "full"}, + "search.time-range.alarm": {"support": "full"}, + ## Old-date DTSTART-less tasks are found too (testTodoDatesearch) + "search.time-range.todo.no-dtstart": {"support": "full"}, + "search.time-range.todo.no-dtstart.no-due": {"support": "full"}, + "sync-token.limit.initial-sync": {"support": "full"}, + ## Matched on v0.16; RFC 4791 section 9.7.2 has a prop-filter test the + ## properties of the component it sits in, so v1.1 is the stricter reading + "search.text.comp-type-optional": { + "support": "unsupported", + "behaviour": "comp-type-less prop-filter query returns nothing (prop-filter under VCALENDAR matches no component property)", + }, + ## v0.16 kept the two spellings of a calendar id apart as two + ## collections, and served an object PUT to a literal "@" name only + ## under "%40". On v1.1 both spellings resolve: for a calendar id they + ## name one collection, for an object name the probe cannot tell. Both + ## values are the defaults; they only cancel the v0.16 overrides. + "url.encode-at.identity": { + "support": "unsupported", + "behaviour": "calendar paths: both spellings name one collection; object paths: both spellings resolve; principal paths: the server-given path answers under both spellings", + }, + "url.encode-at.literal.object": { + "support": "full", + "behaviour": "an object PUT to the literal '@' path resolves under both spellings; the server would not take a second object at '%40', so whether they are one resource is not observable", + }, +} + ## Lots of transient problems with purelymail purelymail = { ## Measured with caldav-server-tester, 2026-10-19. diff --git a/caldav/config.py b/caldav/config.py index 18af4eb4..d1b67f7e 100644 --- a/caldav/config.py +++ b/caldav/config.py @@ -96,6 +96,10 @@ def read_config(fn, interactive_error=False): "the profile was measured against a Zimbra 8.8 docker image; use " "zimbra_10_1 for a current Zimbra", ), + "stalwart": ( + "stalwart_0_16", + "the profile was measured against Stalwart v0.16; use stalwart_1_1 for Stalwart v1.1", + ), } diff --git a/caldav/testing/docker.py b/caldav/testing/docker.py index f31f55e7..23b87e72 100644 --- a/caldav/testing/docker.py +++ b/caldav/testing/docker.py @@ -506,7 +506,7 @@ def __init__(self, config: dict[str, Any] | None = None) -> None: ) config.setdefault("password", os.environ.get("STALWART_PASSWORD", "testcaldav")) if "features" not in config: - config["features"] = compatibility_hints.stalwart.copy() + config["features"] = compatibility_hints.stalwart_1_1.copy() # user1-user3 are created by setup_stalwart.sh for scheduling tests # Passwords match setup_stalwart.sh defaults (caldavtest{N}). if "scheduling_users" not in config: diff --git a/changes/+stalwart-versioned-profiles.deprecated.md b/changes/+stalwart-versioned-profiles.deprecated.md new file mode 100644 index 00000000..2ef64e4f --- /dev/null +++ b/changes/+stalwart-versioned-profiles.deprecated.md @@ -0,0 +1 @@ +The `stalwart` compatibility profile is renamed `stalwart_0_16`, as it was measured against Stalwart v0.16, and `stalwart_1_1` is added: Stalwart v1.1 fixed several of the v0.16 issues, but no longer matches a comp-type-less prop-filter search, and treats `@` and `%40` in a path as the same resource. The old name still works, as `stalwart_0_16`: `features: stalwart` or `base: stalwart` in a config gives a FutureWarning, and `compatibility_hints.stalwart` in code a DeprecationWarning. diff --git a/tests/docker-test-servers/stalwart/setup_stalwart.sh b/tests/docker-test-servers/stalwart/setup_stalwart.sh index c4cd9b01..0fcfe2b9 100755 --- a/tests/docker-test-servers/stalwart/setup_stalwart.sh +++ b/tests/docker-test-servers/stalwart/setup_stalwart.sh @@ -2,7 +2,8 @@ # Setup script for Stalwart test server. # Creates the test domain and user via the JMAP management API. # -# Stalwart v0.16+ architecture: +# Stalwart v0.16+ architecture (v1.0 renamed some settings fields, e.g. +# passwordMinStrength -> minPasswordStrength): # - REST /api/ endpoints are gone; management is done via JMAP (POST /jmap). # - x:Domain/set creates a domain principal. # - x:Account/set creates a user account (name = local part, domainId = domain id). @@ -57,7 +58,7 @@ RESULT=$(jmap_call '{ "using": ["urn:ietf:params:jmap:core"], "methodCalls": [["x:Authentication/set", { "accountId": "d333333", - "update": {"singleton": {"passwordMinStrength": "zero"}} + "update": {"singleton": {"minPasswordStrength": "zero"}} }, "0"]] }') if echo "$RESULT" | grep -q '"updated"'; then @@ -95,6 +96,32 @@ else echo "Rate limiting disabled" fi +echo "" +echo "Disabling MTA inbound throttles for test environment..." +# v1 delivers iTIP between local accounts through the MTA, whose default +# throttles include 25 messages an hour per sender domain and recipient. The +# test users share one domain, so a test run exhausts it and invitations +# stall with "452 4.4.5 Rate limit exceeded" for up to an hour. +RESULT=$(jmap_call '{"using":["urn:ietf:params:jmap:core"],"methodCalls":[["x:MtaInboundThrottle/get",{"accountId":"d333333","ids":null,"properties":["id"]},"0"]]}') +THROTTLE_IDS=$(echo "$RESULT" | \ + python3 -c "import json,sys; print(' '.join(x['id'] for x in json.load(sys.stdin)['methodResponses'][0][1]['list']))" 2>/dev/null || true) +if [ -z "$THROTTLE_IDS" ]; then + echo "Warning: no MTA inbound throttles found, invitations may stall: $RESULT" +fi +for THROTTLE_ID in $THROTTLE_IDS; do + RESULT=$(jmap_call "{ + \"using\": [\"urn:ietf:params:jmap:core\"], + \"methodCalls\": [[\"x:MtaInboundThrottle/set\", { + \"accountId\": \"d333333\", + \"update\": {\"${THROTTLE_ID}\": {\"enable\": false}} + }, \"0\"]] + }") + if ! echo "$RESULT" | grep -q '"updated"'; then + echo "Warning: disabling throttle ${THROTTLE_ID} returned: $RESULT" + fi +done +echo "Disabled throttles: ${THROTTLE_IDS:-none}" + echo "" echo "Creating domain '${DOMAIN}'..." RESULT=$(jmap_call "{ diff --git a/tests/test_compatibility_hints.py b/tests/test_compatibility_hints.py index 7c55c6d4..ed49e08b 100644 --- a/tests/test_compatibility_hints.py +++ b/tests/test_compatibility_hints.py @@ -665,6 +665,30 @@ def test_zimbra_10_1_refuses_a_taken_uid_with_412(self) -> None: assert ch.zimbra_8_8["save.no-uid-conflict"] == {"support": "full"} assert ch.zimbra_10_1["save-load.stable-url"] == ch.zimbra_8_8["save-load.stable-url"] + def test_stalwart_profile_was_version_stamped(self) -> None: + assert hasattr(ch, "stalwart_0_16") + assert hasattr(ch, "stalwart_1_1") + assert "stalwart" not in vars(ch) + + def test_deprecated_stalwart_name_still_resolves(self) -> None: + for name in ("stalwart", "compatibility_hints.stalwart"): + with pytest.warns(FutureWarning, match="stalwart_1_1"): + assert _resolve_features(name) == ch.stalwart_0_16 + with pytest.warns(DeprecationWarning, match="stalwart_0_16"): + assert ch.stalwart is ch.stalwart_0_16 + + def test_stalwart_1_1_differs_from_0_16(self) -> None: + old = FeatureSet(ch.stalwart_0_16) + new = FeatureSet(ch.stalwart_1_1) + for feature, before, after in [ + ("supported-components.honoured", "broken", "full"), + ("search.text.comp-type-optional", "full", "unsupported"), + ("sync-token.limit.initial-sync", "broken", "full"), + ("search.time-range.alarm", "unsupported", "full"), + ]: + assert old.is_supported(feature, str) == before, feature + assert new.is_supported(feature, str) == after, feature + def test_unknown_profile_names_the_offender(self) -> None: with pytest.raises(ValueError) as exc_info: _resolve_features("no_such_server") @@ -1085,9 +1109,10 @@ def test_multiple_inherits_an_unprobeable_ancestor(self, ancestor: str) -> None: ("bedework_3_10_3", {CREATE: "full", HONOURED: "broken"}), ("davical", {CREATE: "full", HONOURED: "broken"}), ("synology", {CREATE: "full", HONOURED: "broken"}), - ("stalwart", {CREATE: "full", HONOURED: "broken"}), + ("stalwart_0_16", {CREATE: "full", HONOURED: "broken"}), ## the calendar is VEVENT-only, whatever was asked for ("bedework_5_0_0", {CREATE: "unsupported", HONOURED: "full"}), + ("stalwart_1_1", {CREATE: "full", HONOURED: "full"}), ("sogo", {CREATE: "unsupported", EXISTS: "full"}), ("gmx", {CREATE: "unsupported", MULTIPLE: "unknown"}), ("yahoo", {CREATE: "unknown"}),