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
- Verify password-file existence and permissions (
600). - Use
PROPFIND https://caldav.icloud.com/.well-known/caldavwith the iCloud username and app-specific password to discover the principal. - 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 genericcaldav.icloud.comURL. - List calendars in the calendar home.
- If the requested calendar (for example
Bills) does not exist, create it withMKCOLagainst 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-styleUser-Agent, for exampleDAVKit/4.0.3 (732); CalendarStore/4.0.3 (991); iCal/4.0.3 (1388); Mac OS X/10.6.4 (10F569). - 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>
- Treat HTTP
201 Createdas success. Verify by listing calendars again before reporting success. - If an accidental display name was used during debugging, rename the calendar with
PROPPATCHonDAV:displaynameand 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, otherwiseBill 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):
- Delete the event from iCloud via CalDAV DELETE on the event URL.
- Remove the event from the local state file (
calendar_events.json). - Add the sender domain to
USER_BLOCKED_SPAM_DOMAINSin 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.