Calendar: CalDAV two-way sync (iCloud/generic) + all-day timegrid fix

CalDAV account connect (Settings → Calendars) with RFC 6764 discovery,
per-calendar vaulted credentials, and full two-way sync verified live
against NextCloud:

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 10:27:21 +02:00
co-authored by Claude Opus 4.8
parent 719eeef378
commit a27755a4d3
13 changed files with 1260 additions and 22 deletions
@@ -11,8 +11,9 @@ 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_calendar.router import router
from webmail_plugin_calendar.sync import caldav_sync
_HERE = Path(__file__).resolve().parent
@@ -20,6 +21,8 @@ plugin = Plugin(
name="calendar",
version="0.1.0",
builtin=True,
# Refresh connected CalDAV calendars every 15 minutes (also runs on connect).
jobs=[JobSpec(func=caldav_sync, schedule="*/15 * * * *")],
router=router,
ui_manifest=UiManifest(
pages=[
@@ -0,0 +1,390 @@
"""Minimal CalDAV client for two-way sync (SPEC §A.3).
Speaks CalDAV over the SDK's SSRF-guarded HTTP: RFC 6764 discovery
(current-user-principal → calendar-home-set → calendar collections), an RFC 4791
``calendar-query`` REPORT to pull events, and PUT/DELETE (with ETags) to push local
changes. iCloud redirects to a per-user partition host, so requests follow a few
redirects (each still SSRF-validated by the underlying guard).
The pure helpers — ``parse_multistatus_calendars``, ``parse_report``,
``vevent_to_fields``, ``build_ics`` — take/return plain data and are unit-tested
without a network.
"""
from __future__ import annotations
import logging
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from urllib.parse import urljoin
from defusedxml import ElementTree as SafeET
logger = logging.getLogger("plg.calendar.caldav")
# Well-known base URLs for providers that don't need a user-supplied server URL.
PROVIDER_BASE = {"icloud": "https://caldav.icloud.com/"}
_DAV = "{DAV:}"
_CAL = "{urn:ietf:params:xml:ns:caldav}"
_CS = "{http://calendarserver.org/ns/}"
_ICAL = "{http://apple.com/ns/ical/}"
_PRINCIPAL_BODY = (
b'<?xml version="1.0" encoding="utf-8"?>'
b'<d:propfind xmlns:d="DAV:"><d:prop><d:current-user-principal/></d:prop></d:propfind>'
)
_HOME_BODY = (
b'<?xml version="1.0" encoding="utf-8"?>'
b'<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
b"<d:prop><c:calendar-home-set/></d:prop></d:propfind>"
)
_CAL_LIST_BODY = (
b'<?xml version="1.0" encoding="utf-8"?>'
b'<d:propfind xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"'
b' xmlns:cs="http://calendarserver.org/ns/" xmlns:ical="http://apple.com/ns/ical/">'
b"<d:prop><d:resourcetype/><d:displayname/><ical:calendar-color/><cs:getctag/>"
b"<d:current-user-privilege-set/><c:supported-calendar-component-set/></d:prop></d:propfind>"
)
_CTAG_BODY = (
b'<?xml version="1.0" encoding="utf-8"?>'
b'<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/">'
b"<d:prop><cs:getctag/></d:prop></d:propfind>"
)
_EVENTS_QUERY = (
b'<?xml version="1.0" encoding="utf-8"?>'
b'<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
b"<d:prop><d:getetag/><c:calendar-data/></d:prop>"
b'<c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT"/>'
b"</c:comp-filter></c:filter></c:calendar-query>"
)
class CalDavError(Exception):
"""A CalDAV request failed (auth, network, or unexpected status)."""
@dataclass
class DavCalendar:
url: str
name: str
color: str = "#3b82f6"
ctag: str = ""
read_only: bool = False
@dataclass
class DavEvent:
href: str
etag: str
ics: str
# --- pure parsing/building (unit-tested) -------------------------------------
def _text(el: Any, path: str) -> str:
found = el.find(path)
return (found.text or "").strip() if found is not None and found.text else ""
def _propstat_ok(response: Any) -> Any | None:
"""Return the ``<prop>`` element of the HTTP-200 propstat in a response, or None."""
for propstat in response.findall(f"{_DAV}propstat"):
status = _text(propstat, f"{_DAV}status")
if " 200 " in status:
return propstat.find(f"{_DAV}prop")
# Some servers omit the status; fall back to the first prop.
ps = response.find(f"{_DAV}propstat")
return ps.find(f"{_DAV}prop") if ps is not None else None
def parse_href(xml_text: str, prop_tag: str) -> str | None:
"""First ``<prop_tag><d:href>`` value in a multistatus (discovery steps)."""
root = SafeET.fromstring(xml_text)
for response in root.iter(f"{_DAV}response"):
prop = _propstat_ok(response)
if prop is None:
continue
target = prop.find(prop_tag)
if target is not None:
href = target.find(f"{_DAV}href")
if href is not None and href.text:
return href.text.strip()
return None
def parse_multistatus_calendars(xml_text: str, base_url: str) -> list[DavCalendar]:
"""Calendar collections from a Depth-1 PROPFIND on the calendar-home-set."""
root = SafeET.fromstring(xml_text)
out: list[DavCalendar] = []
for response in root.iter(f"{_DAV}response"):
prop = _propstat_ok(response)
href_el = response.find(f"{_DAV}href")
if prop is None or href_el is None or not href_el.text:
continue
resourcetype = prop.find(f"{_DAV}resourcetype")
if resourcetype is None or resourcetype.find(f"{_CAL}calendar") is None:
continue # not a calendar collection (e.g. the home node itself, inbox/outbox)
# Only calendars that hold events.
comps = prop.find(f"{_CAL}supported-calendar-component-set")
if comps is not None:
names = {c.get("name") for c in comps.findall(f"{_CAL}comp")}
if names and "VEVENT" not in names:
continue
name = _text(prop, f"{_DAV}displayname") or "Calendar"
color = (_text(prop, f"{_ICAL}calendar-color") or "#3b82f6")[:7]
ctag = _text(prop, f"{_CS}getctag")
# read-only unless the privilege set grants write.
read_only = True
privset = prop.find(f"{_DAV}current-user-privilege-set")
if privset is None:
read_only = False # server didn't report; assume writable
else:
for priv in privset.iter(f"{_DAV}privilege"):
if priv.find(f"{_DAV}write") is not None or priv.find(f"{_DAV}all") is not None:
read_only = False
break
out.append(
DavCalendar(
url=urljoin(base_url, href_el.text.strip()),
name=name,
color=color or "#3b82f6",
ctag=ctag,
read_only=read_only,
)
)
return out
def parse_report(xml_text: str, base_url: str) -> list[DavEvent]:
"""(href, etag, calendar-data) tuples from a calendar-query/multiget REPORT."""
root = SafeET.fromstring(xml_text)
out: list[DavEvent] = []
for response in root.iter(f"{_DAV}response"):
href_el = response.find(f"{_DAV}href")
prop = _propstat_ok(response)
if href_el is None or not href_el.text or prop is None:
continue
cdata = _text(prop, f"{_CAL}calendar-data")
if not cdata:
continue
out.append(
DavEvent(
href=urljoin(base_url, href_el.text.strip()),
etag=_text(prop, f"{_DAV}getetag").strip('"'),
ics=cdata,
)
)
return out
def _aware(dt: Any) -> datetime:
if not isinstance(dt, datetime):
dt = datetime(dt.year, dt.month, dt.day)
return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
def vevent_to_fields(ics_text: str) -> dict[str, Any] | None:
"""Map the first VEVENT in an ICS resource to plg_calendar_events columns."""
from icalendar import Calendar as ICalendar
try:
cal = ICalendar.from_ical(ics_text)
except Exception: # noqa: BLE001 - malformed resource; skip it
return None
comp = next((c for c in cal.walk("VEVENT")), None)
if comp is None:
return None
start = comp.get("dtstart")
if start is None:
return None
all_day = not isinstance(start.dt, datetime)
dtstart = _aware(start.dt)
end = comp.get("dtend")
dtend = _aware(end.dt) if end is not None else None
rrule = comp.get("rrule")
return {
"ical_uid": str(comp.get("uid") or f"{uuid.uuid4()}@caldav"),
"sequence": int(comp.get("sequence") or 0),
"summary": str(comp.get("summary") or ""),
"description": str(comp.get("description") or ""),
"location": str(comp.get("location") or ""),
"dtstart": dtstart,
"dtend": dtend,
"all_day": all_day,
"rrule": rrule.to_ical().decode() if rrule is not None else None,
"status": str(comp.get("status") or "confirmed").lower(),
"raw_ical": ics_text,
}
def build_ics(event: dict[str, Any]) -> str:
"""A single-VEVENT VCALENDAR for PUTting a locally created/edited event."""
from icalendar import Calendar as ICalendar
from icalendar import Event as IEvent
from icalendar.prop import vRecur
cal = ICalendar()
cal.add("prodid", "-//Ultimo//Calendar//EN")
cal.add("version", "2.0")
ev = IEvent()
ev.add("uid", event["ical_uid"])
ev.add("sequence", int(event.get("sequence") or 0))
ev.add("summary", event.get("summary") or "")
if event.get("description"):
ev.add("description", event["description"])
if event.get("location"):
ev.add("location", event["location"])
start, end = event["dtstart"], event.get("dtend")
if event.get("all_day"):
ev.add("dtstart", start.date() if isinstance(start, datetime) else start)
if end is not None:
ev.add("dtend", end.date() if isinstance(end, datetime) else end)
else:
ev.add("dtstart", start)
if end is not None:
ev.add("dtend", end)
if event.get("rrule"):
ev.add("rrule", vRecur.from_ical(str(event["rrule"])))
for ex in event.get("exdates") or []:
ev.add("exdate", ex.date() if event.get("all_day") and isinstance(ex, datetime) else ex)
ev.add("dtstamp", datetime.now(UTC))
cal.add_component(ev)
return cal.to_ical().decode()
# --- network operations ------------------------------------------------------
async def _request(
http: Any,
method: str,
url: str,
*,
creds: tuple[str, str],
depth: str | None = None,
body: bytes | None = None,
extra_headers: dict[str, str] | None = None,
) -> Any:
"""One CalDAV request, following up to 5 redirects (iCloud partition hosts).
Each hop goes through ``sdk.http`` (SSRF-guarded). Returns the final response."""
headers: dict[str, str] = {"User-Agent": "Ultimo-Webmail/1.0"}
if body is not None and method in ("PROPFIND", "REPORT"):
headers["Content-Type"] = "application/xml; charset=utf-8"
if depth is not None:
headers["Depth"] = depth
if extra_headers:
headers.update(extra_headers)
current = url
resp = None
for _ in range(6):
try:
resp = await http.request(
method, current, headers=headers, content=body, auth=creds
)
except Exception as exc: # transport error or SSRF guard rejection
name = type(exc).__name__.lower()
msg = str(exc).lower()
if "ssrf" in name or "public" in msg or "private" in msg or "not allowed" in msg:
raise CalDavError("private_host") from exc
raise CalDavError("unreachable") from exc
if resp.status in (301, 302, 307, 308):
location = resp.headers.get("location")
if not location:
break
current = urljoin(current, location)
continue
return resp
return resp
async def discover_calendars(
http: Any, base_url: str, creds: tuple[str, str]
) -> list[DavCalendar]:
"""RFC 6764 discovery: principal → calendar-home-set → calendar collections."""
resp = await _request(http, "PROPFIND", base_url, creds=creds, depth="0", body=_PRINCIPAL_BODY)
if resp is None or resp.status in (401, 403):
raise CalDavError("auth_failed")
if resp.status not in (207, 200):
raise CalDavError(f"principal lookup failed ({resp.status})")
principal = parse_href(resp.text, f"{_DAV}current-user-principal") or base_url
principal_url = urljoin(base_url, principal)
resp = await _request(
http, "PROPFIND", principal_url, creds=creds, depth="0", body=_HOME_BODY
)
if resp is None or resp.status not in (207, 200):
raise CalDavError("home-set lookup failed")
home = parse_href(resp.text, f"{_CAL}calendar-home-set")
home_url = urljoin(principal_url, home) if home else principal_url
resp = await _request(http, "PROPFIND", home_url, creds=creds, depth="1", body=_CAL_LIST_BODY)
if resp is None or resp.status not in (207, 200):
raise CalDavError("calendar list failed")
return parse_multistatus_calendars(resp.text, home_url)
async def get_ctag(http: Any, calendar_url: str, creds: tuple[str, str]) -> str:
resp = await _request(http, "PROPFIND", calendar_url, creds=creds, depth="0", body=_CTAG_BODY)
if resp is None or resp.status not in (207, 200):
return ""
root = SafeET.fromstring(resp.text)
for response in root.iter(f"{_DAV}response"):
prop = _propstat_ok(response)
if prop is not None:
return _text(prop, f"{_CS}getctag")
return ""
async def pull_events(http: Any, calendar_url: str, creds: tuple[str, str]) -> list[DavEvent]:
resp = await _request(
http, "REPORT", calendar_url, creds=creds, depth="1", body=_EVENTS_QUERY
)
if resp is None or resp.status not in (207, 200):
raise CalDavError(f"event report failed ({resp.status if resp else 'no response'})")
return parse_report(resp.text, calendar_url)
def _if_match(etag: str) -> str:
"""Format a stored (quote-stripped) ETag as an HTTP entity-tag for If-Match.
RFC 7232 requires the value be a quoted-string; Sabre/NextCloud reject a bare
token with 412. Weak validators (``W/"…"``) and already-quoted values pass through."""
etag = etag.strip()
if etag.startswith("W/") or (etag.startswith('"') and etag.endswith('"')):
return etag
return f'"{etag}"'
async def put_event(
http: Any, url: str, ics: str, etag: str | None, creds: tuple[str, str]
) -> str:
"""Create/update an event resource; return the server's new ETag (may be empty)."""
extra = {"Content-Type": "text/calendar; charset=utf-8"}
if etag:
extra["If-Match"] = _if_match(etag) # update: only if unchanged since we last saw it
else:
extra["If-None-Match"] = "*" # create: only if it doesn't already exist
resp = await _request(http, "PUT", url, creds=creds, body=ics.encode(), extra_headers=extra)
if resp is None or resp.status not in (200, 201, 204):
raise CalDavError(f"PUT failed ({resp.status if resp else 'no response'})")
return (resp.headers.get("etag") or "").strip('"')
async def delete_event(http: Any, url: str, etag: str | None, creds: tuple[str, str]) -> None:
extra = {"If-Match": _if_match(etag)} if etag else {}
resp = await _request(http, "DELETE", url, creds=creds, extra_headers=extra)
# 404/412 (already gone / changed) are treated as done — the local intent (remove) holds.
if resp is None or resp.status not in (200, 204, 404, 412):
raise CalDavError(f"DELETE failed ({resp.status})")
def event_href(calendar_url: str, ical_uid: str) -> str:
"""Deterministic resource path for a locally created event (UID.ics under the collection)."""
base = calendar_url if calendar_url.endswith("/") else calendar_url + "/"
safe = "".join(ch for ch in ical_uid if ch.isalnum() or ch in "-_.@") or uuid.uuid4().hex
return urljoin(base, f"{safe}.ics")
@@ -0,0 +1,45 @@
"""calendar: CalDAV two-way sync fields (SPEC §A.3)
Per-event CalDAV bookkeeping so the sync engine can pull incrementally and push
local changes back: ``dav_href`` (the event resource path on the server) and
``dav_etag`` (its last-known ETag, for conflict detection). ``dav_dirty`` marks an
event created/edited/deleted locally that still needs pushing. Deletions are
tombstoned (``dav_deleted``) so the pusher can DELETE them, then drop the row.
Calendars already carry ``kind`` (local|caldav|ics_subscription), ``source_config``
(server/principal/home/collection URLs + an account group id), ``secret_ct`` (vault
credentials) and ``sync_token`` (the collection ctag), so no calendar columns change.
Revision ID: calendar0005
Revises: calendar0004
Create Date: 2026-08-07
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "calendar0005"
down_revision: str | None = "calendar0004"
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 dav_href text")
op.execute("ALTER TABLE plg_calendar_events ADD COLUMN dav_etag text")
op.execute(
"ALTER TABLE plg_calendar_events ADD COLUMN dav_dirty boolean NOT NULL DEFAULT false"
)
op.execute(
"ALTER TABLE plg_calendar_events ADD COLUMN dav_deleted boolean NOT NULL DEFAULT false"
)
def downgrade() -> None:
op.execute("ALTER TABLE plg_calendar_events DROP COLUMN IF EXISTS dav_deleted")
op.execute("ALTER TABLE plg_calendar_events DROP COLUMN IF EXISTS dav_dirty")
op.execute("ALTER TABLE plg_calendar_events DROP COLUMN IF EXISTS dav_etag")
op.execute("ALTER TABLE plg_calendar_events DROP COLUMN IF EXISTS dav_href")
@@ -0,0 +1,54 @@
"""calendar: make the event uniqueness NULLS NOT DISTINCT
The event key is ``(calendar_id, ical_uid, recurrence_id)``. ``recurrence_id`` is
NULL for the common case (non-recurring events and recurring masters). Postgres
treats NULLs as *distinct* in a unique index by default, so the CalDAV pull's
``INSERT ... ON CONFLICT (calendar_id, ical_uid, recurrence_id)`` never matched
those rows and re-inserted a duplicate on every full re-pull. Recreate the
constraint with ``NULLS NOT DISTINCT`` (PG 15+) so a NULL ``recurrence_id`` collides
as intended and the upsert updates in place.
Revision ID: calendar0006
Revises: calendar0005
Create Date: 2026-08-08
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "calendar0006"
down_revision: str | None = "calendar0005"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_CONSTRAINT = "plg_calendar_events_calendar_id_ical_uid_recurrence_id_key"
def upgrade() -> None:
# Collapse any duplicates the old (NULLS DISTINCT) index let through, keeping
# the most recently inserted row per (calendar_id, ical_uid, recurrence_id).
op.execute(
"""
DELETE FROM plg_calendar_events e USING plg_calendar_events dup
WHERE e.calendar_id = dup.calendar_id
AND e.ical_uid = dup.ical_uid
AND e.recurrence_id IS NOT DISTINCT FROM dup.recurrence_id
AND e.id < dup.id
"""
)
op.execute(f"ALTER TABLE plg_calendar_events DROP CONSTRAINT {_CONSTRAINT}")
op.execute(
f"ALTER TABLE plg_calendar_events ADD CONSTRAINT {_CONSTRAINT} "
"UNIQUE NULLS NOT DISTINCT (calendar_id, ical_uid, recurrence_id)"
)
def downgrade() -> None:
op.execute(f"ALTER TABLE plg_calendar_events DROP CONSTRAINT {_CONSTRAINT}")
op.execute(
f"ALTER TABLE plg_calendar_events ADD CONSTRAINT {_CONSTRAINT} "
"UNIQUE (calendar_id, ical_uid, recurrence_id)"
)
@@ -0,0 +1,59 @@
"""calendar: auto-mark CalDAV events dirty on local writes
Two-way sync needs to know which events changed locally so the pusher can PUT/DELETE
them back to the server. Rather than thread dirty-marking through every mutation
endpoint (create / patch / edit-following / delete with its recurrence scopes), a
BEFORE INSERT OR UPDATE trigger sets ``dav_dirty`` on any row belonging to a
``caldav`` calendar.
The pull sync itself writes these rows too, so it must NOT re-mark them dirty (that
would push server state straight back). It signals "this write is a sync" by setting
the transaction-local GUC ``webmail.dav_sync = 'on'``; the trigger skips when it sees
that. Deletions are tombstoned by the endpoint (``dav_deleted``), which is an UPDATE
and so also flows through this trigger.
Revision ID: calendar0007
Revises: calendar0006
Create Date: 2026-08-08
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "calendar0007"
down_revision: str | None = "calendar0006"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
CREATE OR REPLACE FUNCTION plg_calendar_mark_dav_dirty() RETURNS trigger AS $$
BEGIN
IF current_setting('webmail.dav_sync', true) IS DISTINCT FROM 'on' THEN
IF (SELECT kind FROM plg_calendar_calendars WHERE id = NEW.calendar_id)
= 'caldav' THEN
NEW.dav_dirty := true;
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
"""
)
op.execute(
"""
CREATE TRIGGER plg_calendar_events_dav_dirty
BEFORE INSERT OR UPDATE ON plg_calendar_events
FOR EACH ROW EXECUTE FUNCTION plg_calendar_mark_dav_dirty();
"""
)
def downgrade() -> None:
op.execute("DROP TRIGGER IF EXISTS plg_calendar_events_dav_dirty ON plg_calendar_events")
op.execute("DROP FUNCTION IF EXISTS plg_calendar_mark_dav_dirty()")
@@ -27,6 +27,7 @@ from webmail_plugin_calendar.queries import expand_range
from webmail_plugin_calendar.schemas import (
CalendarIn,
CalendarPatch,
DavConnectIn,
EditFollowingIn,
EventIn,
EventPatch,
@@ -66,6 +67,25 @@ async def _assert_writable(session: AsyncSession, calendar_id: uuid.UUID) -> Non
raise HTTPException(status_code=403, detail="read_only_calendar")
async def _is_caldav(session: AsyncSession, calendar_id: uuid.UUID) -> bool:
kind = (
await session.execute(
text("SELECT kind FROM plg_calendar_calendars WHERE id = :id"),
{"id": str(calendar_id)},
)
).scalar_one_or_none()
return kind == "caldav"
async def _enqueue_dav_sync(calendar_id: uuid.UUID) -> None:
"""Kick off an immediate push/pull for a CalDAV calendar so a local create/edit/
delete reaches the server right away instead of waiting for the periodic cron.
Call *after* the mutation's transaction has committed."""
from webmail_plugin_calendar.sync import caldav_sync
await _SDK.enqueue(caldav_sync, calendar_id=str(calendar_id))
# --- calendars ---------------------------------------------------------------
def _make_publish_token() -> str:
@@ -113,7 +133,7 @@ async def _calendar_event_rows(s: AsyncSession, calendar_id: uuid.UUID) -> list[
text(
"SELECT ical_uid, summary, description, location, dtstart, dtend, all_day, "
"rrule FROM plg_calendar_events "
"WHERE calendar_id = :id AND recurrence_id IS NULL"
"WHERE calendar_id = :id AND recurrence_id IS NULL AND dav_deleted = false"
),
{"id": str(calendar_id)},
)
@@ -314,6 +334,135 @@ async def subscribe_calendar(body: SubscribeIn, user_id: CurrentUserId) -> dict[
# --- events ------------------------------------------------------------------
@router.post("/dav/connect", dependencies=[Depends(require_csrf)])
async def dav_connect(body: DavConnectIn, user_id: CurrentUserId) -> dict[str, object]:
"""Connect a CalDAV account (iCloud or generic): discover its calendars and import
each as a read-only ``caldav`` calendar, then kick off an initial pull (SPEC §A.3).
Credentials are envelope-encrypted per calendar (§10.2). Google is not supported
here — its CalDAV needs OAuth (see GOOGLE_OAUTH_ENABLED)."""
from webmail_plugin_calendar import caldav
from webmail_plugin_calendar.sync import caldav_sync
base_url = caldav.PROVIDER_BASE.get(body.provider) or body.server_url.strip()
if not base_url:
raise HTTPException(status_code=400, detail="server_url_required")
creds = (body.username.strip(), body.password)
try:
found = await caldav.discover_calendars(_SDK.http, base_url, creds)
except caldav.CalDavError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not found:
raise HTTPException(status_code=400, detail="no_calendars")
account_id = uuid.uuid4().hex
secret = _SDK.vault.encrypt(body.password.encode("utf-8"))
created: list[dict[str, str]] = []
async with _SDK.session(user_id) as s:
for cal in found:
cfg = {
"account_id": account_id,
"provider": body.provider,
"base_url": base_url,
"calendar_url": cal.url,
"username": body.username.strip(),
}
cal_id = await _insert_dav_calendar(s, user_id, cal, cfg, secret)
if cal_id is not None:
created.append({"id": str(cal_id), "name": cal.name})
for c in created:
await _SDK.enqueue(caldav_sync, calendar_id=c["id"])
return {"account_id": account_id, "calendars": created}
async def _insert_dav_calendar(
s: AsyncSession,
user_id: uuid.UUID,
cal: object,
cfg: dict[str, str],
secret: bytes,
) -> uuid.UUID | None:
"""Insert one CalDAV calendar, disambiguating the name against the (user, name)
unique constraint. Returns the new id, or None if it couldn't be placed."""
base_name = getattr(cal, "name", "Calendar")
candidates = (
base_name,
f"{base_name} ({cfg['provider']})",
f"{base_name} ({cfg['username']})",
)
for candidate in candidates:
try:
return (
await s.execute(
text(
"INSERT INTO plg_calendar_calendars "
"(user_id, name, color, kind, read_only, secret_ct, sync_token, "
" source_config) "
"VALUES (:u, :n, :c, 'caldav', :ro, :sec, '', cast(:cfg as jsonb)) "
"RETURNING id"
),
{
"u": str(user_id),
"n": candidate,
"c": getattr(cal, "color", "#3b82f6"),
"ro": bool(getattr(cal, "read_only", False)),
"sec": secret,
"cfg": json.dumps(cfg),
},
)
).scalar_one()
except IntegrityError:
await s.rollback()
return None
@router.get("/dav/accounts")
async def list_dav_accounts(user_id: CurrentUserId) -> list[dict[str, object]]:
"""Connected CalDAV accounts, grouped by account id, with their calendars."""
async with _SDK.session(user_id) as s:
rows = (
await s.execute(
text(
"SELECT id, name, source_config FROM plg_calendar_calendars "
"WHERE kind = 'caldav' ORDER BY name"
)
)
).mappings().all()
accounts: dict[str, dict[str, object]] = {}
for r in rows:
cfg = r["source_config"]
if isinstance(cfg, str):
cfg = json.loads(cfg)
aid = str(cfg.get("account_id") or r["id"])
acct = accounts.setdefault(
aid,
{
"account_id": aid,
"provider": cfg.get("provider", "caldav"),
"username": cfg.get("username", ""),
"calendars": [],
},
)
cals = acct["calendars"]
assert isinstance(cals, list)
cals.append({"id": str(r["id"]), "name": r["name"]})
return list(accounts.values())
@router.delete("/dav/accounts/{account_id}", dependencies=[Depends(require_csrf)])
async def delete_dav_account(account_id: str, user_id: CurrentUserId) -> dict[str, str]:
"""Disconnect a CalDAV account: delete its calendars (events cascade)."""
async with _SDK.session(user_id) as s:
await s.execute(
text(
"DELETE FROM plg_calendar_calendars "
"WHERE kind = 'caldav' AND source_config->>'account_id' = :aid"
),
{"aid": account_id},
)
return {"status": "ok"}
@router.get("/events")
async def list_events(
user_id: CurrentUserId,
@@ -326,7 +475,7 @@ async def list_events(
the calendar search panel over a wide date window."""
range_start, range_end = _aware(start), _aware(end)
params: dict[str, object] = {"end": range_end}
where = "WHERE e.dtstart < :end"
where = "WHERE e.dtstart < :end AND e.dav_deleted = false"
term = q.strip()
if term:
where += (
@@ -466,6 +615,9 @@ async def create_event(body: EventIn, user_id: CurrentUserId) -> dict[str, str]:
async with _SDK.session(user_id) as s:
await _assert_writable(s, body.calendar_id)
new_id, ical_uid = await _insert_event(s, user_id, body, attendees)
is_dav = await _is_caldav(s, body.calendar_id)
if is_dav:
await _enqueue_dav_sync(body.calendar_id)
# iMIP: email each guest from the organizer's mailbox (best-effort, after commit).
await _send_invites_from(user_id, new_id, ical_uid, body, attendees)
return {"id": str(new_id), "ical_uid": ical_uid}
@@ -535,7 +687,10 @@ async def edit_following(
target_id, target_uid = await _insert_event(s, user_id, ev, merged_att)
notify = None # brand-new series → notify every guest
seq = 0
is_dav = await _is_caldav(s, base["calendar_id"])
if is_dav:
await _enqueue_dav_sync(base["calendar_id"])
await _send_invites_from(
user_id, target_id, target_uid, ev, merged_att, sequence=seq, notify=notify
)
@@ -603,6 +758,9 @@ async def patch_event(
),
params,
)
is_dav = await _is_caldav(s, existing["calendar_id"])
if is_dav:
await _enqueue_dav_sync(existing["calendar_id"])
# Email only the newly-added guests; the ICS lists the full (merged) roster.
if invite_new and merged_attendees is not None:
@@ -645,19 +803,30 @@ async def delete_event(
async with _SDK.session(user_id) as s:
row = (
await s.execute(
text("SELECT calendar_id, dtstart, rrule FROM plg_calendar_events WHERE id = :id"),
text(
"SELECT e.calendar_id, e.dtstart, e.rrule, c.kind "
"FROM plg_calendar_events e JOIN plg_calendar_calendars c "
"ON c.id = e.calendar_id WHERE e.id = :id"
),
{"id": str(event_id)},
)
).mappings().first()
if row is None:
raise HTTPException(status_code=404, detail="event_not_found")
await _assert_writable(s, row["calendar_id"])
# On CalDAV calendars, hard deletes become tombstones so the pusher can DELETE
# the resource on the server first, then drop the row. The dav_dirty trigger
# fires on this UPDATE. (EXDATE / series-truncation below are ordinary edits
# that re-PUT the whole resource, so they need no special handling.)
remove_sql = (
"UPDATE plg_calendar_events SET dav_deleted = true WHERE id = :id"
if row["kind"] == "caldav"
else "DELETE FROM plg_calendar_events WHERE id = :id"
)
recurring = row["rrule"] is not None
if not recurring or scope == "all" or occurrence is None:
await s.execute(
text("DELETE FROM plg_calendar_events WHERE id = :id"), {"id": str(event_id)}
)
await s.execute(text(remove_sql), {"id": str(event_id)})
elif scope == "this":
await s.execute(
text(
@@ -670,9 +839,7 @@ async def delete_event(
occ = _aware(occurrence)
if occ <= _aware(row["dtstart"]):
# Truncating from the first occurrence removes everything.
await s.execute(
text("DELETE FROM plg_calendar_events WHERE id = :id"), {"id": str(event_id)}
)
await s.execute(text(remove_sql), {"id": str(event_id)})
else:
until = (occ - timedelta(seconds=1)).astimezone(UTC).strftime("%Y%m%dT%H%M%SZ")
trimmed = _strip_recurrence_end(str(row["rrule"]))
@@ -683,6 +850,8 @@ async def delete_event(
)
else:
raise HTTPException(status_code=400, detail="bad_scope")
if row["kind"] == "caldav":
await _enqueue_dav_sync(row["calendar_id"])
return {"status": "ok"}
@@ -25,6 +25,13 @@ class SubscribeIn(BaseModel):
color: str = "#6b7280"
class DavConnectIn(BaseModel):
provider: str # "icloud" | "caldav"
server_url: str = "" # required for generic caldav; ignored for known providers
username: str
password: str
class Attendee(BaseModel):
email: str
name: str = ""
@@ -0,0 +1,199 @@
"""CalDAV pull sync (SPEC §A.3, read side).
Refreshes imported CalDAV calendars from their servers: skip when the collection's
ctag is unchanged, else pull every VEVENT and upsert it, dropping local events that
vanished remotely. Runs on connect (per calendar) and on a periodic cron (all).
Credentials come from the calendar's own vaulted ``secret_ct``.
Write-back (pushing local edits to the server) is a later stage; imported CalDAV
calendars are created ``read_only`` for now, so nothing here has to reconcile local
changes.
"""
from __future__ import annotations
import json
import logging
import uuid
from typing import Any
from sqlalchemy import text
from app.plugins.sdk import sdk
from webmail_plugin_calendar import caldav
logger = logging.getLogger("plg.calendar.sync")
_SDK = sdk("calendar")
def _creds(source_config: dict[str, Any], secret_ct: bytes | None) -> tuple[str, str]:
username = str(source_config.get("username") or "")
password = _SDK.vault.decrypt(secret_ct).decode("utf-8") if secret_ct else ""
return username, password
async def _push_changes(
session: Any, http: Any, cal_id: str, calendar_url: str, creds: tuple[str, str]
) -> int:
"""Push locally-changed events (``dav_dirty``) to the server. Deletions
(``dav_deleted``) are DELETEd then dropped; other changes are PUT. Returns the
number of resources pushed. Runs with ``webmail.dav_sync='on'`` so its own writes
don't re-mark rows dirty."""
rows = (
await session.execute(
text(
"SELECT id, ical_uid, sequence, summary, description, location, dtstart, "
"dtend, all_day, rrule, exdates, dav_href, dav_etag, dav_deleted "
"FROM plg_calendar_events WHERE calendar_id = :cal AND dav_dirty = true"
),
{"cal": cal_id},
)
).mappings().all()
pushed = 0
for r in rows:
try:
if r["dav_deleted"]:
if r["dav_href"]:
await caldav.delete_event(http, r["dav_href"], r["dav_etag"], creds)
await session.execute(
text("DELETE FROM plg_calendar_events WHERE id = :id"), {"id": str(r["id"])}
)
else:
href = r["dav_href"] or caldav.event_href(calendar_url, r["ical_uid"])
ics = caldav.build_ics(dict(r))
new_etag = await caldav.put_event(http, href, ics, r["dav_etag"], creds)
await session.execute(
text(
"UPDATE plg_calendar_events SET dav_dirty = false, dav_href = :href, "
"dav_etag = :etag WHERE id = :id"
),
{"href": href, "etag": new_etag, "id": str(r["id"])},
)
pushed += 1
except caldav.CalDavError:
# Conflict/transport error: leave the row dirty and try again next run.
logger.exception("caldav push failed for event %s", r["id"])
return pushed
async def sync_calendar(session: Any, cal: dict[str, Any]) -> int:
"""Sync one CalDAV calendar: push local changes, then pull the server into
``plg_calendar_events``. Returns events upserted on the pull."""
cfg = cal["source_config"]
if isinstance(cfg, str):
cfg = json.loads(cfg)
calendar_url = str(cfg.get("calendar_url") or "")
if not calendar_url:
return 0
creds = _creds(cfg, cal["secret_ct"])
http = _SDK.http
# Suppress the dav_dirty trigger for every write we make in this transaction.
await session.execute(text("SELECT set_config('webmail.dav_sync', 'on', true)"))
pushed = await _push_changes(session, http, str(cal["id"]), calendar_url, creds)
ctag = await caldav.get_ctag(http, calendar_url, creds)
if not pushed and ctag and ctag == (cal.get("sync_token") or ""):
return 0 # nothing pushed and collection unchanged since last sync
events = await caldav.pull_events(http, calendar_url, creds)
cal_id = str(cal["id"])
user_id = str(cal["user_id"])
seen: set[str] = set()
upserted = 0
for dav in events:
fields = caldav.vevent_to_fields(dav.ics)
if fields is None:
continue
seen.add(fields["ical_uid"])
await session.execute(
text(
"INSERT INTO plg_calendar_events "
"(user_id, calendar_id, ical_uid, sequence, summary, description, location, "
" dtstart, dtend, all_day, rrule, status, raw_ical, dav_href, dav_etag) VALUES "
"(:u, :cal, :uid, :seq, :sum, :desc, :loc, :start, :end, :allday, :rrule, "
" :status, :raw, :href, :etag) "
"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, "
"rrule = EXCLUDED.rrule, status = EXCLUDED.status, raw_ical = EXCLUDED.raw_ical, "
"dav_href = EXCLUDED.dav_href, dav_etag = EXCLUDED.dav_etag "
# Don't overwrite a locally-changed row that hasn't been pushed yet.
"WHERE plg_calendar_events.dav_dirty = false"
),
{
"u": user_id,
"cal": cal_id,
"uid": fields["ical_uid"],
"seq": fields["sequence"],
"sum": fields["summary"],
"desc": fields["description"],
"loc": fields["location"],
"start": fields["dtstart"],
"end": fields["dtend"],
"allday": fields["all_day"],
"rrule": fields["rrule"],
"status": fields["status"],
"raw": fields["raw_ical"],
"href": dav.href,
"etag": dav.etag,
},
)
upserted += 1
# Drop local events that no longer exist on the server, but never touch rows with
# unpushed local changes (dav_dirty) — including freshly-created ones not yet PUT.
if seen:
await session.execute(
text(
"DELETE FROM plg_calendar_events "
"WHERE calendar_id = :cal AND dav_dirty = false "
"AND NOT (ical_uid = ANY(:keep))"
),
{"cal": cal_id, "keep": list(seen)},
)
else:
await session.execute(
text(
"DELETE FROM plg_calendar_events "
"WHERE calendar_id = :cal AND dav_dirty = false"
),
{"cal": cal_id},
)
await session.execute(
text("UPDATE plg_calendar_calendars SET sync_token = :ct WHERE id = :id"),
{"ct": ctag, "id": cal_id},
)
return upserted
async def caldav_sync(ctx: dict[str, Any], calendar_id: str | None = None) -> None:
"""Job: sync one CalDAV calendar (``calendar_id`` given) or all of them (cron).
Enumerates via a non-RLS session (cron spans users), then applies each calendar's
upserts under that user's RLS session."""
async with _SDK.public_session() as pub:
where = "kind = 'caldav'"
params: dict[str, Any] = {}
if calendar_id:
where += " AND id = :id"
params["id"] = calendar_id
rows = (
await pub.execute(
text(
"SELECT id, user_id, source_config, secret_ct, sync_token "
f"FROM plg_calendar_calendars WHERE {where}"
),
params,
)
).mappings().all()
for cal in rows:
try:
async with _SDK.session(uuid.UUID(str(cal["user_id"]))) as s:
n = await sync_calendar(s, dict(cal))
logger.info("caldav sync: calendar %s upserted %d event(s)", cal["id"], n)
except Exception: # noqa: BLE001 - one bad calendar mustn't stop the rest
logger.exception("caldav sync failed for calendar %s", cal["id"])
@@ -322,7 +322,12 @@ const CSS = `
.dark .cal-col:hover{background:rgba(255,255,255,.05)}
.cal-col.today:hover{background:rgba(59,130,246,.11)}
.cal-col .line{height:${HOUR_PX}px;border-top:1px solid rgb(var(--app-border))}
.cal-tev{position:absolute;left:2px;width:calc(100% - 4px);border-radius:5px;border:1px solid rgb(var(--app-elevated));border-left:3px solid #3b82f6;background:rgb(var(--app-hover));padding:2px 5px;font-size:14px;overflow:hidden;cursor:pointer;box-sizing:border-box}
.cal-tev{position:absolute;left:2px;width:calc(100% - 4px);border-radius:5px;border:1px solid rgb(var(--app-elevated));border-left:3px solid #3b82f6;background:rgb(var(--app-hover));padding:2px 5px;font-size:14px;overflow:hidden;cursor:pointer;box-sizing:border-box;z-index:2}
/* all-day event in the timegrid: a full-height band filling the day column. The band
itself is click-through (so click-to-create and timed events still work); only its
label chip is interactive. */
.cal-aday{position:absolute;top:0;box-sizing:border-box;border:1px solid rgb(var(--app-elevated));border-left:3px solid #3b82f6;border-radius:5px;pointer-events:none;z-index:1}
.cal-aday .lbl{position:sticky;top:var(--cal-head-h,46px);z-index:3;pointer-events:auto;cursor:pointer;display:block;font-size:13px;font-weight:600;line-height:1.3;margin:1px;padding:3px 6px;border-radius:4px;color:rgb(var(--app-fg));background:rgb(var(--app-elevated));box-shadow:0 1px 2px rgba(0,0,0,.25);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cal-now{position:absolute;left:0;right:0;height:0;border-top:2px solid #ef4444;z-index:4}
/* right agenda */
.cal-agenda .day{display:flex;gap:14px;padding:10px 14px;border-bottom:1px solid rgb(var(--app-border))}
@@ -1411,17 +1416,33 @@ export default function mount(el, ctx, props) {
const num = `${d.getDate()}${d.getDate() === 1 ? " " + MONTHS[d.getMonth()].slice(0, 3) : ""}`;
return `<div class="d${today ? " today" : ""}">${DAYNAMES[(d.getDay() + 6) % 7].toUpperCase()}<b>${today ? `<span class="num">${d.getDate()}</span>` : num}</b></div>`;
}).join("");
const gridH = 24 * HOUR_PX;
const cols = days.map((d) => {
const today = sameDay(d, new Date());
const dayStart = startOfDay(d), dayEnd = addDays(dayStart, 1);
const lines = Array.from({ length: 24 }, () => `<div class="line"></div>`).join("");
const dayEvs = evs.filter((ev) => !ev.all_day && sameDay(ev._s, d));
const lay = layoutDay(dayEvs);
const blocks = dayEvs.map((ev) => {
// Match all-day events by their start day (like month view) so a CalDAV event
// stored as UTC-midnight..midnight doesn't spill into the next local day.
// All-day and timed events share ONE column layout: all-day events are treated
// as spanning the whole day so overlapping events sit next to each other.
const items = [
...evs.filter((ev) => ev.all_day && sameDay(ev._s, d))
.map((ev) => ({ ev, _s: dayStart, _e: dayEnd, allday: true })),
...evs.filter((ev) => !ev.all_day && sameDay(ev._s, d))
.map((ev) => ({ ev, _s: ev._s, _e: ev._e, allday: false })),
];
const lay = layoutDay(items);
const blocks = items.map((it) => {
const ev = it.ev;
const pos = lay.get(it) || { col: 0, total: 1 };
const w = 100 / pos.total;
const xy = `left:calc(${pos.col * w}% + 1px);width:calc(${w}% - 3px)`;
if (it.allday) {
return `<div class="cal-aday" style="${xy};height:${gridH}px;border-color:${esc(ev.color)};background:${esc(ev.color)}22"><span class="lbl" data-ev="${ev.id}" data-start="${esc(ev.start)}">${esc(ev.summary || "(no title)")}</span></div>`;
}
const top = (ev._s.getHours() + ev._s.getMinutes() / 60) * HOUR_PX;
const h = Math.max(18, ((ev._e - ev._s) / 3600000) * HOUR_PX);
const pos = lay.get(ev) || { col: 0, total: 1 };
const w = 100 / pos.total;
return `<div class="cal-tev" data-ev="${ev.id}" data-start="${esc(ev.start)}" style="top:${top}px;height:${h}px;left:calc(${pos.col * w}% + 1px);width:calc(${w}% - 3px);border-color:${esc(ev.color)}"><b>${esc(ev.summary || "(no title)")}</b><div style="color:rgb(var(--app-muted))">${hhmm(ev._s)}</div></div>`;
return `<div class="cal-tev" data-ev="${ev.id}" data-start="${esc(ev.start)}" style="top:${top}px;height:${h}px;${xy};border-color:${esc(ev.color)}"><b>${esc(ev.summary || "(no title)")}</b><div style="color:rgb(var(--app-muted))">${hhmm(ev._s)}</div></div>`;
}).join("");
const now = today ? `<div class="cal-now" style="top:${(new Date().getHours() + new Date().getMinutes() / 60) * HOUR_PX}px"></div>` : "";
return `<div class="cal-col${today ? " today" : ""}" data-col="${iso(d)}">${lines}${blocks}${now}</div>`;
@@ -1678,7 +1699,11 @@ export default function mount(el, ctx, props) {
function scrollTimegrid() {
const view = el.querySelector(".cal-view");
if (view) view.scrollTop = 7 * HOUR_PX; // start around 7 AM like the mockups
if (!view) return;
// Pin all-day labels just below the sticky day-header (its height is dynamic).
const head = view.querySelector(".cal-tg-head");
if (head) view.style.setProperty("--cal-head-h", head.offsetHeight + "px");
view.scrollTop = 7 * HOUR_PX; // start around 7 AM like the mockups
}
async function loadChrome() {