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:
@@ -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
|
||||
+154
@@ -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}")
|
||||
Reference in New Issue
Block a user