Files
webmail-client/plugins/webmail_plugin_calendar/webmail_plugin_calendar/caldav.py
T
lukasandClaude Opus 4.8 a27755a4d3 Calendar: CalDAV two-way sync (iCloud/generic) + all-day timegrid fix
CalDAV account connect (Settings → Calendars) with RFC 6764 discovery,
per-calendar vaulted credentials, and full two-way sync verified live
against NextCloud:

- caldav.py: discovery, calendar-query REPORT (pull), PUT/DELETE with
  quoted ETags (If-Match/If-None-Match), iCloud partition-redirect
  handling, ICS build/parse; clear error codes (auth/private-host/
  unreachable) surfaced to the connect UI.
- sync.py: ctag-gated pull + push of local changes; dirty rows protected
  from being clobbered; 15-min cron + immediate enqueue on every
  create/edit/delete so changes reach the server in ~1s.
- Dirty tracking via a BEFORE INSERT/UPDATE trigger (migration 0007),
  suppressed during sync with a txn-local GUC; deletes tombstone then
  DELETE server-side. Writability now driven by server privileges.
- migration 0006: event uniqueness NULLS NOT DISTINCT — fixes duplicate
  events on every full re-pull (NULL recurrence_id never matched ON CONFLICT).
- 5 CalDAV parsing unit tests.

Calendar UI (page.js): all-day events now render in Day/3-days/Week views
(were month-only). They share the timed-event column layout so overlapping
events sit side-by-side, fill the day column vertically with a sticky label,
and are matched by start day (fixes UTC-midnight events spilling into the
next local day).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 10:27:21 +02:00

391 lines
15 KiB
Python

