Files
hermes-skills/skills/email/email-workflows/references/imap-email-triage-apple-icloud-caldav-bill-calendar.md

114 lines
5.8 KiB
Markdown

# 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.