Files
webmail-client/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ical_resource.py
T
lukasandClaude Opus 5.5 0e3793a4d6 App passwords; CalDAV / CardDAV server for phones, Thunderbird, macOS
App passwords (SPEC App. D §D.5): one generated password per app, shown once,
revocable on its own, revoked on a password reset; never accepted by the web
sign-in. Settings → Security → App passwords (migration 0024).

CalDAV / CardDAV server (SPEC Appendix E), built in: /dav/ with
/.well-known/caldav|carddav, HTTP Basic with app passwords only (cached checks,
throttled failures). The calendar and contacts plugins serve their own tables as
collections (Plugin(dav=...)):
- calendars as iCalendar: series with overrides, time zones, all-day, attendees
  and RSVP, alarms <-> reminders; MKCALENDAR / PROPPATCH / DELETE of calendars;
  subscriptions read-only; writes to upstream-synced calendars flow on upstream;
- address books as vCard (3.0 served, 3.0/4.0 read), groups as CATEGORIES;
- what a client stores is served back verbatim until the web app edits it;
- sync tokens / CTags from change logs filled by triggers (calendar0008,
  contacts0002), so every write path counts; trimmed nightly after 90 days.
REPORTs: calendar-query (time range), calendar-/addressbook-multiget,
addressbook-query, sync-collection. nginx and the Vite proxy route /dav/.
Settings shows the server and user name to enter; install guide §5.4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:49:04 +02:00

311 lines
12 KiB
Python

