# 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//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 Bills #FF9500FF ``` 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: - ` when amount is known, otherwise `Bill due: `. - 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.