Ok, I’ve digged a little bit deeper (with the help of Claude & the currently published Git code base). It seems that we’ve to deal with 2 issues here:
CardDAV: version attribute of CARDDAV:address-data is discarded during XML parsing (RFC 6352 §8.6)
Repository: stalwartlabs/stalwart
Environment
- Stalwart 0.16.8 (originally observed on 0.16.6)
- CardDAV via
addressbook-query / addressbook-multiget
Summary
The version (and content-type) attributes of the <C:address-data> element in REPORT bodies are silently dropped. A client requesting <C:address-data content-type="text/vcard" version="3.0"/> receives vCard 4.0 if the stored card is 4.0.
Per RFC 6352 §8.6, these attributes specify the media type to be returned; if unsupported, the server MUST fail with the CARDDAV:supported-address-data precondition. Silently returning a different version is neither.
Reproduction
REPORT /dav/card/<account>/<book>/ with body:
<C:addressbook-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav">
<D:prop><C:address-data content-type="text/vcard" version="3.0"/></D:prop>
<C:filter><C:prop-filter name="UID"/></C:filter>
</C:addressbook-query>
→ All returned cards contain VERSION:4.0 (the stored version).
The same request with an HTTP header Accept: text/vcard; version=3.0 correctly returns 3.0 — so the conversion machinery exists; only the RFC-mandated XML path never reaches it.
Root cause (current main)
- In
crates/dav-proto/src/parser/property.rs, collect_properties(), the AddressData match arm destructures Token::ElementStart { name, .. }, discarding the raw field that carries the element attributes (contrast with the Comp / Prop arms, which call raw.attributes::<T>()).
collect_address_data() only processes child elements (<C:allprop>, <C:prop name=…>).
- The data model cannot carry the version either:
CardDavProperty::AddressData(Vec<CardDavPropertyName>) with CardDavPropertyName { name, group, no_value }.
- Serialization version selection in
crates/dav/src/common/propfind.rs is query.max_vcard_version.or_else(|| card.version()), where max_vcard_version is populated exclusively from the Accept header (crates/dav-proto/src/parser/header.rs).
Side findings
- The
CalendarData arm in the same function discards raw identically, so the content-type / version attributes from RFC 4791 §9.6 are equally unsupported on the CalDAV side.
Accept parsing (parser/header.rs) matches version= via split_once and VCardVersion::try_parse, which only accepts the exact strings 4.0 | 3.0 | 2.1 | 2.0 — a MIME-quoted parameter (version="3.0") is silently ignored.
- When multiple
text/vcard entries appear in Accept, the highest version wins; arguably the lowest common version would be the safer negotiation result.
Impact
Apple iOS/macOS Contacts receive vCard 4.0 regardless of what they request, leading to mojibake (UTF-8 bytes read as Latin-1, e.g. Bökelberg → Bökelberg) and silently dropped contacts. Observed: a 508-contact address book → 489 contacts on iOS, fewer on macOS Tahoe 26.5.1.
Note: even when 3.0 is negotiated via Accept, the produced 3.0 output is invalid — see the companion issue in stalwartlabs/calcard (vCard 3.0 serialization emits v4-only parameters such as JSCOMPS). Both fixes together are required for Apple-compatible output.
vCard 3.0 serialization emits vCard 4.0-only parameters and properties (JSCOMPS, RFC 9554/9555)
Repository: stalwartlabs/calcard
Empirical evidence
Verified against Stalwart 0.16.8: a contact created via JSContact (webmail) and fetched over CardDAV with Accept: text/vcard; version=3.0 returns:
VERSION:3.0
N;JSCOMPS=";1;0":Kömps;Jstest;;;;;
The same card fetched with Accept: text/vcard; version=4.0 returns the identical N line under VERSION:4.0 — i.e. the 3.0 downgrade changes the VERSION line but does not remove version-incompatible content.
Summary
JSCOMPS is defined by RFC 9555 for vCard 4.0 only; emitting it in a card declared as 3.0 produces invalid vCard 3.0 and is a plausible trigger for strict parsers (Apple Contacts on iOS/macOS) to drop cards. (The dropping itself is observed client behavior, not code-verified.)
Root cause (current main)
In src/vcard/rkyv_writer.rs — the writer used by Stalwart’s DAV path:
ArchivedVCard::write_to iterates all entries, skipping only Begin / End / Version. There is no property filtering by target version: RFC 9554/9555 and v4-only properties (CREATED, GRAMGENDER, PRONOUNS, SOCIALPROFILE, LANGUAGE, JSPROP, KIND, MEMBER, …) are written into 3.0 output unchanged.
- The parameter loop in
ArchivedVCardEntry::write_to has no is_v4 check at all: JSCOMPS, PROP-ID, DERIVED, AUTHOR, PHONETIC, etc. are written unconditionally for every target version.
- Version-dependent handling exists only for:
;ENCODING=b on binary values (non-v4),
- date formats (
format_as_vcard vs. format_as_legacy_vcard),
- the
data: URI prefix for binary values (v4 only).
Additional divergence between the two writers
The owned writer (src/vcard/writer.rs, VCardEntry::write_to) appends ;CHARSET=UTF-8 for non-ASCII text values when !is_v4. This block is entirely absent from the archived writer (rkyv_writer.rs), so the two writers produce different non-v4 output for the same card. The archived writer is the one used on the DAV serving path.
Suggested direction
- Version-aware filtering/translation in the writers: drop or translate v4-only parameters and properties when targeting ≤ 3.0.
JSCOMPS can simply be omitted — the structured-name semantics are preserved by the N property itself.
- Align the owned and archived writers so both produce identical output for the same card and target version.
Impact
Stalwart can negotiate vCard 3.0 via the Accept header (and, once fixed, via the CARDDAV:address-data version attribute — see companion issue in stalwartlabs/stalwart), but the resulting “3.0” cards still carry 4.0 syntax. Apple clients exhibit mojibake and silently dropped contacts when consuming such cards; both issues need to be fixed for Apple-compatible CardDAV output.