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