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

5.8 KiB

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:

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:

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:
<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>
  1. Treat HTTP 201 Created as success. Verify by listing calendars again before reporting success.
  2. 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):

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

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.