"""Nextcloud calendar ingestion over CalDAV. READ-ONLY INVARIANT: this module only ever issues CalDAV REPORT/PROPFIND reads (`Calendar.search()` and the principal/calendar discovery it needs). It never calls `save_event`, `add_event`, `Event.delete()`, `make_calendar()` or anything else that would mutate the collection, per docs/project-plan.md Phase 12 step 8 and the Phase 8 "no silent mutation" precedent. The digest can say an event needs attention; it can never move, create or delete one. Auth is a Nextcloud **app password**, not OAuth2 and not the account password — the same reasoning email_imap.py documents for Gmail App Passwords. Nextcloud issues them at Settings -> Security -> Devices & sessions -> Create new app password, and they are mandatory for CalDAV once two-factor authentication is on, because the DAV endpoints have no way to prompt for a second factor. Even without 2FA they are the right credential here: revocable on their own, scoped to this one integration, and they leave the account password out of a file on the container host. Sourcing, verified 2026-07-28: The protocol is not hand-rolled. `caldav` on PyPI (3.2.1, 2026-05-28, Python >=3.10, https://pypi.org/project/caldav/) does discovery, the calendar-query REPORT and recurrence expansion; hand-writing that XML against a real server is a bad trade. It pulls in `icalendar`, which is what actually parses the returned VEVENTs here. URL: point CALDAV_URL at Nextcloud's DAV root, `https:///remote.php/dav` — the library discovers the principal and its calendars from there. Nextcloud documents the equivalent per-user form `https:///remote.php/dav/principals/users//`; either works. `search(start=..., end=..., event=True, expand=True)` is the documented way to get a time-bounded list of events with recurrences expanded into concrete occurrences. Without `expand`, a weekly recurring event comes back once, as its original master VEVENT with an RRULE, and the digest would report a meeting on the day it was first created. caldav 2.0+ can expand client-side when the server will not, but sabre/dav servers that reject the request outright are handled by the retry below. The window is deliberately asymmetric: everything else in this component looks backwards over the digest window, but a calendar is mostly useful forwards, so this reads `lookback_hours` back (to catch what happened earlier today, and events still in progress) and CALDAV_LOOKAHEAD_HOURS forwards. """ import logging import os from datetime import date, datetime, timedelta, timezone LOG = logging.getLogger(__name__) DEFAULT_LOOKAHEAD_HOURS = 48 DEFAULT_MAX_EVENTS = 100 MAX_DESCRIPTION_CHARS = 500 def _csv_set(name): return {item.strip().lower() for item in os.environ.get(name, "").split(",") if item.strip()} def _stamp(value): if value is None: return None, False if isinstance(value, datetime): # A floating (tz-naive) DTSTART is legal iCalendar; treating it as UTC keeps # the ordering sane instead of raising on the comparison. aware = value if value.tzinfo else value.replace(tzinfo=timezone.utc) return aware.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"), False if isinstance(value, date): return value.isoformat(), True return None, False def _text(component, key, limit=None): value = component.get(key) if value is None: return None text = str(value).strip() if not text: return None return text[:limit] if limit else text def _events_from(raw, calendar_name, ical_parser): events = [] for component in ical_parser.from_ical(raw).walk("VEVENT"): start_property = component.get("dtstart") end_property = component.get("dtend") start, all_day = _stamp(getattr(start_property, "dt", None)) end, _ = _stamp(getattr(end_property, "dt", None)) if start is None: continue events.append( { "source": "caldav", "category": "calendar_event", "calendar": calendar_name, "uid": _text(component, "uid"), "summary": _text(component, "summary") or "(no title)", "start": start, "end": end, "all_day": all_day, "location": _text(component, "location"), "description": _text(component, "description", MAX_DESCRIPTION_CHARS), "status": _text(component, "status"), "recurring": component.get("rrule") is not None, } ) return events def _search(calendar, window_start, window_end): try: return calendar.search(start=window_start, end=window_end, event=True, expand=True) except Exception: # Some sabre/dav deployments reject an expand request outright. An # unexpanded window is still worth having; the only cost is that a # recurring series shows up as its master event. LOG.warning("caldav: expanded search failed, retrying unexpanded", exc_info=True) return calendar.search(start=window_start, end=window_end, event=True) def _calendar_name(calendar): try: return str(calendar.name or "") except Exception: # .name is a lazy PROPFIND; a calendar that will not name itself is still # readable, and dropping it over a missing display name would be absurd. return "" def fetch(lookback_hours): url = os.environ.get("CALDAV_URL", "").strip() username = os.environ.get("CALDAV_USERNAME", "").strip() password = os.environ.get("CALDAV_PASSWORD", "") if not (url and username and password): LOG.warning("caldav: CALDAV_URL/CALDAV_USERNAME/CALDAV_PASSWORD not all set, skipping") return [] lookahead_hours = float(os.environ.get("CALDAV_LOOKAHEAD_HOURS", DEFAULT_LOOKAHEAD_HOURS)) max_events = int(os.environ.get("CALDAV_MAX_EVENTS", DEFAULT_MAX_EVENTS)) wanted = _csv_set("CALDAV_CALENDARS") verify = os.environ.get("CALDAV_VERIFY_TLS", "true").strip().lower() == "true" if not verify: LOG.warning("caldav: CALDAV_VERIFY_TLS is false, the app password is sent over an unverified session") now = datetime.now(timezone.utc) window_start = now - timedelta(hours=float(lookback_hours)) window_end = now + timedelta(hours=lookahead_hours) events = [] try: # Imported here, not at module scope, so a missing/broken optional # dependency degrades this one source instead of the whole run. The # absolute import resolves to the PyPI `caldav` package, not to this # module, which is only ever reachable as `ingest.caldav`. import caldav as caldav_lib from icalendar import Calendar as ICalendar with caldav_lib.DAVClient( url=url, username=username, password=password, ssl_verify_cert=verify ) as client: for calendar in client.principal().calendars(): name = _calendar_name(calendar) if wanted and name.lower() not in wanted: continue try: for item in _search(calendar, window_start, window_end): events.extend(_events_from(item.data, name, ICalendar)) except Exception: LOG.warning("caldav: calendar %r could not be read, skipping", name, exc_info=True) except Exception: LOG.warning("caldav: ingestion failed, returning nothing", exc_info=True) return [] events.sort(key=lambda event: event["start"]) if len(events) > max_events: LOG.warning("caldav: %d events in the window, truncating to %d", len(events), max_events) events = events[:max_events] LOG.info("caldav: %d event(s) from -%sh to +%sh", len(events), lookback_hours, lookahead_hours) return events