From f73168b9a2f5467fa3624d7127a68f2edf47d7bd Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Thu, 12 Nov 2026 20:05:55 +0100 Subject: [PATCH] feat: version-stamp the Stalwart profile Stalwart v1.0 needs a migration from v0.16, and v1.1 behaves differently: several v0.16 bugs are fixed, a comp-type-less prop-filter search now finds nothing (the stricter reading of RFC 4791), and "@" and "%40" in a path name one resource. Like zimbra, the profile is split into stalwart_0_16 and stalwart_1_1; the old name resolves to stalwart_0_16 with a warning. v1.1 updates its text-search index asynchronously, so stalwart_1_1 asks for a short search-cache delay. The docker setup follows v1's renamed minPasswordStrength, and turns off the MTA inbound throttles: v1.1 delivers local iTIP through them, so after 25 invitations to one user they stall. Prompt: A new version of stalwart is out. Please check if anything needs to be changed in the setup script. Please run the caldav server tester and see if htnts (sic) needs to be changed Followup-Prompt: Perhaps we should do with stalwart like we've done with bedework and zimbra - keep the old version hints but rename it Followup-Prompt: search.text.comp-type-optional failing is not really a regression, a strictly RFC-compliant server should not support it. (...) Assisted-By: Claude Opus 5.5 Reviewed-by: Tobias Brox --- caldav/compatibility_hints.py | 43 ++++++++++++++++++- caldav/config.py | 4 ++ caldav/testing/docker.py | 2 +- ...+stalwart-versioned-profiles.deprecated.md | 1 + .../stalwart/setup_stalwart.sh | 31 ++++++++++++- tests/test_compatibility_hints.py | 27 +++++++++++- 6 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 changes/+stalwart-versioned-profiles.deprecated.md 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"}),