Skip to content

Read iCloud subscribed calendars from their source feed - #146

Open
cosmosified wants to merge 2 commits into
jherforth:mainfrom
cosmosified:feature/icloud-subscribed-calendar-events
Open

Read iCloud subscribed calendars from their source feed#146
cosmosified wants to merge 2 commits into
jherforth:mainfrom
cosmosified:feature/icloud-subscribed-calendar-events

Conversation

@cosmosified

@cosmosified cosmosified commented Aug 29, 2026

Copy link
Copy Markdown

Problem

A subscribed iCloud calendar — a webcal feed added to iCloud, such as a
TeamSnap team schedule — appears in the calendar picker and syncs with
success, but always imports zero events. The same calendar shows its
events correctly in Calendar.app.

Root cause

An iCloud subscription is a pointer, not a container of event resources. Its
resourcetype is <cs:subscribed/> and its events live at the URL in the
<cs:source> property (CalendarServer extension).

fetchCalendarEvents() issued a calendar-query REPORT against the collection.
Per RFC 4791 §7.8 that report targets a calendar collection holding event
resources; against a subscription it is accepted but returns an empty
multistatus. The sync therefore recorded success with 0 events and no error,
which made it look like an empty calendar rather than a bug.

parseCalendars() already intentionally lists subscriptions (they advertise
VEVENT), so they were selectable in the picker — only the fetch path was wrong.
The discovery PROPFIND also never requested <cs:source/>, so the feed URL was
never available, even though the cs namespace was already declared.

Changes

All in server/services/appleCalDAV.js:

  • Request <cs:source /> during calendar discovery.
  • parseCalendars() now reports subscribed and sourceUrl per calendar.
  • parseCollectionSource() reads subscription details from a Depth:0 PROPFIND.
  • describeCollection() probes a single collection before fetching.
  • fetchSubscriptionEvents() reads the upstream ICS feed directly.
  • fetchCalendarEvents() branches: subscriptions read their source feed,
    everything else keeps the existing REPORT path unchanged.
  • normalizeFeedUrl() rewrites webcal:// / webcals:// to https:// and
    rejects anything not http(s) afterwards.
  • Outbound CalDAV request bodies are collected into a frozen
    ICLOUD_XML_MESSAGE class so the wire format lives in one place.
    CALENDAR_QUERY is a builder (a calendar-query must carry a concrete
    time-range); the other four are constants. This is a pure extraction — the
    generated bodies were verified byte-identical to the previously inlined
    literals.

Compatibility and safety

  • Existing calendars cannot regress. The subscription probe is wrapped in
    try/catch; any failure logs a warning and falls through to the current REPORT
    path. There is a test for this.
  • Credentials are not forwarded. The feed fetch is deliberately
    unauthenticated: the source is a third-party host (TeamSnap, leagues, school
    districts), so the iCloud app-specific password is never sent to it. Tested.
  • No SSRF through <cs:source>. Non-http(s) schemes are refused, so a
    malformed or hostile source property cannot trigger a file:// read. Tested.
  • Recurrence behaviour is unchanged. icsToEvents() does not expand RRULE,
    and neither did the REPORT path. Left as-is deliberately rather than altered
    as a side effect of this fix.
  • No database migration, no schema change, no API change, and no client change.

Testing

cd server && npm test196 pass, 0 fail (184 before this branch).

Behavioural tests use a throwaway local http server rather than module mocks,
following the existing approach in homeAssistant.test.js and
outboundTls.test.js; fetchCalendarEvents() takes the collection URL as an
argument, so pointing it at 127.0.0.1 is enough to assert which requests it
makes:

  • a subscribed calendar reads its source feed and issues no REPORT
  • iCloud credentials are never sent to the feed host
  • a regular calendar still uses REPORT and fetches no feed
  • a failing probe falls back to REPORT
  • a subscription with no source URL fails loudly instead of returning []
  • a non-http(s) source is refused rather than fetched
  • the REPORT body carries a well-formed calendar-query with a time-range

Request bodies are covered too: each static body is well-formed XML,
CALENDAR_LIST still requests cs:source (dropping it is exactly what made
subscriptions look empty), and the class is frozen.

Unit tests additionally cover webcal:///webcals:// rewriting, http(s)
passthrough, rejection of file:///ftp:///empty, subscription detection, and
parseCalendars() exposing subscribed + sourceUrl.

Verified end to end against a real iCloud-subscribed TeamSnap calendar using
docker-compose-dev.yml: the same source went from success / 0 events to
success / 59 events in 379ms.

Notes for review

  • Backend-only, so the light/dark theme and multi-screen-size checks in
    CONTRIBUTING.md do not apply — there are no client changes.
  • Applies cleanly on top of 17d2220; a test-merge reports no conflicts.

Chris Morton added 2 commits August 28, 2026 11:08
Subscribed iCloud calendars (webcal feeds added to iCloud) are pointers,
not event containers: the resourcetype is <cs:subscribed/> and the events
live at the <cs:source> URL. A calendar-query REPORT against one succeeds
but returns an empty multistatus, so a sync reported success with zero
events while Calendar.app showed the events fine.

- Request <cs:source/> during calendar discovery
- Expose subscribed/sourceUrl from parseCalendars
- Probe the collection with a Depth:0 PROPFIND in fetchCalendarEvents and
  read the upstream ICS feed when it is a subscription
- Normalize webcal:// -> https:// and reject non-http(s) source URLs
- Fetch the feed unauthenticated so the iCloud app password is never sent
  to the third-party feed host

Probe failures fall back to the existing REPORT path, so calendars that
already worked cannot regress.
The first pass only unit-tested the parsing helpers, which would not have
caught the actual bug: fetchCalendarEvents issuing a calendar-query REPORT
against a subscription. These tests pin the behaviour instead.

Regression tests (local http server, matching the approach already used by
homeAssistant.test.js and outboundTls.test.js, since fetchCalendarEvents
takes the collection URL as an argument):

- a subscribed calendar reads its source feed and issues NO REPORT
- iCloud credentials are never sent to the third-party feed host
- a regular calendar still uses REPORT and fetches no feed
- a failing subscription probe falls back to REPORT
- a subscription with no source URL fails loudly instead of returning []
- a non-http(s) source is refused rather than fetched
- the REPORT body carries a well-formed calendar-query with a time-range

Also move every outbound CalDAV body into a frozen ICLOUD_XML_MESSAGE class
with static fields, so the wire format lives in one place instead of being
scattered through the request functions. CALENDAR_QUERY is a builder because
a calendar-query must carry a concrete time-range; the other four are
constants. Verified byte-identical to the previously inlined bodies.

Covered by tests asserting each body is well-formed XML, that CALENDAR_LIST
still requests cs:source (dropping it is what made subscriptions look empty),
and that the class is frozen.

196 server tests pass.
@cosmosified cosmosified changed the title Feature/icloud subscribed calendar events Read iCloud subscribed calendars from their source feed Aug 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

1 participant