"""Minimal CalDAV client for two-way sync (SPEC §A.3).
Speaks CalDAV over the SDK's SSRF-guarded HTTP: RFC 6764 discovery
(current-user-principal → calendar-home-set → calendar collections), an RFC 4791
``calendar-query`` REPORT to pull events, and PUT/DELETE (with ETags) to push local
changes. iCloud redirects to a per-user partition host, so requests follow a few
redirects (each still SSRF-validated by the underlying guard).
The pure helpers — ``parse_multistatus_calendars``, ``parse_report``,
``vevent_to_fields``, ``build_ics`` — take/return plain data and are unit-tested
without a network.
"""
from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from urllib.parse import urljoin
from defusedxml import ElementTree as SafeET
logger = logging.getLogger("plg.calendar.caldav")
# Well-known base URLs for providers that don't need a user-supplied server URL.
PROVIDER_BASE = {"icloud": "https://caldav.icloud.com/"}
_DAV = "{DAV:}"
_CAL = "{urn:ietf:params:xml:ns:caldav}"
_CS = "{http://calendarserver.org/ns/}"
_ICAL = "{http://apple.com/ns/ical/}"
_PRINCIPAL_BODY = (
b'<?xml version="1.0" encoding="utf-8"?>'
b'<d:propfind xmlns:d="DAV:"><d:prop><d:current-user-principal/></d:prop></d:propfind>'
)
_HOME_BODY = (
b'<?xml version="1.0" encoding="utf-8"?>'
b'<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
b"<d:prop><c:calendar-home-set/></d:prop></d:propfind>"
)
_CAL_LIST_BODY = (
b'<?xml version="1.0" encoding="utf-8"?>'
b'<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"'
b' xmlns:cs="http://calendarserver.org/ns/" xmlns:ical="http://apple.com/ns/ical/">'
b"<d:prop><d:resourcetype/><d:displayname/><ical:calendar-color/><cs:getctag/>"
b"<d:current-user-privilege-set/><c:supported-calendar-component-set/></d:prop></d:propfind>"
)
_CTAG_BODY = (
b'<?xml version="1.0" encoding="utf-8"?>'
b'<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/">'
b"<d:prop><cs:getctag/></d:prop></d:propfind>"
)
_EVENTS_QUERY = (
b'<?xml version="1.0" encoding="utf-8"?>'
b'<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
b"<d:prop><d:getetag/><c:calendar-data/></d:prop>"
b'<c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT"/>'
b"</c:comp-filter></c:filter></c:calendar-query>"
)
class CalDavError(Exception):
"""A CalDAV request failed (auth, network, or unexpected status)."""
@dataclass
class DavCalendar:
url: str
name: str
color: str = "#3b82f6"
ctag: str = ""
read_only: bool = False
@dataclass
class DavEvent:
href: str
etag: str
ics: str
# --- pure parsing/building (unit-tested) -------------------------------------
def _text(el: Any, path: str) -> str:
found = el.find(path)
return (found.text or "").strip() if found is not None and found.text else ""
def _propstat_ok(response: Any) -> Any | None:
"""Return the ``<prop>`` element of the HTTP-200 propstat in a response, or None."""
for propstat in response.findall(f"{_DAV}propstat"):
status = _text(propstat, f"{_DAV}status")
if " 200 " in status:
return propstat.find(f"{_DAV}prop")
# Some servers omit the status; fall back to the first prop.
ps = response.find(f"{_DAV}propstat")
return ps.find(f"{_DAV}prop") if ps is not None else None
def parse_href(xml_text: str, prop_tag: str) -> str | None:
"""First ``<prop_tag><d:href>`` value in a multistatus (discovery steps)."""
root = SafeET.fromstring(xml_text)
for response in root.iter(f"{_DAV}response"):
prop = _propstat_ok(response)
if prop is None:
continue
target = prop.find(prop_tag)
if target is not None:
href = target.find(f"{_DAV}href")
if href is not None and href.text:
return href.text.strip()
return None
def parse_multistatus_calendars(xml_text: str, base_url: str) -> list[DavCalendar]:
"""Calendar collections from a Depth-1 PROPFIND on the calendar-home-set."""
root = SafeET.fromstring(xml_text)
out: list[DavCalendar] = []
for response in root.iter(f"{_DAV}response"):
prop = _propstat_ok(response)
href_el = response.find(f"{_DAV}href")
if prop is None or href_el is None or not href_el.text:
continue
resourcetype = prop.find(f"{_DAV}resourcetype")
if resourcetype is None or resourcetype.find(f"{_CAL}calendar") is None:
continue # not a calendar collection (e.g. the home node itself, inbox/outbox)
# Only calendars that hold events.
comps = prop.find(f"{_CAL}supported-calendar-component-set")
if comps is not None:
names = {c.get("name") for c in comps.findall(f"{_CAL}comp")}
if names and "VEVENT" not in names:
continue
name = _text(prop, f"{_DAV}displayname") or "Calendar"
color = (_text(prop, f"{_ICAL}calendar-color") or "#3b82f6")[:7]
ctag = _text(prop, f"{_CS}getctag")
# read-only unless the privilege set grants write.
read_only = True
privset = prop.find(f"{_DAV}current-user-privilege-set")
if privset is None:
read_only = False # server didn't report; assume writable
else:
for priv in privset.iter(f"{_DAV}privilege"):
if priv.find(f"{_DAV}write") is not None or priv.find(f"{_DAV}all") is not None:
read_only = False
break
out.append(
DavCalendar(
url=urljoin(base_url, href_el.text.strip()),
name=name,
color=color or "#3b82f6",
ctag=ctag,
read_only=read_only,
)
)
return out
def parse_report(xml_text: str, base_url: str) -> list[DavEvent]:
"""(href, etag, calendar-data) tuples from a calendar-query/multiget REPORT."""
root = SafeET.fromstring(xml_text)
out: list[DavEvent] = []
for response in root.iter(f"{_DAV}response"):
href_el = response.find(f"{_DAV}href")
prop = _propstat_ok(response)
if href_el is None or not href_el.text or prop is None:
continue
cdata = _text(prop, f"{_CAL}calendar-data")
if not cdata:
continue
out.append(
DavEvent(
href=urljoin(base_url, href_el.text.strip()),
etag=_text(prop, f"{_DAV}getetag").strip('"'),
ics=cdata,
)
)
return out
def _aware(dt: Any) -> datetime:
if not isinstance(dt, datetime):
dt = datetime(dt.year, dt.month, dt.day)
return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
def vevent_to_fields(ics_text: str) -> dict[str, Any] | None:
"""Map the first VEVENT in an ICS resource to plg_calendar_events columns."""
from icalendar import Calendar as ICalendar
try:
cal = ICalendar.from_ical(ics_text)
except Exception: # noqa: BLE001 - malformed resource; skip it
return None
comp = next((c for c in cal.walk("VEVENT")), None)
if comp is None:
return None
start = comp.get("dtstart")
if start is None:
return None
all_day = not isinstance(start.dt, datetime)
dtstart = _aware(start.dt)
end = comp.get("dtend")
dtend = _aware(end.dt) if end is not None else None
rrule = comp.get("rrule")
return {
"ical_uid": str(comp.get("uid") or f"{uuid.uuid4()}@caldav"),
"sequence": int(comp.get("sequence") or 0),
"summary": str(comp.get("summary") or ""),
"description": str(comp.get("description") or ""),
"location": str(comp.get("location") or ""),
"dtstart": dtstart,
"dtend": dtend,
"all_day": all_day,
"rrule": rrule.to_ical().decode() if rrule is not None else None,
"status": str(comp.get("status") or "confirmed").lower(),
"raw_ical": ics_text,
}
def build_ics(event: dict[str, Any]) -> str:
"""A single-VEVENT VCALENDAR for PUTting a locally created/edited event."""
from icalendar import Calendar as ICalendar
from icalendar import Event as IEvent
from icalendar.prop import vRecur
cal = ICalendar()
cal.add("prodid", "-//Ultimo//Calendar//EN")
cal.add("version", "2.0")
ev = IEvent()
ev.add("uid", event["ical_uid"])
ev.add("sequence", int(event.get("sequence") or 0))
ev.add("summary", event.get("summary") or "")
if event.get("description"):
ev.add("description", event["description"])
if event.get("location"):
ev.add("location", event["location"])
start, end = event["dtstart"], event.get("dtend")
if event.get("all_day"):
ev.add("dtstart", start.date() if isinstance(start, datetime) else start)
if end is not None:
ev.add("dtend", end.date() if isinstance(end, datetime) else end)
else:
ev.add("dtstart", start)
if end is not None:
ev.add("dtend", end)
if event.get("rrule"):
ev.add("rrule", vRecur.from_ical(str(event["rrule"])))
for ex in event.get("exdates") or []:
ev.add("exdate", ex.date() if event.get("all_day") and isinstance(ex, datetime) else ex)
ev.add("dtstamp", datetime.now(UTC))
cal.add_component(ev)
return cal.to_ical().decode()
# --- network operations ------------------------------------------------------
async def _request(
http: Any,
method: str,
url: str,
*,
creds: tuple[str, str],
depth: str | None = None,
body: bytes | None = None,
extra_headers: dict[str, str] | None = None,
) -> Any:
"""One CalDAV request, following up to 5 redirects (iCloud partition hosts).
Each hop goes through ``sdk.http`` (SSRF-guarded). Returns the final response."""
headers: dict[str, str] = {"User-Agent": "Ultimo-Webmail/1.0"}
if body is not None and method in ("PROPFIND", "REPORT"):
headers["Content-Type"] = "application/xml; charset=utf-8"
if depth is not None:
headers["Depth"] = depth
if extra_headers:
headers.update(extra_headers)
current = url
resp = None
for _ in range(6):
try:
resp = await http.request(
method, current, headers=headers, content=body, auth=creds
)
except Exception as exc: # transport error or SSRF guard rejection
name = type(exc).__name__.lower()
msg = str(exc).lower()
if "ssrf" in name or "public" in msg or "private" in msg or "not allowed" in msg:
raise CalDavError("private_host") from exc
raise CalDavError("unreachable") from exc
if resp.status in (301, 302, 307, 308):
location = resp.headers.get("location")
if not location:
break
current = urljoin(current, location)
continue
return resp
return resp
async def discover_calendars(
http: Any, base_url: str, creds: tuple[str, str]
) -> list[DavCalendar]:
"""RFC 6764 discovery: principal → calendar-home-set → calendar collections."""
resp = await _request(http, "PROPFIND", base_url, creds=creds, depth="0", body=_PRINCIPAL_BODY)
if resp is None or resp.status in (401, 403):
raise CalDavError("auth_failed")
if resp.status not in (207, 200):
raise CalDavError(f"principal lookup failed ({resp.status})")
principal = parse_href(resp.text, f"{_DAV}current-user-principal") or base_url
principal_url = urljoin(base_url, principal)
resp = await _request(
http, "PROPFIND", principal_url, creds=creds, depth="0", body=_HOME_BODY
)
if resp is None or resp.status not in (207, 200):
raise CalDavError("home-set lookup failed")
home = parse_href(resp.text, f"{_CAL}calendar-home-set")
home_url = urljoin(principal_url, home) if home else principal_url
resp = await _request(http, "PROPFIND", home_url, creds=creds, depth="1", body=_CAL_LIST_BODY)
if resp is None or resp.status not in (207, 200):
raise CalDavError("calendar list failed")
return parse_multistatus_calendars(resp.text, home_url)
async def get_ctag(http: Any, calendar_url: str, creds: tuple[str, str]) -> str:
resp = await _request(http, "PROPFIND", calendar_url, creds=creds, depth="0", body=_CTAG_BODY)
if resp is None or resp.status not in (207, 200):
return ""
root = SafeET.fromstring(resp.text)
for response in root.iter(f"{_DAV}response"):
prop = _propstat_ok(response)
if prop is not None:
return _text(prop, f"{_CS}getctag")
return ""
async def pull_events(http: Any, calendar_url: str, creds: tuple[str, str]) -> list[DavEvent]:
resp = await _request(
http, "REPORT", calendar_url, creds=creds, depth="1", body=_EVENTS_QUERY
)
if resp is None or resp.status not in (207, 200):
raise CalDavError(f"event report failed ({resp.status if resp else 'no response'})")
return parse_report(resp.text, calendar_url)
def _if_match(etag: str) -> str:
"""Format a stored (quote-stripped) ETag as an HTTP entity-tag for If-Match.
RFC 7232 requires the value be a quoted-string; Sabre/NextCloud reject a bare
token with 412. Weak validators (``W/"…"``) and already-quoted values pass through."""
etag = etag.strip()
if etag.startswith("W/") or (etag.startswith('"') and etag.endswith('"')):
return etag
return f'"{etag}"'
async def put_event(
http: Any, url: str, ics: str, etag: str | None, creds: tuple[str, str]
) -> str:
"""Create/update an event resource; return the server's new ETag (may be empty)."""
extra = {"Content-Type": "text/calendar; charset=utf-8"}
if etag:
extra["If-Match"] = _if_match(etag) # update: only if unchanged since we last saw it
else:
extra["If-None-Match"] = "*" # create: only if it doesn't already exist
resp = await _request(http, "PUT", url, creds=creds, body=ics.encode(), extra_headers=extra)
if resp is None or resp.status not in (200, 201, 204):
raise CalDavError(f"PUT failed ({resp.status if resp else 'no response'})")
return (resp.headers.get("etag") or "").strip('"')
async def delete_event(http: Any, url: str, etag: str | None, creds: tuple[str, str]) -> None:
extra = {"If-Match": _if_match(etag)} if etag else {}
resp = await _request(http, "DELETE", url, creds=creds, extra_headers=extra)
# 404/412 (already gone / changed) are treated as done — the local intent (remove) holds.
if resp is None or resp.status not in (200, 204, 404, 412):
raise CalDavError(f"DELETE failed ({resp.status})")
def event_href(calendar_url: str, ical_uid: str) -> str:
"""Deterministic resource path for a locally created event (UID.ics under the collection)."""
base = calendar_url if calendar_url.endswith("/") else calendar_url + "/"
safe = "".join(ch for ch in ical_uid if ch.isalnum() or ch in "-_.@") or uuid.uuid4().hex
return urljoin(base, f"{safe}.ics")