Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions caldav/compatibility_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<username>/ 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"},
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions caldav/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
}


Expand Down
2 changes: 1 addition & 1 deletion caldav/testing/docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions changes/+stalwart-versioned-profiles.deprecated.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 29 additions & 2 deletions tests/docker-test-servers/stalwart/setup_stalwart.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 "{
Expand Down
27 changes: 26 additions & 1 deletion tests/test_compatibility_hints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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"}),
Expand Down
Loading