From a27755a4d360c3f9d09730dd9ee105c8490a6b9d Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Sat, 8 Aug 2026 10:27:21 +0200 Subject: [PATCH] Calendar: CalDAV two-way sync (iCloud/generic) + all-day timegrid fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/tests/plugins/test_calendar_caldav.py | 122 ++++++ frontend/src/i18n/en.ts | 20 + frontend/src/lib/api.ts | 13 + frontend/src/routes/settings/Settings.tsx | 140 ++++++- .../webmail_plugin_calendar/__init__.py | 5 +- .../webmail_plugin_calendar/caldav.py | 390 ++++++++++++++++++ .../migrations/versions/0005_caldav_sync.py | 45 ++ .../0006_events_uid_nulls_not_distinct.py | 54 +++ .../versions/0007_dav_dirty_trigger.py | 59 +++ .../webmail_plugin_calendar/router.py | 187 ++++++++- .../webmail_plugin_calendar/schemas.py | 7 + .../webmail_plugin_calendar/sync.py | 199 +++++++++ .../webmail_plugin_calendar/ui/page.js | 41 +- 13 files changed, 1260 insertions(+), 22 deletions(-) create mode 100644 backend/tests/plugins/test_calendar_caldav.py create mode 100644 plugins/webmail_plugin_calendar/webmail_plugin_calendar/caldav.py create mode 100644 plugins/webmail_plugin_calendar/webmail_plugin_calendar/migrations/versions/0005_caldav_sync.py create mode 100644 plugins/webmail_plugin_calendar/webmail_plugin_calendar/migrations/versions/0006_events_uid_nulls_not_distinct.py create mode 100644 plugins/webmail_plugin_calendar/webmail_plugin_calendar/migrations/versions/0007_dav_dirty_trigger.py create mode 100644 plugins/webmail_plugin_calendar/webmail_plugin_calendar/sync.py diff --git a/backend/tests/plugins/test_calendar_caldav.py b/backend/tests/plugins/test_calendar_caldav.py new file mode 100644 index 0000000..fffa4b8 --- /dev/null +++ b/backend/tests/plugins/test_calendar_caldav.py @@ -0,0 +1,122 @@ +"""CalDAV client pure helpers — XML multistatus / REPORT parsing + ICS round-trip.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from webmail_plugin_calendar.caldav import ( + build_ics, + event_href, + parse_href, + parse_multistatus_calendars, + parse_report, + vevent_to_fields, +) + +_PRINCIPAL_XML = """ + + /HTTP/1.1 200 OK + /123/principal/ + +""" + +_CAL_LIST_XML = """ + + /123/calendars/home/HTTP/1.1 200 OK + + /123/calendars/work/HTTP/1.1 200 OK + + + Work + #ff0000ff + ctag-42 + + + + + /123/calendars/holidays/ + HTTP/1.1 200 OK + + + Holidays + + + + +""" + +_REPORT_XML = """ + + /123/calendars/work/ev1.ics + HTTP/1.1 200 OK + "etag-1" + BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:ev1@example.com +SUMMARY:Standup +DTSTART:20260810T090000Z +DTEND:20260810T091500Z +END:VEVENT +END:VCALENDAR +""" + + +def test_parse_href_principal() -> None: + assert parse_href(_PRINCIPAL_XML, "{DAV:}current-user-principal") == "/123/principal/" + + +def test_parse_calendar_list_filters_and_reads_props() -> None: + cals = parse_multistatus_calendars(_CAL_LIST_XML, "https://dav.example.com/123/calendars/") + # The home collection (no resourcetype) is skipped; two calendars remain. + by_name = {c.name: c for c in cals} + assert set(by_name) == {"Work", "Holidays"} + work = by_name["Work"] + assert work.url == "https://dav.example.com/123/calendars/work/" + assert work.color == "#ff0000" # trimmed to #rrggbb + assert work.ctag == "ctag-42" + assert work.read_only is False # has + assert by_name["Holidays"].read_only is True # read privilege only + + +def test_parse_report_events() -> None: + events = parse_report(_REPORT_XML, "https://dav.example.com/123/calendars/work/") + assert len(events) == 1 + ev = events[0] + assert ev.href == "https://dav.example.com/123/calendars/work/ev1.ics" + assert ev.etag == "etag-1" # quotes stripped + fields = vevent_to_fields(ev.ics) + assert fields is not None + assert fields["ical_uid"] == "ev1@example.com" + assert fields["summary"] == "Standup" + assert fields["dtstart"] == datetime(2026, 8, 10, 9, 0, tzinfo=UTC) + assert fields["all_day"] is False + + +def test_build_ics_roundtrips() -> None: + ics = build_ics( + { + "ical_uid": "made@ultimo", + "summary": "Lunch", + "location": "Cafe", + "dtstart": datetime(2026, 8, 11, 12, 0, tzinfo=UTC), + "dtend": datetime(2026, 8, 11, 13, 0, tzinfo=UTC), + "all_day": False, + "rrule": "FREQ=WEEKLY;BYDAY=MO", + } + ) + fields = vevent_to_fields(ics) + assert fields is not None + assert fields["ical_uid"] == "made@ultimo" + assert fields["summary"] == "Lunch" + assert fields["location"] == "Cafe" + assert fields["rrule"] == "FREQ=WEEKLY;BYDAY=MO" + assert fields["dtstart"] == datetime(2026, 8, 11, 12, 0, tzinfo=UTC) + + +def test_event_href() -> None: + assert ( + event_href("https://dav.example.com/cal/work", "abc@ex") + == "https://dav.example.com/cal/work/abc@ex.ics" + ) diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 80aa59e..c14a0f0 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -361,6 +361,26 @@ export const en = { "settings.subscribed": "Subscribed", "settings.comingSoon": "Coming soon", "settings.subscribeError": "Couldn't subscribe to that URL.", + "settings.connectTitle": "Connect {provider}", + "settings.connect": "Connect", + "settings.connecting": "Connecting…", + "settings.connectError": "Couldn't connect. Check the details and try again.", + "settings.connectAuthFailed": + "Sign-in was rejected. If you have two-factor auth on, use an app password (not your normal password).", + "settings.connectPrivateHost": + "That server is on a private network, which isn't reachable for security reasons. Use a publicly-accessible HTTPS address.", + "settings.connectUnreachable": + "Couldn't reach that server. Check the URL is correct and uses https://.", + "settings.connectNoCalendars": "Connected, but no calendars were found on that account.", + "settings.connectNeedsUrl": "Enter the CalDAV server URL.", + "settings.davServerUrl": "CalDAV server URL", + "settings.appleId": "Apple ID (email)", + "settings.username": "Username", + "settings.appPassword": "App-specific password", + "settings.password": "Password", + "settings.icloudHint": + "Use an app-specific password from account.apple.com → Sign-In and Security.", + "settings.calendarCount": "{count} calendars", "settings.removeSubscription": "Remove subscription", "settings.removeSubscriptionConfirm": "Stop syncing “{name}” and remove its events?", "settings.deleteCalendar": "Delete calendar", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8dbc023..26a3ce1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -215,6 +215,19 @@ export const api = { method: "POST", body: JSON.stringify(body), }), + davConnect: (body: { provider: string; server_url?: string; username: string; password: string }) => + apiFetch<{ account_id: string; calendars: { id: string; name: string }[] }>( + "/plugins/calendar/dav/connect", + { method: "POST", body: JSON.stringify(body) }, + ), + davAccounts: () => + apiFetch<{ account_id: string; provider: string; username: string; calendars: { id: string; name: string }[] }[]>( + "/plugins/calendar/dav/accounts", + ), + davDeleteAccount: (accountId: string) => + apiFetch<{ status: string }>(`/plugins/calendar/dav/accounts/${accountId}`, { + method: "DELETE", + }), // --- saved searches (virtual mailboxes) --- savedSearches: () => apiFetch("/saved-searches"), diff --git a/frontend/src/routes/settings/Settings.tsx b/frontend/src/routes/settings/Settings.tsx index 645c383..eb39b3c 100644 --- a/frontend/src/routes/settings/Settings.tsx +++ b/frontend/src/routes/settings/Settings.tsx @@ -965,6 +965,39 @@ function CalendarList({ ["iCloud", "icloud"], ["CalDAV", "caldav"], ]; + // CalDAV account connect (iCloud / generic). Google needs OAuth — left disabled. + const davAccounts = useQuery({ queryKey: ["dav-accounts"], queryFn: api.davAccounts }); + const [connecting, setConnecting] = useState<"icloud" | "caldav" | null>(null); + const [davUser, setDavUser] = useState(""); + const [davPass, setDavPass] = useState(""); + const [davUrl, setDavUrl] = useState(""); + const refreshCalendars = () => { + queryClient.invalidateQueries({ queryKey: ["calendars"] }); + queryClient.invalidateQueries({ queryKey: ["dav-accounts"] }); + }; + const closeConnect = () => { + setConnecting(null); + setDavUser(""); + setDavPass(""); + setDavUrl(""); + }; + const connect = useMutation({ + mutationFn: () => + api.davConnect({ + provider: connecting ?? "caldav", + server_url: davUrl.trim(), + username: davUser.trim(), + password: davPass, + }), + onSuccess: () => { + closeConnect(); + refreshCalendars(); + }, + }); + const disconnect = useMutation({ + mutationFn: (accountId: string) => api.davDeleteAccount(accountId), + onSuccess: refreshCalendars, + }); return (

