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>
200 lines
8.0 KiB
Python
200 lines
8.0 KiB
Python
"""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"])
|