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>
This commit is contained in:
2026-10-09 21:49:04 +02:00
co-authored by Claude Opus 5.5
parent fa552392c8
commit 0e3793a4d6
30 changed files with 3968 additions and 4 deletions
@@ -12,7 +12,9 @@ from __future__ import annotations
from pathlib import Path
from app.plugins.sdk import JobSpec, Page, PageNav, Plugin, Slot, UiManifest
from webmail_plugin_calendar.dav import calendar_dav, prune_changes
from webmail_plugin_calendar.hook import on_message_new
from webmail_plugin_calendar.reminders import reminder_job
from webmail_plugin_calendar.router import router
from webmail_plugin_calendar.sync import caldav_sync
@@ -24,9 +26,17 @@ plugin = Plugin(
builtin=True,
# An organizer's cancellation, applied to the calendar as the email arrives.
hooks={"message.new": on_message_new},
# Refresh connected CalDAV calendars every 15 minutes (also runs on connect).
jobs=[JobSpec(func=caldav_sync, schedule="*/15 * * * *")],
jobs=[
# Refresh connected CalDAV calendars every 15 minutes (also runs on connect).
JobSpec(func=caldav_sync, schedule="*/15 * * * *"),
# Event reminders, as notifications on the user's devices.
JobSpec(func=reminder_job, schedule="* * * * *", singleton=True),
# Trim the CalDAV change log nightly (sync tokens stay valid for 90 days).
JobSpec(func=prune_changes, schedule="20 4 * * *"),
],
router=router,
# Calendars served to CalDAV clients — phones, Thunderbird (SPEC App. E).
dav=calendar_dav,
ui_manifest=UiManifest(
# An invitation received by email, above the message body (iMIP): renders
# only for a message carrying an .ics, so ordinary mail shows nothing.
@@ -0,0 +1,464 @@
"""The calendar plugin's CalDAV provider (SPEC App. E.3): the user's calendars
as CalDAV collections, each event series one resource.
Reads and writes the same rows the web app does, in the user's RLS session, so
reminders, invitations and the upstream CalDAV write-back (the ``dav_dirty``
trigger; tombstones on synced calendars) behave as for any other change.
Change numbers come from ``plg_calendar_dav_changes``, filled by triggers.
"""
from __future__ import annotations
import hashlib
import json
import uuid
from collections import defaultdict
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import text
from app.dav.provider import Changes, Collection, DavError, Resource
from app.plugins.sdk import sdk
from webmail_plugin_calendar import ical_resource
from webmail_plugin_calendar.queries import expand_range
_SDK = sdk("calendar")
_C = "{urn:ietf:params:xml:ns:caldav}"
_EVENT_COLUMNS = (
"e.id, e.calendar_id, e.ical_uid, e.recurrence_id, e.sequence, e.summary, e.description, "
"e.location, e.dtstart, e.dtend, e.all_day, e.tz, e.rrule, e.exdates, e.status, "
"e.organizer, e.attendees, e.my_partstat, e.reminders, e.color, e.show_as, "
"e.conference_url, e.modified_at, e.srv_ical, e.srv_ical_ver, "
"plg_calendar_dav_name(e) AS dav_name"
)
class CalendarDav:
kind = "calendar"
content_type = "text/calendar; charset=utf-8"
extension = ".ics"
# -- helpers ---------------------------------------------------------------------
async def _addresses(self, user_id: uuid.UUID) -> set[str]:
return {email.lower() for _, email, _ in await _SDK.accounts.of_user(user_id)}
async def _calendar(self, s: Any, collection_id: str) -> dict[str, Any]:
try:
uuid.UUID(collection_id)
except ValueError:
raise DavError(404) from None
row = (
(
await s.execute(
text("SELECT id, kind, read_only FROM plg_calendar_calendars WHERE id = :id"),
{"id": collection_id},
)
)
.mappings()
.first()
)
if row is None:
raise DavError(404)
return dict(row)
async def _rows(
self, s: Any, collection_id: str, names: list[str] | None = None
) -> dict[str, list[dict[str, Any]]]:
"""The live rows of a calendar, by resource name."""
where = "e.calendar_id = :cal AND e.dav_deleted = false"
params: dict[str, Any] = {"cal": collection_id}
if names is not None:
if not names:
return {}
where += " AND plg_calendar_dav_name(e) = ANY(:names)"
params["names"] = names
rows = (
(
await s.execute(
text(f"SELECT {_EVENT_COLUMNS} FROM plg_calendar_events e WHERE {where}"),
params,
)
)
.mappings()
.all()
)
out: dict[str, list[dict[str, Any]]] = defaultdict(list)
for r in rows:
out[r["dav_name"]].append(dict(r))
return out
@staticmethod
def _serve(
rows: list[dict[str, Any]], addresses: set[str], with_data: bool
) -> tuple[str, str | None]:
"""(ETag, data) of a resource: the client's own text while the fields are
unchanged since it was stored, else generated from the fields."""
base = next((r for r in rows if r["recurrence_id"] is None), rows[0])
fp = ical_resource.fingerprint(rows)
raw = base.get("srv_ical")
if raw and base.get("srv_ical_ver") == fp:
return "r" + hashlib.sha1(raw.encode()).hexdigest(), raw # noqa: S324
return "g" + fp, ical_resource.build(rows, addresses) if with_data else None
# -- collections -----------------------------------------------------------------
async def collections(self, user_id: uuid.UUID) -> list[Collection]:
async with _SDK.session(user_id) as s:
rows = (
(
await s.execute(
text(
"SELECT c.id, c.name, c.color, c.kind, c.read_only, "
"COALESCE((SELECT max(id) FROM plg_calendar_dav_changes d "
" WHERE d.calendar_id = c.id), 0) AS token "
"FROM plg_calendar_calendars c ORDER BY c.name"
)
)
)
.mappings()
.all()
)
return [
Collection(
id=str(r["id"]),
displayname=r["name"],
sync_token=int(r["token"]),
color=r["color"],
read_only=bool(r["read_only"]) or r["kind"] not in ("local", "caldav"),
)
for r in rows
]
async def create_collection(
self, user_id: uuid.UUID, collection_id: str, displayname: str, color: str | None
) -> None:
try:
cid = uuid.UUID(collection_id)
except ValueError:
raise DavError(403) from None
name = (displayname or "Calendar").strip()[:100]
async with _SDK.session(user_id) as s:
taken = {
str(n).lower()
for n in (
await s.execute(text("SELECT name FROM plg_calendar_calendars"))
).scalars()
}
base, n = name, 2
while name.lower() in taken: # names are unique per user
name, n = f"{base} ({n})", n + 1
await s.execute(
text(
"INSERT INTO plg_calendar_calendars (id, user_id, name, color, kind) "
"VALUES (:id, :u, :name, :color, 'local')"
),
{
"id": str(cid),
"u": str(user_id),
"name": name,
"color": _color(color) or "#3b82f6",
},
)
async def update_collection(
self,
user_id: uuid.UUID,
collection_id: str,
*,
displayname: str | None = None,
color: str | None = None,
description: str | None = None,
) -> None:
sets, params = [], {"id": collection_id}
if displayname:
sets.append("name = :name")
params["name"] = displayname.strip()[:100]
if color and _color(color):
sets.append("color = :color")
params["color"] = _color(color) or ""
if not sets:
return
async with _SDK.session(user_id) as s:
await self._calendar(s, collection_id)
try:
await s.execute(
text(f"UPDATE plg_calendar_calendars SET {', '.join(sets)} WHERE id = :id"),
params,
)
except Exception as err: # noqa: BLE001 - a duplicate name
raise DavError(409) from err
async def delete_collection(self, user_id: uuid.UUID, collection_id: str) -> None:
async with _SDK.session(user_id) as s:
cal = await self._calendar(s, collection_id)
if cal["kind"] != "local":
raise DavError(403)
count = await s.scalar(
text("SELECT count(*) FROM plg_calendar_calendars WHERE kind = 'local'")
)
if (count or 0) <= 1:
raise DavError(403) # the last calendar stays
await s.execute(
text("DELETE FROM plg_calendar_calendars WHERE id = :id"), {"id": collection_id}
)
# -- resources -------------------------------------------------------------------
async def resources(self, user_id: uuid.UUID, collection_id: str) -> list[Resource]:
return await self._get(user_id, collection_id, None, with_data=False)
async def get(
self, user_id: uuid.UUID, collection_id: str, names: list[str] | None
) -> list[Resource]:
return await self._get(user_id, collection_id, names, with_data=True)
async def _get(
self, user_id: uuid.UUID, collection_id: str, names: list[str] | None, *, with_data: bool
) -> list[Resource]:
addresses = await self._addresses(user_id) if with_data else set()
async with _SDK.session(user_id) as s:
await self._calendar(s, collection_id)
by_name = await self._rows(s, collection_id, names)
out = []
for name, rows in by_name.items():
etag, data = self._serve(rows, addresses, with_data)
out.append(Resource(name=name, etag=etag, data=data))
return out
async def put(
self,
user_id: uuid.UUID,
collection_id: str,
name: str,
data: str,
*,
if_match: str | None,
if_none_match: bool,
) -> tuple[str, bool]:
addresses = await self._addresses(user_id)
settings = await _SDK.accounts.settings_of(user_id)
try:
new_rows = ical_resource.parse(data, addresses, str(settings.get("tz") or "UTC"))
except ical_resource.InvalidResource as err:
raise DavError(400, str(err), condition=f"{_C}valid-calendar-data") from err
uid = new_rows[0]["ical_uid"]
async with _SDK.session(user_id) as s:
cal = await self._calendar(s, collection_id)
if cal["read_only"] or cal["kind"] not in ("local", "caldav"):
raise DavError(403)
existing = (await self._rows(s, collection_id, [name])).get(name)
if existing:
etag, _ = self._serve(existing, addresses, False)
if if_none_match:
raise DavError(412)
if if_match and if_match != "*" and if_match != etag:
raise DavError(412)
if existing[0]["ical_uid"] != uid:
raise DavError(400, condition=f"{_C}no-uid-conflict")
else:
if if_match:
raise DavError(412)
clash = await s.scalar(
text(
"SELECT plg_calendar_dav_name(e) FROM plg_calendar_events e "
"WHERE calendar_id = :cal AND ical_uid = :uid "
"AND dav_deleted = false LIMIT 1"
),
{"cal": collection_id, "uid": uid},
)
if clash and clash != name:
raise DavError(409, condition=f"{_C}no-uid-conflict")
keep: list[str] = []
for r in new_rows:
params = {
"u": str(user_id),
"cal": collection_id,
"uid": uid,
"rid": r["recurrence_id"],
"seq": r["sequence"],
"sum": r["summary"],
"desc": r["description"],
"loc": r["location"],
"start": r["dtstart"],
"end": r["dtend"],
"allday": r["all_day"],
"tz": r["tz"],
"rrule": r["rrule"],
"ex": r["exdates"],
"status": r["status"],
"org": json.dumps(r["organizer"]) if r["organizer"] else None,
"att": json.dumps(r["attendees"]),
"mine": r["my_partstat"],
"rem": r["reminders"],
"color": r["color"],
"showas": r["show_as"],
"conf": r["conference_url"],
"name": name,
}
row_id = await s.scalar(
text(
"INSERT INTO plg_calendar_events (user_id, calendar_id, 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, srv_name) "
"VALUES (:u, :cal, :uid, :rid, :seq, :sum, :desc, :loc, :start, :end, "
" :allday, :tz, :rrule, :ex, :status, CAST(:org AS jsonb), "
" CAST(:att AS jsonb), :mine, :rem, :color, :showas, :conf, :name) "
"ON CONFLICT (calendar_id, ical_uid, recurrence_id) DO UPDATE SET "
" sequence = EXCLUDED.sequence, summary = EXCLUDED.summary, "
" description = EXCLUDED.description, location = EXCLUDED.location, "
" dtstart = EXCLUDED.dtstart, dtend = EXCLUDED.dtend, "
" all_day = EXCLUDED.all_day, "
" tz = EXCLUDED.tz, rrule = EXCLUDED.rrule, exdates = EXCLUDED.exdates, "
" status = EXCLUDED.status, organizer = EXCLUDED.organizer, "
" attendees = EXCLUDED.attendees, my_partstat = EXCLUDED.my_partstat, "
" reminders = EXCLUDED.reminders, color = EXCLUDED.color, "
" show_as = EXCLUDED.show_as, conference_url = EXCLUDED.conference_url, "
" srv_name = EXCLUDED.srv_name, dav_deleted = false "
"RETURNING id"
),
params,
)
keep.append(str(row_id))
# Occurrences no longer in the client's version: gone.
await self._remove(s, cal, collection_id, uid, keep)
# Keep the client's text, with the fingerprint of the fields it gave.
stored = (await self._rows(s, collection_id, [name])).get(name, [])
fp = ical_resource.fingerprint(stored)
base = next((r for r in stored if r["recurrence_id"] is None), stored[0])
await s.execute(
text(
"UPDATE plg_calendar_events SET srv_ical = :raw, srv_ical_ver = :fp "
"WHERE id = :id"
),
{"raw": data, "fp": fp, "id": str(base["id"])},
)
return "r" + hashlib.sha1(data.encode()).hexdigest(), not existing # noqa: S324
async def _remove(
self, s: Any, cal: dict[str, Any], collection_id: str, uid: str, keep: list[str]
) -> None:
"""Delete a UID's rows (all, or those not in ``keep``) — tombstoned on a
calendar synced upstream, so the deletion reaches the server too."""
where = "calendar_id = :cal AND ical_uid = :uid AND dav_deleted = false"
params: dict[str, Any] = {"cal": collection_id, "uid": uid}
if keep:
where += " AND NOT (id = ANY(CAST(:keep AS uuid[])))"
params["keep"] = keep
if cal["kind"] == "caldav":
await s.execute(
text(f"UPDATE plg_calendar_events SET dav_deleted = true WHERE {where}"), params
)
else:
await s.execute(text(f"DELETE FROM plg_calendar_events WHERE {where}"), params)
async def delete(
self, user_id: uuid.UUID, collection_id: str, name: str, *, if_match: str | None
) -> None:
async with _SDK.session(user_id) as s:
cal = await self._calendar(s, collection_id)
if cal["read_only"] or cal["kind"] not in ("local", "caldav"):
raise DavError(403)
existing = (await self._rows(s, collection_id, [name])).get(name)
if not existing:
raise DavError(404)
if if_match and if_match != "*":
etag, _ = self._serve(existing, set(), False)
if if_match != etag:
raise DavError(412)
await self._remove(s, cal, collection_id, existing[0]["ical_uid"], [])
async def changes(self, user_id: uuid.UUID, collection_id: str, since: int) -> Changes | None:
async with _SDK.session(user_id) as s:
await self._calendar(s, collection_id)
rows = (
(
await s.execute(
text(
"SELECT id, name, deleted FROM plg_calendar_dav_changes "
"WHERE calendar_id = :cal AND id > :since ORDER BY id"
),
{"cal": collection_id, "since": since},
)
)
.mappings()
.all()
)
latest = await s.scalar(
text(
"SELECT COALESCE(max(id), 0) FROM plg_calendar_dav_changes "
"WHERE calendar_id = :cal"
),
{"cal": collection_id},
)
oldest = await s.scalar(
text("SELECT min(id) FROM plg_calendar_dav_changes WHERE calendar_id = :cal"),
{"cal": collection_id},
)
if oldest is not None and since < int(oldest) - 1 and since < int(latest or 0):
return None # older entries were trimmed (prune_changes): resync
if since > int(latest or 0):
return None # a token from the future (another server, a restore): resync
state: dict[str, bool] = {}
for r in rows:
if r["name"]:
state[r["name"]] = bool(r["deleted"])
return Changes(
sync_token=int(latest or 0),
changed=[n for n, gone in state.items() if not gone],
deleted=[n for n, gone in state.items() if gone],
)
async def query(
self, user_id: uuid.UUID, collection_id: str, start: datetime | None, end: datetime | None
) -> list[str] | None:
if start is None and end is None:
return None
lo = start or datetime(1900, 1, 1, tzinfo=UTC)
hi = end or datetime(2200, 1, 1, tzinfo=UTC)
async with _SDK.session(user_id) as s:
await self._calendar(s, collection_id)
by_name = await self._rows(s, collection_id, None)
names = []
for name, rows in by_name.items():
base: list[Any] = [
dict(r, exdates=r["exdates"] or []) for r in rows if r["recurrence_id"] is None
]
overrides: list[Any] = [r for r in rows if r["recurrence_id"] is not None]
for r in base + overrides:
r.setdefault("origin_message_id", None)
if expand_range(base, overrides, lo, hi):
names.append(name)
return names
def _color(value: str | None) -> str | None:
"""A "#rrggbb" colour from a client's ("#rrggbbaa" from Apple)."""
if not value:
return None
v = value.strip()
if v.startswith("#") and len(v) in (7, 9):
return v[:7].lower()
return None
calendar_dav = CalendarDav()
async def prune_changes(ctx: dict[str, Any]) -> int:
"""Nightly: drop change-log entries older than 90 days, keeping each
collection's newest (its sync token). A client holding an older token is
told to resync (``changes`` answers None)."""
async with _SDK.public_session() as pub:
result = await pub.execute(
text(
"DELETE FROM plg_calendar_dav_changes d "
"WHERE created_at < now() - interval '90 days' "
"AND id < (SELECT max(id) FROM plg_calendar_dav_changes k "
" WHERE k.calendar_id = d.calendar_id)"
)
)
return int(result.rowcount or 0) # type: ignore[attr-defined]
@@ -0,0 +1,310 @@
"""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
@@ -0,0 +1,154 @@
"""calendar: serve calendars to CalDAV clients (SPEC Appendix E.3)
* ``srv_name`` — the resource name a CalDAV client stored the event under (NULL:
derived from the UID, ``md5(uid).ics``);
* ``srv_ical`` / ``srv_ical_ver`` — the iCalendar text a client PUT, served back
verbatim while the event's fields still match (``srv_ical_ver`` is their
fingerprint when it was stored);
* ``modified_at`` — when the row last changed (LAST-MODIFIED, DTSTAMP);
* ``plg_calendar_dav_changes`` — every change to an event or a calendar, numbered:
a calendar's sync token / CTag is its latest number, and a client's token
says which changes it has seen. Filled by triggers, so every write path
counts — the web app, invitations by email, upstream CalDAV sync, DAV clients.
Revision ID: calendar0008
Revises: calendar0007
Create Date: 2026-10-09
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
from app.plugins.rls import enable_rls
revision: str = "calendar0008"
down_revision: str | None = "calendar0007"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute("ALTER TABLE plg_calendar_events ADD COLUMN srv_name text")
op.execute("ALTER TABLE plg_calendar_events ADD COLUMN srv_ical text")
op.execute("ALTER TABLE plg_calendar_events ADD COLUMN srv_ical_ver text")
op.execute(
"ALTER TABLE plg_calendar_events ADD COLUMN modified_at timestamptz NOT NULL DEFAULT now()"
)
op.execute(
"CREATE INDEX ix_plg_calendar_events_cal_uid ON plg_calendar_events (calendar_id, ical_uid)"
)
op.execute(
"""
CREATE TABLE plg_calendar_dav_changes (
id bigserial PRIMARY KEY,
user_id uuid NOT NULL,
calendar_id uuid NOT NULL,
name text, -- NULL: the calendar itself changed
deleted boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
)
"""
)
op.execute(
"CREATE INDEX ix_plg_calendar_dav_changes_cal ON plg_calendar_dav_changes (calendar_id, id)"
)
enable_rls("plg_calendar_dav_changes")
op.execute("GRANT USAGE ON SEQUENCE plg_calendar_dav_changes_id_seq TO webmail_plugin")
op.execute(
"""
CREATE OR REPLACE FUNCTION plg_calendar_dav_name(e plg_calendar_events) RETURNS text AS $$
SELECT COALESCE(e.srv_name, md5(e.ical_uid) || '.ics')
$$ LANGUAGE sql IMMUTABLE;
"""
)
# Stamp modified_at on every change of a row.
op.execute(
"""
CREATE OR REPLACE FUNCTION plg_calendar_touch() RETURNS trigger AS $$
BEGIN
NEW.modified_at := now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
"""
)
op.execute(
"""
CREATE TRIGGER plg_calendar_events_touch
BEFORE UPDATE ON plg_calendar_events
FOR EACH ROW EXECUTE FUNCTION plg_calendar_touch();
"""
)
# Log every event change under its resource name. A deleted (or tombstoned)
# row only deletes the resource when no live row of that UID is left — an
# occurrence removed from a series is a change to the series.
op.execute(
"""
CREATE OR REPLACE FUNCTION plg_calendar_log_change() RETURNS trigger AS $$
BEGIN
-- The old resource, when this row left it (deleted, tombstoned for an
-- upstream CalDAV delete, or moved to another calendar). AFTER the
-- statement, so the query sees what is left of the UID.
IF TG_OP = 'DELETE'
OR (TG_OP = 'UPDATE' AND (NEW.calendar_id <> OLD.calendar_id
OR (NEW.dav_deleted AND NOT OLD.dav_deleted))) THEN
INSERT INTO plg_calendar_dav_changes (user_id, calendar_id, name, deleted)
VALUES (OLD.user_id, OLD.calendar_id, plg_calendar_dav_name(OLD),
NOT EXISTS (SELECT 1 FROM plg_calendar_events
WHERE calendar_id = OLD.calendar_id
AND ical_uid = OLD.ical_uid AND dav_deleted = false));
END IF;
-- The resource as it is now.
IF TG_OP <> 'DELETE' AND NOT NEW.dav_deleted THEN
INSERT INTO plg_calendar_dav_changes (user_id, calendar_id, name, deleted)
VALUES (NEW.user_id, NEW.calendar_id, plg_calendar_dav_name(NEW), false);
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
"""
)
op.execute(
"""
CREATE TRIGGER plg_calendar_events_log
AFTER INSERT OR UPDATE OR DELETE ON plg_calendar_events
FOR EACH ROW EXECUTE FUNCTION plg_calendar_log_change();
"""
)
# A renamed / recoloured calendar changes its CTag too.
op.execute(
"""
CREATE OR REPLACE FUNCTION plg_calendar_log_calendar() RETURNS trigger AS $$
BEGIN
INSERT INTO plg_calendar_dav_changes (user_id, calendar_id, name, deleted)
VALUES (NEW.user_id, NEW.id, NULL, false);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
"""
)
op.execute(
"""
CREATE TRIGGER plg_calendar_calendars_log
AFTER INSERT OR UPDATE OF name, color ON plg_calendar_calendars
FOR EACH ROW EXECUTE FUNCTION plg_calendar_log_calendar();
"""
)
def downgrade() -> None:
op.execute("DROP TRIGGER IF EXISTS plg_calendar_calendars_log ON plg_calendar_calendars")
op.execute("DROP TRIGGER IF EXISTS plg_calendar_events_log ON plg_calendar_events")
op.execute("DROP TRIGGER IF EXISTS plg_calendar_events_touch ON plg_calendar_events")
op.execute("DROP FUNCTION IF EXISTS plg_calendar_log_calendar()")
op.execute("DROP FUNCTION IF EXISTS plg_calendar_log_change()")
op.execute("DROP FUNCTION IF EXISTS plg_calendar_touch()")
op.execute("DROP FUNCTION IF EXISTS plg_calendar_dav_name(plg_calendar_events)")
op.execute("DROP TABLE IF EXISTS plg_calendar_dav_changes")
op.execute("DROP INDEX IF EXISTS ix_plg_calendar_events_cal_uid")
for col in ("modified_at", "srv_ical_ver", "srv_ical", "srv_name"):
op.execute(f"ALTER TABLE plg_calendar_events DROP COLUMN IF EXISTS {col}")