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}")
@@ -13,7 +13,8 @@ from __future__ import annotations
from pathlib import Path
from app.plugins.sdk import Page, PageNav, Plugin, UiManifest
from app.plugins.sdk import JobSpec, Page, PageNav, Plugin, UiManifest
from webmail_plugin_contacts.dav import contacts_dav, prune_changes
from webmail_plugin_contacts.hook import on_message_sent
from webmail_plugin_contacts.router import router
@@ -25,6 +26,10 @@ plugin = Plugin(
builtin=True,
hooks={"message.sent": on_message_sent},
router=router,
# Address books served to CardDAV clients — phones, Thunderbird (SPEC App. E).
dav=contacts_dav,
# Trim the CardDAV change log nightly (sync tokens stay valid for 90 days).
jobs=[JobSpec(func=prune_changes, schedule="25 4 * * *")],
ui_manifest=UiManifest(
pages=[
Page(
@@ -0,0 +1,414 @@
"""The contacts plugin's CardDAV provider (SPEC App. E.4): address books as
CardDAV collections, each contact one vCard.
The same rows the web app reads and writes, in the user's RLS session. A
contact's groups are its CATEGORIES: a client's categories become (and are
kept in step with) the user's groups. Change numbers come from
``plg_contacts_dav_changes``, filled by triggers.
"""
from __future__ import annotations
import hashlib
import json
import uuid
from collections import defaultdict
from datetime import 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_contacts import vcard
_SDK = sdk("contacts")
_CR = "{urn:ietf:params:xml:ns:carddav}"
_COLUMNS = (
"c.id, c.book_id, c.vcard_uid, c.name_display, c.name_parts, c.nickname, c.emails, "
"c.default_email, c.phones, c.addresses, c.urls, c.org, c.department, c.title, "
"c.birthday, c.notes, c.starred, c.updated_at, c.srv_vcard, c.srv_vcard_ver, "
"plg_contacts_dav_name(c) AS dav_name"
)
class ContactsDav:
kind = "addressbook"
content_type = "text/vcard; charset=utf-8"
extension = ".vcf"
async def _book(self, s: Any, book_id: str) -> dict[str, Any]:
try:
uuid.UUID(book_id)
except ValueError:
raise DavError(404) from None
row = (
(
await s.execute(
text("SELECT id, kind, read_only FROM plg_contacts_books WHERE id = :id"),
{"id": book_id},
)
)
.mappings()
.first()
)
if row is None:
raise DavError(404)
return dict(row)
async def _rows(
self, s: Any, book_id: str, names: list[str] | None
) -> dict[str, dict[str, Any]]:
where = "c.book_id = :book"
params: dict[str, Any] = {"book": book_id}
if names is not None:
if not names:
return {}
where += " AND plg_contacts_dav_name(c) = ANY(:names)"
params["names"] = names
rows = (
(
await s.execute(
text(f"SELECT {_COLUMNS} FROM plg_contacts_contacts c WHERE {where}"), params
)
)
.mappings()
.all()
)
out = {r["dav_name"]: dict(r) for r in rows}
if out:
groups: dict[str, list[str]] = defaultdict(list)
for cid, name in (
await s.execute(
text(
"SELECT gm.contact_id, g.name FROM plg_contacts_group_members gm "
"JOIN plg_contacts_groups g ON g.id = gm.group_id "
"WHERE gm.contact_id = ANY(CAST(:ids AS uuid[])) ORDER BY g.name"
),
{"ids": [str(r["id"]) for r in out.values()]},
)
).all():
groups[str(cid)].append(name)
for r in out.values():
r["groups"] = groups.get(str(r["id"]), [])
return out
@staticmethod
def _serve(row: dict[str, Any], with_data: bool) -> tuple[str, str | None]:
fp = vcard.fingerprint(row, row.get("groups") or [])
raw = row.get("srv_vcard")
if raw and row.get("srv_vcard_ver") == fp:
return "r" + hashlib.sha1(raw.encode()).hexdigest(), raw # noqa: S324
return "g" + fp, vcard.build(row, row.get("groups") or []) if with_data else None
# -- collections -----------------------------------------------------------------
async def collections(self, user_id: uuid.UUID) -> list[Collection]:
from webmail_plugin_contacts.router import _ensure_book
async with _SDK.session(user_id) as s:
await _ensure_book(s, user_id) # a new user's first sync finds "Contacts"
rows = (
(
await s.execute(
text(
"SELECT b.id, b.name, b.kind, b.read_only, "
"COALESCE((SELECT max(id) FROM plg_contacts_dav_changes d "
" WHERE d.book_id = b.id), 0) AS token "
"FROM plg_contacts_books b ORDER BY b.name"
)
)
)
.mappings()
.all()
)
return [
Collection(
id=str(r["id"]),
displayname=r["name"],
sync_token=int(r["token"]),
read_only=bool(r["read_only"]) or r["kind"] != "local",
components=[],
)
for r in rows
]
async def create_collection(
self, user_id: uuid.UUID, collection_id: str, displayname: str, color: str | None
) -> None:
try:
bid = uuid.UUID(collection_id)
except ValueError:
raise DavError(403) from None
name = (displayname or "Address book").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_contacts_books"))).scalars()
}
base, n = name, 2
while name.lower() in taken:
name, n = f"{base} ({n})", n + 1
await s.execute(
text("INSERT INTO plg_contacts_books (id, user_id, name) VALUES (:id, :u, :name)"),
{"id": str(bid), "u": str(user_id), "name": name},
)
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:
if not displayname:
return
async with _SDK.session(user_id) as s:
await self._book(s, collection_id)
try:
await s.execute(
text("UPDATE plg_contacts_books SET name = :n WHERE id = :id"),
{"n": displayname.strip()[:100], "id": collection_id},
)
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:
book = await self._book(s, collection_id)
if book["kind"] != "local":
raise DavError(403)
count = await s.scalar(text("SELECT count(*) FROM plg_contacts_books"))
if (count or 0) <= 1:
raise DavError(403) # the last address book stays
await s.execute(
text("DELETE FROM plg_contacts_books 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]:
async with _SDK.session(user_id) as s:
await self._book(s, collection_id)
rows = await self._rows(s, collection_id, names)
out = []
for name, row in rows.items():
etag, data = self._serve(row, 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]:
try:
fields, groups = vcard.parse(data)
except vcard.InvalidVCard as err:
raise DavError(400, str(err), condition=f"{_CR}valid-address-data") from err
async with _SDK.session(user_id) as s:
book = await self._book(s, collection_id)
if book["read_only"] or book["kind"] != "local":
raise DavError(403)
existing = (await self._rows(s, collection_id, [name])).get(name)
if existing:
etag, _ = self._serve(existing, False)
if if_none_match or (if_match and if_match != "*" and if_match != etag):
raise DavError(412)
if existing["vcard_uid"] != fields["vcard_uid"]:
raise DavError(400, condition=f"{_CR}no-uid-conflict")
else:
if if_match:
raise DavError(412)
clash = await s.scalar(
text(
"SELECT plg_contacts_dav_name(c) FROM plg_contacts_contacts c "
"WHERE book_id = :b AND vcard_uid = :uid"
),
{"b": collection_id, "uid": fields["vcard_uid"]},
)
if clash and clash != name:
raise DavError(409, condition=f"{_CR}no-uid-conflict")
contact_id = await s.scalar(
text(
"INSERT INTO plg_contacts_contacts (user_id, book_id, vcard_uid, name_display, "
" name_parts, nickname, emails, default_email, phones, addresses, urls, org, "
" department, title, birthday, notes, starred, srv_name) "
"VALUES (:u, :b, :uid, :fn, CAST(:n AS jsonb), :nick, CAST(:emails AS jsonb), "
" :def, CAST(:phones AS jsonb), CAST(:adr AS jsonb), CAST(:urls AS jsonb), "
" :org, :dept, :title, :bday, :notes, :star, :name) "
"ON CONFLICT (book_id, vcard_uid) DO UPDATE SET "
" name_display = EXCLUDED.name_display, name_parts = EXCLUDED.name_parts, "
" nickname = EXCLUDED.nickname, emails = EXCLUDED.emails, "
" default_email = EXCLUDED.default_email, phones = EXCLUDED.phones, "
" addresses = EXCLUDED.addresses, urls = EXCLUDED.urls, org = EXCLUDED.org, "
" department = EXCLUDED.department, title = EXCLUDED.title, "
" birthday = EXCLUDED.birthday, notes = EXCLUDED.notes, "
" starred = EXCLUDED.starred, srv_name = EXCLUDED.srv_name "
"RETURNING id"
),
{
"u": str(user_id),
"b": collection_id,
"uid": fields["vcard_uid"],
"fn": fields["name_display"],
"n": json.dumps(fields["name_parts"]),
"nick": fields["nickname"],
"emails": json.dumps(fields["emails"]),
"def": fields["default_email"],
"phones": json.dumps(fields["phones"]),
"adr": json.dumps(fields["addresses"]),
"urls": json.dumps(fields["urls"]),
"org": fields["org"],
"dept": fields["department"],
"title": fields["title"],
"bday": fields["birthday"],
"notes": fields["notes"],
"star": fields["starred"],
"name": name,
},
)
await self._set_groups(s, user_id, str(contact_id), groups)
stored = (await self._rows(s, collection_id, [name]))[name]
fp = vcard.fingerprint(stored, stored.get("groups") or [])
await s.execute(
text(
"UPDATE plg_contacts_contacts SET srv_vcard = :raw, srv_vcard_ver = :fp "
"WHERE id = :id"
),
{"raw": data, "fp": fp, "id": str(contact_id)},
)
return "r" + hashlib.sha1(data.encode()).hexdigest(), not existing # noqa: S324
async def _set_groups(
self, s: Any, user_id: uuid.UUID, contact_id: str, names: list[str]
) -> None:
"""Make the contact's groups exactly ``names`` (creating missing ones)."""
ids: list[str] = []
for name in names:
gid = await s.scalar(
text(
"INSERT INTO plg_contacts_groups (user_id, name) VALUES (:u, :n) "
"ON CONFLICT (user_id, name) DO NOTHING RETURNING id"
),
{"u": str(user_id), "n": name[:100]},
)
if gid is None: # exists already (an update would mark all its members changed)
gid = await s.scalar(
text("SELECT id FROM plg_contacts_groups WHERE name = :n"), {"n": name[:100]}
)
ids.append(str(gid))
await s.execute(
text(
"DELETE FROM plg_contacts_group_members WHERE contact_id = :c "
"AND NOT (group_id = ANY(CAST(:ids AS uuid[])))"
),
{"c": contact_id, "ids": ids},
)
for gid in ids:
await s.execute(
text(
"INSERT INTO plg_contacts_group_members (user_id, group_id, contact_id) "
"VALUES (:u, :g, :c) ON CONFLICT DO NOTHING"
),
{"u": str(user_id), "g": gid, "c": contact_id},
)
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:
book = await self._book(s, collection_id)
if book["read_only"] or book["kind"] != "local":
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 != "*" and if_match != self._serve(existing, False)[0]:
raise DavError(412)
await s.execute(
text("DELETE FROM plg_contacts_contacts WHERE id = :id"),
{"id": str(existing["id"])},
)
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._book(s, collection_id)
rows = (
(
await s.execute(
text(
"SELECT id, name, deleted FROM plg_contacts_dav_changes "
"WHERE book_id = :b AND id > :since ORDER BY id"
),
{"b": collection_id, "since": since},
)
)
.mappings()
.all()
)
latest = await s.scalar(
text(
"SELECT COALESCE(max(id), 0) FROM plg_contacts_dav_changes WHERE book_id = :b"
),
{"b": collection_id},
)
oldest = await s.scalar(
text("SELECT min(id) FROM plg_contacts_dav_changes WHERE book_id = :b"),
{"b": 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
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:
return None # address books: the core returns them all
contacts_dav = ContactsDav()
async def prune_changes(ctx: dict[str, Any]) -> int:
"""Nightly: drop change-log entries older than 90 days, keeping each address
book'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_contacts_dav_changes d "
"WHERE created_at < now() - interval '90 days' "
"AND id < (SELECT max(id) FROM plg_contacts_dav_changes k "
" WHERE k.book_id = d.book_id)"
)
)
return int(result.rowcount or 0) # type: ignore[attr-defined]
@@ -0,0 +1,175 @@
"""contacts: serve address books to CardDAV clients (SPEC Appendix E.4)
* ``srv_name`` — the resource name a client stored the contact under (NULL:
``md5(uid).vcf``);
* ``srv_vcard`` / ``srv_vcard_ver`` — the vCard a client PUT, served back
verbatim while the contact's fields (and groups) still match;
* ``plg_contacts_dav_changes`` — numbered changes per address book (sync token /
CTag), filled by triggers: a contact changed, its groups changed (they are
its CATEGORIES), a group renamed, a book renamed.
Revision ID: contacts0002
Revises: contacts0001
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 = "contacts0002"
down_revision: str | None = "contacts0001"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
for col in ("srv_name", "srv_vcard", "srv_vcard_ver"):
op.execute(f"ALTER TABLE plg_contacts_contacts ADD COLUMN {col} text")
op.execute(
"""
CREATE TABLE plg_contacts_dav_changes (
id bigserial PRIMARY KEY,
user_id uuid NOT NULL,
book_id uuid NOT NULL,
name text, -- NULL: the address book itself changed
deleted boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
)
"""
)
op.execute(
"CREATE INDEX ix_plg_contacts_dav_changes_book ON plg_contacts_dav_changes (book_id, id)"
)
enable_rls("plg_contacts_dav_changes")
op.execute("GRANT USAGE ON SEQUENCE plg_contacts_dav_changes_id_seq TO webmail_plugin")
op.execute(
"""
CREATE OR REPLACE FUNCTION plg_contacts_dav_name(c plg_contacts_contacts) RETURNS text AS $$
SELECT COALESCE(c.srv_name, md5(c.vcard_uid) || '.vcf')
$$ LANGUAGE sql IMMUTABLE;
"""
)
op.execute(
"""
CREATE OR REPLACE FUNCTION plg_contacts_touch() RETURNS trigger AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
"""
)
op.execute(
"""
CREATE TRIGGER plg_contacts_contacts_touch
BEFORE UPDATE ON plg_contacts_contacts
FOR EACH ROW EXECUTE FUNCTION plg_contacts_touch();
"""
)
op.execute(
"""
CREATE OR REPLACE FUNCTION plg_contacts_log_change() RETURNS trigger AS $$
BEGIN
IF TG_OP = 'DELETE' OR (TG_OP = 'UPDATE' AND NEW.book_id <> OLD.book_id) THEN
INSERT INTO plg_contacts_dav_changes (user_id, book_id, name, deleted)
VALUES (OLD.user_id, OLD.book_id, plg_contacts_dav_name(OLD), true);
END IF;
IF TG_OP <> 'DELETE' THEN
INSERT INTO plg_contacts_dav_changes (user_id, book_id, name, deleted)
VALUES (NEW.user_id, NEW.book_id, plg_contacts_dav_name(NEW), false);
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
"""
)
op.execute(
"""
CREATE TRIGGER plg_contacts_contacts_log
AFTER INSERT OR UPDATE OR DELETE ON plg_contacts_contacts
FOR EACH ROW EXECUTE FUNCTION plg_contacts_log_change();
"""
)
# Group membership is part of the vCard (CATEGORIES).
op.execute(
"""
CREATE OR REPLACE FUNCTION plg_contacts_log_member() RETURNS trigger AS $$
DECLARE
m plg_contacts_group_members;
BEGIN
m := CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
INSERT INTO plg_contacts_dav_changes (user_id, book_id, name, deleted)
SELECT c.user_id, c.book_id, plg_contacts_dav_name(c), false
FROM plg_contacts_contacts c WHERE c.id = m.contact_id;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
"""
)
op.execute(
"""
CREATE TRIGGER plg_contacts_group_members_log
AFTER INSERT OR DELETE ON plg_contacts_group_members
FOR EACH ROW EXECUTE FUNCTION plg_contacts_log_member();
"""
)
op.execute(
"""
CREATE OR REPLACE FUNCTION plg_contacts_log_group() RETURNS trigger AS $$
BEGIN
INSERT INTO plg_contacts_dav_changes (user_id, book_id, name, deleted)
SELECT c.user_id, c.book_id, plg_contacts_dav_name(c), false
FROM plg_contacts_group_members gm
JOIN plg_contacts_contacts c ON c.id = gm.contact_id
WHERE gm.group_id = NEW.id;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
"""
)
op.execute(
"""
CREATE TRIGGER plg_contacts_groups_log
AFTER UPDATE OF name ON plg_contacts_groups
FOR EACH ROW EXECUTE FUNCTION plg_contacts_log_group();
"""
)
op.execute(
"""
CREATE OR REPLACE FUNCTION plg_contacts_log_book() RETURNS trigger AS $$
BEGIN
INSERT INTO plg_contacts_dav_changes (user_id, book_id, name, deleted)
VALUES (NEW.user_id, NEW.id, NULL, false);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
"""
)
op.execute(
"""
CREATE TRIGGER plg_contacts_books_log
AFTER INSERT OR UPDATE OF name ON plg_contacts_books
FOR EACH ROW EXECUTE FUNCTION plg_contacts_log_book();
"""
)
def downgrade() -> None:
op.execute("DROP TRIGGER IF EXISTS plg_contacts_books_log ON plg_contacts_books")
op.execute("DROP TRIGGER IF EXISTS plg_contacts_groups_log ON plg_contacts_groups")
op.execute(
"DROP TRIGGER IF EXISTS plg_contacts_group_members_log ON plg_contacts_group_members"
)
op.execute("DROP TRIGGER IF EXISTS plg_contacts_contacts_log ON plg_contacts_contacts")
op.execute("DROP TRIGGER IF EXISTS plg_contacts_contacts_touch ON plg_contacts_contacts")
for fn in ("log_book", "log_group", "log_member", "log_change", "touch"):
op.execute(f"DROP FUNCTION IF EXISTS plg_contacts_{fn}()")
op.execute("DROP FUNCTION IF EXISTS plg_contacts_dav_name(plg_contacts_contacts)")
op.execute("DROP TABLE IF EXISTS plg_contacts_dav_changes")
for col in ("srv_vcard_ver", "srv_vcard", "srv_name"):
op.execute(f"ALTER TABLE plg_contacts_contacts DROP COLUMN IF EXISTS {col}")
@@ -0,0 +1,338 @@
"""vCard (RFC 2426 v3.0, RFC 6350 v4.0) <-> contact fields (SPEC App. E.4).
:func:`parse` reads a vCard 3.0 or 4.0 a client sent into the columns of
``plg_contacts_contacts`` (plus its CATEGORIES, which are the user's groups);
:func:`build` writes a contact as vCard 3.0, which every client reads.
: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
import re
from collections.abc import Mapping, Sequence
from datetime import date, datetime
from typing import Any
FIELDS = (
"vcard_uid",
"name_display",
"name_parts",
"nickname",
"emails",
"default_email",
"phones",
"addresses",
"urls",
"org",
"department",
"title",
"birthday",
"notes",
"starred",
)
class InvalidVCard(ValueError):
pass
def fingerprint(row: Mapping[str, Any], groups: Sequence[str]) -> str:
data = {f: row.get(f) for f in FIELDS}
data["groups"] = sorted(groups, key=str.lower)
return hashlib.sha1(json.dumps(data, sort_keys=True, default=str).encode()).hexdigest()
# ---- text level -----------------------------------------------------------------------
def _unfold(text: str) -> list[str]:
lines: list[str] = []
for raw in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
if raw[:1] in (" ", "\t") and lines:
lines[-1] += raw[1:]
elif raw.strip():
lines.append(raw)
return lines
def _split_unescaped(value: str, sep: str) -> list[str]:
parts, cur, esc = [], [], False
for ch in value:
if esc:
cur.append("\\" + ch)
esc = False
elif ch == "\\":
esc = True
elif ch == sep:
parts.append("".join(cur))
cur = []
else:
cur.append(ch)
parts.append("".join(cur))
return parts
def _unescape(value: str) -> str:
return re.sub(r"\\(.)", lambda m: "\n" if m.group(1) in "nN" else m.group(1), value)
def _escape(value: str) -> str:
return value.replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\n", "\\n")
class _Line:
def __init__(self, raw: str) -> None:
head, sep, value = self._split(raw)
if not sep:
raise InvalidVCard(f"bad line: {raw[:40]}")
bits = _split_params(head)
name = bits[0]
self.name = name.rsplit(".", 1)[-1].upper() # "item1.EMAIL" -> EMAIL
self.params: dict[str, list[str]] = {}
for p in bits[1:]:
key, eq, val = p.partition("=")
if not eq: # vCard 2.1-style bare type ("WORK")
key, val = "TYPE", key
vals = [v.strip('"') for v in val.split(",")]
self.params.setdefault(key.upper(), []).extend(v for v in vals if v)
self.value = value
@staticmethod
def _split(raw: str) -> tuple[str, str, str]:
quoted = False
for i, ch in enumerate(raw):
if ch == '"':
quoted = not quoted
elif ch == ":" and not quoted:
return raw[:i], ":", raw[i + 1 :]
return raw, "", ""
def types(self) -> set[str]:
return {t.lower() for t in self.params.get("TYPE", [])}
def pref(self) -> bool:
return "pref" in self.types() or bool(self.params.get("PREF"))
def _split_params(head: str) -> list[str]:
out, cur, quoted = [], [], False
for ch in head:
if ch == '"':
quoted = not quoted
cur.append(ch)
elif ch == ";" and not quoted:
out.append("".join(cur))
cur = []
else:
cur.append(ch)
out.append("".join(cur))
return out
# ---- vCard -> fields ------------------------------------------------------------------
def _birthday(value: str) -> date | None:
v = value.strip()
for fmt in ("%Y-%m-%d", "%Y%m%d"):
try:
return datetime.strptime(v[:10] if "-" in v else v[:8], fmt).date()
except ValueError:
continue
return None # "--MMDD" (no year) and the like are not kept as a date
def parse(text: str) -> tuple[dict[str, Any], list[str]]:
"""(contact fields, group names) of one vCard. Raises InvalidVCard."""
lines = _unfold(text)
if not lines or lines[0].strip().upper() != "BEGIN:VCARD":
raise InvalidVCard("not a vCard")
if sum(1 for line in lines if line.strip().upper() == "BEGIN:VCARD") != 1:
raise InvalidVCard("one vCard per resource")
row: dict[str, Any] = {
"vcard_uid": "",
"name_display": "",
"name_parts": {"prefix": "", "given": "", "middle": "", "family": "", "suffix": ""},
"nickname": "",
"emails": [],
"default_email": "",
"phones": [],
"addresses": [],
"urls": [],
"org": "",
"department": "",
"title": "",
"birthday": None,
"notes": "",
"starred": False,
}
groups: list[str] = []
for raw in lines[1:]:
if raw.strip().upper() == "END:VCARD":
break
line = _Line(raw)
name, value, types = line.name, line.value, line.types()
if name == "UID":
v = _unescape(value).strip()
row["vcard_uid"] = v[9:] if v.lower().startswith("urn:uuid:") else v
elif name == "FN":
row["name_display"] = _unescape(value).strip()
elif name == "N":
parts = [_unescape(p).strip() for p in _split_unescaped(value, ";")] + [""] * 5
row["name_parts"] = {
"family": parts[0],
"given": parts[1],
"middle": parts[2],
"prefix": parts[3],
"suffix": parts[4],
}
elif name == "NICKNAME":
row["nickname"] = _unescape(_split_unescaped(value, ",")[0]).strip()
elif name == "EMAIL":
addr = _unescape(value).strip()
if addr.lower().startswith("mailto:"):
addr = addr[7:]
if addr:
kind = "work" if "work" in types else "personal" if "home" in types else "other"
row["emails"].append({"value": addr, "type": kind, "pref": line.pref()})
elif name == "TEL":
number = _unescape(value).strip()
if number.lower().startswith("tel:"):
number = number[4:]
if number:
kind = (
"mobile"
if "cell" in types
else "work"
if "work" in types
else "home"
if "home" in types
else "other"
)
row["phones"].append({"value": number, "type": kind})
elif name == "ADR":
p = [_unescape(x).strip() for x in _split_unescaped(value, ";")] + [""] * 7
street = ", ".join(x for x in (p[1], p[2]) if x) # extended + street
kind = "work" if "work" in types else "home" if "home" in types else "other"
if any((street, p[3], p[5], p[6])):
row["addresses"].append(
{
"type": kind,
"street": street,
"city": p[3],
"postal_code": p[5],
"country": p[6],
}
)
elif name == "URL":
url = _unescape(value).strip()
if url:
row["urls"].append({"value": url, "type": "website"})
elif name == "ORG":
p = [_unescape(x).strip() for x in _split_unescaped(value, ";")] + [""]
row["org"], row["department"] = p[0], p[1]
elif name == "TITLE":
row["title"] = _unescape(value).strip()
elif name == "BDAY":
row["birthday"] = _birthday(_unescape(value))
elif name == "NOTE":
row["notes"] = _unescape(value)
elif name == "CATEGORIES":
groups.extend(
g for g in (_unescape(x).strip() for x in _split_unescaped(value, ",")) if g
)
elif name == "X-ULTIMO-VIP":
row["starred"] = value.strip() in ("1", "TRUE", "true")
if not row["vcard_uid"]:
raise InvalidVCard("UID is required")
if not row["name_display"]:
n = row["name_parts"]
row["name_display"] = (
" ".join(
x for x in (n["prefix"], n["given"], n["middle"], n["family"], n["suffix"]) if x
)
or row["org"]
or (row["emails"][0]["value"] if row["emails"] else "")
)
pref = next((e["value"] for e in row["emails"] if e["pref"]), None)
row["default_email"] = pref or (row["emails"][0]["value"] if row["emails"] else "")
return row, list(dict.fromkeys(groups))
# ---- fields -> vCard ------------------------------------------------------------------
def _fold(line: str) -> str:
"""Fold at 75 octets (RFC 6350 §3.2), never inside a UTF-8 character."""
out: list[str] = []
cur = b""
for ch in line:
b = ch.encode()
if len(cur) + len(b) > (75 if not out else 74):
out.append(cur.decode())
cur = b""
cur += b
out.append(cur.decode())
return "\r\n ".join(out)
def build(row: Mapping[str, Any], groups: Sequence[str]) -> str:
"""A contact as vCard 3.0."""
lines = ["BEGIN:VCARD", "VERSION:3.0", "PRODID:-//Ultimo//Contacts//EN"]
lines.append(f"UID:{_escape(str(row['vcard_uid']))}")
lines.append(f"FN:{_escape(row.get('name_display') or '')}")
n = row.get("name_parts") or {}
lines.append(
"N:"
+ ";".join(
_escape(str(n.get(k, "") or ""))
for k in ("family", "given", "middle", "prefix", "suffix")
)
)
if row.get("nickname"):
lines.append(f"NICKNAME:{_escape(row['nickname'])}")
if row.get("org") or row.get("department"):
lines.append(f"ORG:{_escape(row.get('org') or '')};{_escape(row.get('department') or '')}")
if row.get("title"):
lines.append(f"TITLE:{_escape(row['title'])}")
default = (row.get("default_email") or "").lower()
for e in row.get("emails") or []:
kind = {"work": "WORK", "personal": "HOME"}.get(e.get("type", ""), "OTHER")
pref = (
",pref" if e.get("pref") or (default and e.get("value", "").lower() == default) else ""
)
lines.append(f"EMAIL;TYPE=INTERNET,{kind}{pref}:{_escape(e.get('value', ''))}")
for p in row.get("phones") or []:
kind = {"mobile": "CELL", "home": "HOME", "work": "WORK"}.get(p.get("type", ""), "VOICE")
lines.append(f"TEL;TYPE={kind}:{_escape(p.get('value', ''))}")
for a in row.get("addresses") or []:
kind = {"home": "HOME", "work": "WORK"}.get(a.get("type", ""), "OTHER")
parts = [
"",
"",
a.get("street", ""),
a.get("city", ""),
"",
a.get("postal_code", ""),
a.get("country", ""),
]
lines.append(f"ADR;TYPE={kind}:" + ";".join(_escape(str(x or "")) for x in parts))
for u in row.get("urls") or []:
lines.append(f"URL:{_escape(u.get('value', ''))}")
bday = row.get("birthday")
if bday:
lines.append(f"BDAY:{bday.isoformat() if hasattr(bday, 'isoformat') else bday}")
if row.get("notes"):
lines.append(f"NOTE:{_escape(row['notes'])}")
if groups:
lines.append("CATEGORIES:" + ",".join(_escape(g) for g in groups))
if row.get("starred"):
lines.append("X-ULTIMO-VIP:1")
updated = row.get("updated_at")
if isinstance(updated, datetime):
lines.append(f"REV:{updated.strftime('%Y%m%dT%H%M%SZ')}")
lines.append("END:VCARD")
return "\r\n".join(_fold(line) for line in lines) + "\r\n"