"""CalDAV operations: create, update, and cancel events.""" import logging import caldav import icalendar from .config import CaldavConfig from .email_processor import CalendarEvent log = logging.getLogger(__name__) class CalDAVHandler: def __init__(self, config: CaldavConfig): self._config = config self._client: caldav.DAVClient | None = None self._calendar: caldav.Calendar | None = None def connect(self) -> None: """Connect to the CalDAV server and locate the calendar.""" self._client = caldav.DAVClient( url=self._config.url, username=self._config.username, password=self._config.password, ) self._calendar = caldav.Calendar(client=self._client, url=self._config.url) log.info("Connected to CalDAV server: %s", self._config.url) def handle_event(self, event: CalendarEvent) -> None: """Dispatch a calendar event based on its METHOD.""" try: if event.method == "REQUEST" or event.method == "PUBLISH": self._handle_request(event) elif event.method == "REPLY": self._handle_reply(event) elif event.method == "CANCEL": self._handle_cancel(event) else: log.warning("Unknown METHOD %s for UID %s, treating as REQUEST", event.method, event.uid) self._handle_request(event) except Exception: log.error("CalDAV error processing UID %s (method=%s)", event.uid, event.method, exc_info=True) def _find_event(self, uid: str) -> caldav.CalendarObjectResource | None: """Search the calendar for an event by UID.""" try: return self._calendar.event_by_uid(uid) except caldav.error.NotFoundError: return None except Exception: log.debug("Error searching for UID %s", uid, exc_info=True) return None def _handle_request(self, event: CalendarEvent) -> None: """Handle REQUEST/PUBLISH: create or update an event.""" existing = self._find_event(event.uid) cal = icalendar.Calendar() cal.add("PRODID", "-//imap-idle-caldav//EN") cal.add("VERSION", "2.0") cal.add_component(event.vevent) ical_data = cal.to_ical().decode("utf-8") if existing: existing.data = ical_data existing.save() log.info("Updated event UID %s", event.uid) else: self._calendar.save_event(ical_data) log.info("Created event UID %s", event.uid) def _handle_reply(self, event: CalendarEvent) -> None: """Handle REPLY: update attendee PARTSTAT on an existing event.""" existing = self._find_event(event.uid) if not existing: log.warning("REPLY for unknown UID %s, skipping", event.uid) return existing_cal = icalendar.Calendar.from_ical(existing.data) reply_attendees = event.vevent.get("ATTENDEE") if reply_attendees is None: log.warning("REPLY with no ATTENDEE for UID %s", event.uid) return # Normalize to list if not isinstance(reply_attendees, list): reply_attendees = [reply_attendees] # Build a map of reply attendee addresses to their PARTSTAT reply_map: dict[str, str] = {} for att in reply_attendees: addr = str(att).lower() partstat = att.params.get("PARTSTAT", "NEEDS-ACTION") reply_map[addr] = str(partstat) updated = False for component in existing_cal.walk(): if component.name != "VEVENT": continue existing_attendees = component.get("ATTENDEE") if existing_attendees is None: continue if not isinstance(existing_attendees, list): existing_attendees = [existing_attendees] for att in existing_attendees: addr = str(att).lower() if addr in reply_map: att.params["PARTSTAT"] = reply_map[addr] updated = True log.info("Updated PARTSTAT for %s to %s on UID %s", addr, reply_map[addr], event.uid) if updated: existing.data = existing_cal.to_ical().decode("utf-8") existing.save() else: log.warning("REPLY attendee not found in existing event UID %s", event.uid) def _handle_cancel(self, event: CalendarEvent) -> None: """Handle CANCEL: mark event as CANCELLED.""" existing = self._find_event(event.uid) if not existing: log.warning("CANCEL for unknown UID %s, skipping", event.uid) return existing_cal = icalendar.Calendar.from_ical(existing.data) for component in existing_cal.walk(): if component.name == "VEVENT": component["STATUS"] = "CANCELLED" existing.data = existing_cal.to_ical().decode("utf-8") existing.save() log.info("Cancelled event UID %s", event.uid)