"""One event series as a CalDAV resource: iCalendar text <-> event rows
(SPEC App. E.3).
A resource holds every row of one UID: the series (no ``recurrence_id``) and
its moved or cancelled occurrences. :func:`build` writes the rows as a
VCALENDAR — time zones, all-day dates, recurrence, overrides, organizer and
attendees, alarms from the reminders, the event colour and video link (as X-
properties a client keeps); :func:`parse` reads what a client PUT back into row
fields. :func:`fingerprint` summarises the modelled fields, so a client's own
text can be served back verbatim for as long as they are unchanged.
"""
from __future__ import annotations
import hashlib
import json
from collections.abc import Mapping, Sequence
from datetime import UTC, date, datetime, timedelta
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
Row = Mapping[str, Any]
# The columns a resource is made of (the fingerprint covers exactly these).
FIELDS = (
"ical_uid",
"recurrence_id",
"sequence",
"summary",
"description",
"location",
"dtstart",
"dtend",
"all_day",
"tz",
"rrule",
"exdates",
"status",
"organizer",
"attendees",
"my_partstat",
"reminders",
"color",
"show_as",
"conference_url",
)
_PARTSTATS = {"needs-action", "accepted", "declined", "tentative", "delegated"}
class InvalidResource(ValueError):
"""Not a calendar object we can store (no VEVENT, several UIDs, …)."""
def _zone(name: str | None) -> ZoneInfo:
try:
return ZoneInfo(name or "UTC")
except (ZoneInfoNotFoundError, ValueError):
return ZoneInfo("UTC")
def _norm(value: Any) -> Any:
if isinstance(value, datetime):
return value.astimezone(UTC).isoformat()
if isinstance(value, date):
return value.isoformat()
if isinstance(value, list | tuple):
return [_norm(v) for v in value]
if isinstance(value, dict):
return {k: _norm(v) for k, v in sorted(value.items())}
return value
def fingerprint(rows: Sequence[Row]) -> str:
"""A digest of the modelled fields of a resource's rows."""
items = sorted(
({f: _norm(r.get(f)) for f in FIELDS} for r in rows),
key=lambda d: str(d["recurrence_id"] or ""),
)
return hashlib.sha1(json.dumps(items, sort_keys=True, default=str).encode()).hexdigest()
# ---- rows -> iCalendar ----------------------------------------------------------------
def _when(value: datetime, all_day: bool, tz: str) -> date | datetime:
if all_day:
return value.astimezone(UTC).date()
zone = _zone(tz)
return value.astimezone(zone) if tz and tz != "UTC" else value.astimezone(UTC)
def _address(prop_name: str, person: Mapping[str, Any], partstat: str | None = None) -> Any:
from icalendar import vCalAddress
addr = vCalAddress(f"mailto:{person.get('email', '')}")
if person.get("name"):
addr.params["CN"] = person["name"]
if prop_name == "attendee":
addr.params["PARTSTAT"] = (partstat or person.get("partstat") or "needs-action").upper()
addr.params["ROLE"] = "REQ-PARTICIPANT"
return addr
def build(rows: Sequence[Row], my_addresses: set[str]) -> str:
"""The VCALENDAR for one resource's rows (series first, then overrides)."""
from icalendar import Alarm, Calendar, Event
from icalendar.prop import vRecur
cal = Calendar()
cal.add("prodid", "-//Ultimo//Calendar//EN")
cal.add("version", "2.0")
cal.add("calscale", "GREGORIAN")
ordered = sorted(
rows,
key=lambda r: (
r.get("recurrence_id") is not None,
r.get("recurrence_id") or datetime.min.replace(tzinfo=UTC),
),
)
for r in ordered:
ev = Event()
all_day = bool(r.get("all_day"))
tz = str(r.get("tz") or "UTC")
ev.add("uid", r["ical_uid"])
stamp = r.get("modified_at") or datetime(2000, 1, 1, tzinfo=UTC)
ev.add("dtstamp", stamp.astimezone(UTC))
ev.add("last-modified", stamp.astimezone(UTC))
ev.add("sequence", int(r.get("sequence") or 0))
ev.add("summary", r.get("summary") or "")
if r.get("description"):
ev.add("description", r["description"])
if r.get("location"):
ev.add("location", r["location"])
ev.add("dtstart", _when(r["dtstart"], all_day, tz))
if r.get("dtend") is not None:
ev.add("dtend", _when(r["dtend"], all_day, tz))
if r.get("recurrence_id") is not None:
ev.add("recurrence-id", _when(r["recurrence_id"], all_day, tz))
if r.get("rrule") and r.get("recurrence_id") is None:
ev.add("rrule", vRecur.from_ical(str(r["rrule"])))
for ex in r.get("exdates") or []:
ev.add("exdate", _when(ex, all_day, tz))
status = str(r.get("status") or "confirmed").upper()
if status in ("CONFIRMED", "TENTATIVE", "CANCELLED"):
ev.add("status", status)
ev.add("transp", "TRANSPARENT" if r.get("show_as") == "free" else "OPAQUE")
organizer = r.get("organizer")
if isinstance(organizer, dict) and organizer.get("email"):
ev.add("organizer", _address("organizer", organizer))
for att in r.get("attendees") or []:
if not isinstance(att, dict) or not att.get("email"):
continue
mine = str(att["email"]).lower() in my_addresses
ev.add("attendee", _address("attendee", att, r.get("my_partstat") if mine else None))
if r.get("conference_url"):
ev.add("x-ultimo-conference", r["conference_url"])
if r.get("color"):
ev.add("x-ultimo-color", r["color"])
for minutes in sorted(set(r.get("reminders") or [])):
alarm = Alarm()
alarm.add("action", "DISPLAY")
alarm.add("description", "Reminder")
alarm.add("trigger", timedelta(minutes=-int(minutes)))
ev.add_component(alarm)
cal.add_component(ev)
cal.add_missing_timezones()
return str(cal.to_ical().decode())
# ---- iCalendar -> rows ----------------------------------------------------------------
def _aware(value: date | datetime, default_tz: str) -> tuple[datetime, bool, str]:
"""(instant, all-day, tz name) of a DTSTART-like value."""
if not isinstance(value, datetime):
return datetime(value.year, value.month, value.day, tzinfo=UTC), True, "UTC"
if value.tzinfo is None: # floating: the user's own zone
zone = _zone(default_tz)
return value.replace(tzinfo=zone), False, default_tz or "UTC"
name = getattr(value.tzinfo, "key", None) or getattr(value.tzinfo, "zone", None) or ""
try:
ZoneInfo(str(name))
except (ZoneInfoNotFoundError, ValueError):
name = "UTC"
return value, False, str(name) or "UTC"
def _person(prop: Any) -> dict[str, str] | None:
if prop is None:
return None
email = str(prop)
if email.lower().startswith("mailto:"):
email = email[7:]
email = email.strip().lower()
if not email:
return None
name = str(prop.params.get("CN", "")).strip() if hasattr(prop, "params") else ""
return {"email": email, "name": "" if name.lower() == email else name}
def _list(value: Any) -> list[Any]:
if value is None:
return []
return value if isinstance(value, list) else [value]
def _reminders(comp: Any, start: datetime) -> list[int]:
out: set[int] = set()
for alarm in comp.walk("VALARM"):
trigger = alarm.get("trigger")
if trigger is None:
continue
value = trigger.dt
if isinstance(value, timedelta):
if str(trigger.params.get("RELATED", "START")).upper() != "START":
continue
minutes = int(-value.total_seconds() // 60)
elif isinstance(value, datetime):
minutes = int((start - value.astimezone(UTC)).total_seconds() // 60)
else:
continue
if 0 <= minutes <= 60 * 24 * 28:
out.add(minutes)
return sorted(out)
def parse(ics: str, my_addresses: set[str], default_tz: str) -> list[dict[str, Any]]:
"""The rows a client's VCALENDAR describes (one UID). Raises InvalidResource."""
from icalendar import Calendar
try:
cal = Calendar.from_ical(ics)
except Exception as err: # noqa: BLE001 - any parse failure: not valid calendar data
raise InvalidResource(str(err)) from err
events = list(cal.walk("VEVENT"))
if not events:
raise InvalidResource("no VEVENT")
uids = {str(e.get("uid") or "") for e in events}
if len(uids) != 1 or "" in uids:
raise InvalidResource("one UID per resource")
rows: list[dict[str, Any]] = []
for comp in events:
start_prop = comp.get("dtstart")
if start_prop is None:
raise InvalidResource("VEVENT without DTSTART")
dtstart, all_day, tz = _aware(start_prop.dt, default_tz)
end_prop = comp.get("dtend")
if end_prop is not None:
dtend = _aware(end_prop.dt, tz)[0]
elif comp.get("duration") is not None:
dtend = dtstart + comp.get("duration").dt
else:
dtend = dtstart + (timedelta(days=1) if all_day else timedelta(0))
rid_prop = comp.get("recurrence-id")
rrule = comp.get("rrule")
exdates: list[datetime] = []
for ex in _list(comp.get("exdate")):
for d in getattr(ex, "dts", []):
exdates.append(_aware(d.dt, tz)[0])
organizer = _person(comp.get("organizer"))
attendees = []
my_partstat = None
for prop in _list(comp.get("attendee")):
who = _person(prop)
if who is None:
continue
stat = str(prop.params.get("PARTSTAT", "NEEDS-ACTION")).lower()
who["partstat"] = stat if stat in _PARTSTATS else "needs-action"
if who["email"] in my_addresses:
my_partstat = who["partstat"]
attendees.append(who)
conference = ""
for key in ("x-ultimo-conference", "x-google-conference", "conference", "url"):
value = comp.get(key)
if value and str(value).startswith(("https://", "http://")):
conference = str(value)
break
color = str(comp.get("x-ultimo-color") or "") or None
status = str(comp.get("status") or "confirmed").lower()
rows.append(
{
"ical_uid": str(comp.get("uid")),
"recurrence_id": _aware(rid_prop.dt, tz)[0] if rid_prop is not None else None,
"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,
"tz": tz,
"rrule": rrule.to_ical().decode()
if rrule is not None and rid_prop is None
else None,
"exdates": exdates,
"status": status
if status in ("confirmed", "tentative", "cancelled")
else "confirmed",
"organizer": organizer,
"attendees": attendees,
"my_partstat": my_partstat,
"reminders": _reminders(comp, dtstart.astimezone(UTC)),
"color": color,
"show_as": "free"
if str(comp.get("transp") or "").upper() == "TRANSPARENT"
else "busy",
"conference_url": conference,
}
)
return rows