Files
webmail-client/plugins/webmail_plugin_calendar/webmail_plugin_calendar/sync.py
T
lukasandClaude Opus 4.8 a27755a4d3 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>
2026-08-08 10:27:21 +02:00

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"])