CalDAV: resource names containing `@` return 404 when the client percent-encodes it as `%40`; Apple Calendar on iOS shows an empty calendar

Issue Description

Had this happen when I imported my calendar from Proton Calendar via the Mac calendar app to Stalwart via CalDav.

A CalDAV resource whose filename contains a literal @ (for example an event stored as <UUID>@proton.me.ics, which is what Calendar.app on macOS names events it imports from a Proton export, since it uses the UID as the filename) is reachable only when the request path spells the @ raw. If the client percent-encodes it as %40, the server answers 404 for GET and reports HTTP/1.1 404 Not Found for that href inside a calendar-multiget REP
ORT.

The server itself is inconsistent about this: in the hrefs it returns, the account segment is encoded (/dav/cal/user%40example.com/) but the resource name is not (.../default/<UUID>@proton.me.ics). RFC 3986 treats @ and %40 in a path segment as equivalent, and Apple’s URL handling re-encodes a bare @ in a path (it is the userinfo delimiter), so iOS Calendar sends %40. The result is that every such event is 404 to the iPhone, the sync succeeds with nothing in it, and the calendar looks empty with no error shown. The Mac that wrote the events still shows them, and curl with the raw form works, so the server looks healthy from everywhere except the affected
device.

Expected Behavior

/dav/cal/user%40example.com/default/name%40example.com.ics and /dav/cal/user%40example.com/default/[email protected] resolve to the same resource, for GET, calendar-multiget and calendar-query hrefs, the same way the account segment already accepts either spelling.

Actual Behavior

PUT  .../default/[email protected]    -> 201
GET  .../default/[email protected]    -> 200
GET  .../default/repro-at-sign%40example.com.ics  -> 404

calendar-multiget with the two hrefs:

<D:href>/dav/cal/user%40example.com/default/[email protected]</D:href>   <D:status>HTTP/1.1 200 OK</D:status>
<D:href>/dav/cal/user%40example.com/default/repro-at-sign%40example.com.ics</D:href> <D:status>HTTP/1.1 404 Not Found</D:status>

Server log at INFO for the iPhone’s sync: twelve web-dav.report requests on the calendar, all result = 207, elapsed = 0ms, versus 6 to 11 ms per batch once the resources were renamed.

Reproduction Steps

H=https://mail.example.com
C=/dav/cal/user%40example.com/default/

printf 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//repro//EN\r\nBEGIN:VEVENT\r\nUID:[email protected]\r\nDTSTAMP:20260919T170000Z\r\nDTSTART;VALUE=DATE:20301231\r\nSUMMARY:repro at sign\r\nEND:VEVENT\r\nEND:VCALENDAR
\r\n' > repro.ics

curl -u [email protected] -X PUT -H 'Content-Type: text/calendar' --data-binary @repro.ics \
  "$H${C}[email protected]"            # 201

curl -u [email protected] -o /dev/null -w '%{http_code}\n' "$H${C}[email protected]"    # 200
curl -u [email protected] -o /dev/null -w '%{http_code}\n' "$H${C}repro-at-sign%40example.com.ics"  # 404

curl -u [email protected] -X REPORT -H 'Content-Type: text/xml' "$H$C" --data \
'<c:calendar-multiget xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
  <d:prop><d:getetag/></d:prop>
  <d:href>/dav/cal/user%40example.com/default/[email protected]</d:href>
  <d:href>/dav/cal/user%40example.com/default/repro-at-sign%40example.com.ics</d:href>
</c:calendar-multiget>'
# first href 200 OK, second href 404 Not Found

Real-world path: export a calendar from Proton, import the .ics into Calendar.app on macOS into a Stalwart CalDAV calendar, then add the same account on an iPhone. The Mac shows every event; the phone shows none.

Stalwart Version

v0.16.x

Installation Method

Binary (Linux)

Database Backend

RocksDB

Blob Storage

RocksDB

Search Engine

Internal

Directory Backend

Internal

Additional Context

  • Stalwart 0.16.20, RocksDB, single node, Linux (Debian 13), no reverse proxy (Stalwart terminates TLS on 443)
  • Clients: macOS 26.6.2 Calendar.app (writes the raw @, works), iOS 27 Calendar (sends %40, gets 404)
  • Nothing in the 0.16.21 or 0.16.22 release notes looks related

I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.

on

I have searched this support forum (open and closed topics) and confirm this is not a duplicate.

on

I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.

on

Confirmed against the source: this matches a real gap in how Stalwart resolves DAV URIs. In validate_uri_with_status (crates/dav/src/common/uri.rs), the account segment of the path is explicitly percent-decoded with decode_path_element before being used to look up the account:

let account = decode_path_element(account);
self.account_id_from_email(&account, false)...

but the trailing resource-name segment is taken as-is from the split iterator (resource.resource = uri_parts.next();) with no percent-decoding applied before it’s used to look up the resource by name (by_path(resource)). So a stored resource named [email protected] only matches a request path containing a literal @; a request with %40 never gets decoded to @ and therefore never matches, producing the 404 you’re seeing for GET and inside calendar-multiget/calendar-query REPORT bodies. This is exactly why the account segment “just works” either way while the item name does not — only one of the two segments goes through decoding.

This is a genuine inconsistency in the URI-handling code rather than something covered in current docs or a client misbehavior, since RFC 3986 does treat %40 and @ as equivalent in a path segment. It needs a code fix (decoding the resource segment the same way the account segment is decoded, or at minimum falling back to decode-then-match on lookup miss).

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong.

This has been fixed in the v1.0.0 branch (expected release late October)