{t("settings.calendars")}

@@ -1010,18 +1043,117 @@ function CalendarList({

{t("settings.addAccount")}

{t("settings.addAccountHint")}

+ + {(davAccounts.data ?? []).length > 0 && ( +
+ {(davAccounts.data ?? []).map((a) => ( +
+
+
{a.username || a.provider}
+
+ {a.provider === "icloud" ? "iCloud" : "CalDAV"} ·{" "} + {t("settings.calendarCount", { count: a.calendars.length })} +
+
+ +
+ ))} +
+ )} +
- {accounts.map(([label]) => ( + {accounts.map(([label, key]) => ( ))}
+ + {connecting && ( +
+
+ {t("settings.connectTitle", { provider: connecting === "icloud" ? "iCloud" : "CalDAV" })} +
+ {connecting === "caldav" && ( + setDavUrl(e.target.value)} + /> + )} + setDavUser(e.target.value)} + autoFocus + /> + setDavPass(e.target.value)} + /> + {connecting === "icloud" && ( +

{t("settings.icloudHint")}

+ )} +
+ + +
+ {connect.isError && ( +

+ {(() => { + const code = + connect.error instanceof ApiError ? connect.error.message : ""; + const keys = { + auth_failed: "settings.connectAuthFailed", + private_host: "settings.connectPrivateHost", + unreachable: "settings.connectUnreachable", + no_calendars: "settings.connectNoCalendars", + server_url_required: "settings.connectNeedsUrl", + } as const; + const key = keys[code as keyof typeof keys]; + return t(key ?? "settings.connectError"); + })()} +

+ )} +
+ )}
diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/__init__.py b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/__init__.py index ae15e7d..f051b8e 100644 --- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/__init__.py +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/__init__.py @@ -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=[ diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/caldav.py b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/caldav.py new file mode 100644 index 0000000..89982b0 --- /dev/null +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/caldav.py @@ -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'' + b'' +) +_HOME_BODY = ( + b'' + b'' + b"" +) +_CAL_LIST_BODY = ( + b'' + b'' + b"" + b"" +) +_CTAG_BODY = ( + b'' + b'' + b"" +) +_EVENTS_QUERY = ( + b'' + b'' + b"" + b'' + b"" +) + + +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 ```` 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 ```` 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") diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/migrations/versions/0005_caldav_sync.py b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/migrations/versions/0005_caldav_sync.py new file mode 100644 index 0000000..67c0d8b --- /dev/null +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/migrations/versions/0005_caldav_sync.py @@ -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") diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/migrations/versions/0006_events_uid_nulls_not_distinct.py b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/migrations/versions/0006_events_uid_nulls_not_distinct.py new file mode 100644 index 0000000..1a8ef30 --- /dev/null +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/migrations/versions/0006_events_uid_nulls_not_distinct.py @@ -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)" + ) diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/migrations/versions/0007_dav_dirty_trigger.py b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/migrations/versions/0007_dav_dirty_trigger.py new file mode 100644 index 0000000..8cca881 --- /dev/null +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/migrations/versions/0007_dav_dirty_trigger.py @@ -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()") diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/router.py b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/router.py index c42e670..121988e 100644 --- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/router.py +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/router.py @@ -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"} diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/schemas.py b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/schemas.py index 5305108..4869736 100644 --- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/schemas.py +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/schemas.py @@ -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 = "" diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/sync.py b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/sync.py new file mode 100644 index 0000000..5d71ac6 --- /dev/null +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/sync.py @@ -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"]) diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js index 310b27a..bebe860 100644 --- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js @@ -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 `
${DAYNAMES[(d.getDay() + 6) % 7].toUpperCase()}${today ? `${d.getDate()}` : num}
`; }).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 }, () => `
`).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 `
${esc(ev.summary || "(no title)")}
`; + } 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 `
${esc(ev.summary || "(no title)")}
${hhmm(ev._s)}
`; + return `
${esc(ev.summary || "(no title)")}
${hhmm(ev._s)}
`; }).join(""); const now = today ? `
` : ""; return `
${lines}${blocks}${now}
`; @@ -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() {