# Apple iCloud CalDAV Bill Calendar Notes

Use this when extending email bill triage into Apple Calendar events.

## Credential pattern

Store the iCloud CalDAV credential in a protected password file, mirroring the IMAP password-file pattern:

```bash
mkdir -p /root/.config/himalaya
umask 077
nano /root/.config/himalaya/g-germainebrown-icloud-calendar.pass
chmod 600 /root/.config/himalaya/g-germainebrown-icloud-calendar.pass
```

The file should contain only the Apple app-specific password, one line, with no label, username, or quotes. Do not ask the user to paste the password into chat.

## Apple-specific pitfall

A successful login at icloud.com with the normal Apple ID password does not prove CalDAV automation will authenticate. iCloud CalDAV requires an Apple app-specific password. Apple app-specific passwords are typically 16 letters and often displayed as four hyphenated groups (`xxxx-xxxx-xxxx-xxxx`). If CalDAV returns HTTP 401, first verify the server-side password file actually contains the full app-specific password, not the normal web password or a truncated value.

Safe diagnostics:

```bash
stat -c '%a %U %G %n' /root/.config/himalaya/g-germainebrown-icloud-calendar.pass
python3 - <<'PY'
from pathlib import Path
s = Path('/root/.config/himalaya/g-germainebrown-icloud-calendar.pass').read_text()
print(len(s.strip()))
PY
```

Do not print the secret itself.

## CalDAV flow

1. Verify password-file existence and permissions (`600`).
2. Use `PROPFIND https://caldav.icloud.com/.well-known/caldav` with the iCloud username and app-specific password to discover the principal.
3. Discover calendar-home-set from the principal and use the returned partition-specific endpoint such as `https://p54-caldav.icloud.com:443/<id>/calendars/`; do not create calendars on the generic `caldav.icloud.com` URL.
4. List calendars in the calendar home.
5. If the requested calendar (for example `Bills`) does not exist, create it with `MKCOL` against the partition-specific calendar-home URL plus a new collection slug. iCloud may reject otherwise-valid creation requests unless the request uses an Apple Calendar/DAVKit-style `User-Agent`, for example `DAVKit/4.0.3 (732); CalendarStore/4.0.3 (991); iCal/4.0.3 (1388); Mac OS X/10.6.4 (10F569)`.
6. Use a request body shaped like:

```xml
<D:mkcol xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:ICAL="http://apple.com/ns/ical/">
  <D:set><D:prop>
    <D:resourcetype><D:collection/><C:calendar/></D:resourcetype>
    <D:displayname>Bills</D:displayname>
    <C:supported-calendar-component-set><C:comp name="VEVENT"/></C:supported-calendar-component-set>
    <ICAL:calendar-color>#FF9500FF</ICAL:calendar-color>
  </D:prop></D:set>
</D:mkcol>
```

7. Treat HTTP `201 Created` as success. Verify by listing calendars again before reporting success.
8. If an accidental display name was used during debugging, rename the calendar with `PROPPATCH` on `DAV:displayname` and verify the final listing.

## Event policy defaults for bills

Unless the user chooses otherwise:

- Calendar name: `Bills`.
- Create all-day event on the due date.
- Title: `Bill due: <vendor> - <amount>` when amount is known, otherwise `Bill due: <vendor>`.
- Reminders: 7 days before and 1 day before.
- Deduplicate using email Message-ID plus vendor and due date.

## Deduplication (critical!)

The dedup check must match by **vendor + due_date**, not by exact event_uid. Two different email notifications for the same bill (e.g. "Your bill is available" vs "Your automated payment is scheduled") produce different message_ids, but they refer to the same bill. If dedup only checks the event_uid (which includes message_id), duplicate events get created.

Implementation pattern (Python, in the add_event function):

```python
# BEFORE the existing event_uid in state check:
existing = state.get("created", {})
for _uid, _info in existing.items():
    if _info.get("vendor") == vendor and _info.get("due_date") == due.isoformat():
        # skip — already have an event for this vendor+due_date
        log_event({"action": "duplicate_skipped", "existing_event_uid": _uid, "vendor": vendor, "due_date": due.isoformat()})
        return
```

**Common duplicates that trigger this:**
- "Your bill is due soon" email + "You have a new online bill" email for the same payment
- "Your automated payment is scheduled" + "Your bill is available" for the same billing cycle
- Two separate notifications for the same subscription renewal from different systems

## Handling false-positive bills

If the user says a calendar event was created from a non-bill email (sales flyer, marketing, misclassified subscription):

1. Delete the event from iCloud via CalDAV DELETE on the event URL.
2. Remove the event from the local state file (`calendar_events.json`).
3. Add the sender domain to `USER_BLOCKED_SPAM_DOMAINS` in the triage script so future emails from that sender get moved to spam instead of being classified as bills.

**Key pitfall:** A root domain like `spectrum.com` in `KNOWN_LEGIT_DOMAINS` also covers its subdomains (e.g. `exchange.spectrum.com`). Subdomains sending promotional mail are not necessarily legitimate billing senders. Add the specific subdomain to `USER_BLOCKED_SPAM_DOMAINS` to override the root-domain legit designation — the triage checks blocked domains before known-legit domains.

## Deleting existing events from iCloud

To remove events that were already created in the Bills calendar, send CalDAV DELETE to the event URL stored in `calendar_events.json`:

```python
req = urllib.request.Request(
    event_url,
    method="DELETE",
    headers={"Authorization": auth_header(), "User-Agent": "DAVKit/4.0.3..."}
)
with urllib.request.urlopen(req, context=ctx, timeout=30) as resp:
    # HTTP 200, 204, or 404 (already gone) = success
```

After deletion, remove the entry from `calendar_events.json` and keep the source email subject/message ID in event notes for auditability.
