Files
webmail-client/plugins/webmail_plugin_calendar/webmail_plugin_calendar/router.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

945 lines
38 KiB
Python

"""HTTP surface for the calendar plugin, mounted at /api/plugins/calendar (SPEC §A).
Phase A1: calendars + events CRUD and the range-expansion events list, all through
the RLS-scoped ``sdk.session`` (per-user isolation enforced by Postgres). Recurrence
is expanded at query time (see queries.py / recurrence.py). Read-only calendars
(ICS subscriptions, A3) reject writes with 403 ``read_only_calendar``.
"""
from __future__ import annotations
import json
import logging
import secrets
import uuid
from datetime import UTC, datetime, timedelta
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse, HTMLResponse, Response
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.sdk import CurrentUserId, require_csrf, sdk
from webmail_plugin_calendar import invites
from webmail_plugin_calendar.queries import expand_range
from webmail_plugin_calendar.schemas import (
CalendarIn,
CalendarPatch,
DavConnectIn,
EditFollowingIn,
EventIn,
EventPatch,
SubscribeIn,
)
router = APIRouter()
_UI = Path(__file__).resolve().parent / "ui"
_SDK = sdk("calendar")
_log = logging.getLogger("webmail_plugin_calendar")
def _aware(dt: datetime) -> datetime:
return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
async def _ensure_default_calendar(session: AsyncSession, user_id: uuid.UUID) -> None:
count = (await session.execute(text("SELECT count(*) FROM plg_calendar_calendars"))).scalar()
if not count:
await session.execute(
text("INSERT INTO plg_calendar_calendars (user_id, name) VALUES (:u, 'Personal')"),
{"u": str(user_id)},
)
async def _assert_writable(session: AsyncSession, calendar_id: uuid.UUID) -> None:
read_only = (
await session.execute(
text("SELECT read_only FROM plg_calendar_calendars WHERE id = :id"),
{"id": str(calendar_id)},
)
).scalar_one_or_none()
if read_only is None:
raise HTTPException(status_code=404, detail="calendar_not_found")
if read_only:
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:
"""A short, unguessable slug for the public /public/{token} .ics feed. Stored in
plg_calendar_calendars.publish_token; the public route resolves it directly."""
return secrets.token_urlsafe(12)
def _calendar_ics(name: object, rows: list[dict[str, object]], *, busy_only: bool) -> bytes:
"""Build a VCALENDAR .ics for a calendar's base events (busy_only strips detail)."""
from icalendar import Calendar as ICalendar
from icalendar import Event as IEvent
from icalendar.prop import vRecur
cal = ICalendar()
cal.add("prodid", "-//Webmail//Calendar//EN")
cal.add("version", "2.0")
cal.add("x-wr-calname", name)
for r in rows:
ev = IEvent()
ev.add("uid", r["ical_uid"])
ev.add("dtstamp", datetime.now(UTC))
ev.add("dtstart", _aware(r["dtstart"])) # type: ignore[arg-type]
if r["dtend"]:
ev.add("dtend", _aware(r["dtend"])) # type: ignore[arg-type]
if busy_only:
ev.add("summary", "Busy")
else:
ev.add("summary", r["summary"] or "")
if r.get("description"):
ev.add("description", r["description"])
if r.get("location"):
ev.add("location", r["location"])
if r["rrule"]:
ev.add("rrule", vRecur.from_ical(str(r["rrule"])))
cal.add_component(ev)
return cal.to_ical()
async def _calendar_event_rows(s: AsyncSession, calendar_id: uuid.UUID) -> list[dict[str, object]]:
return [
dict(r)
for r in (
await s.execute(
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 AND dav_deleted = false"
),
{"id": str(calendar_id)},
)
).mappings().all()
]
@router.get("/calendars")
async def list_calendars(user_id: CurrentUserId) -> list[dict[str, object]]:
async with _SDK.session(user_id) as s:
await _ensure_default_calendar(s, user_id)
rows = (
await s.execute(
text(
"SELECT id, name, color, kind, read_only, publish_token, publish_busy, "
"subscription_url FROM plg_calendar_calendars ORDER BY name"
)
)
).mappings().all()
return [
{
"id": str(r["id"]),
"name": r["name"],
"color": r["color"],
"kind": r["kind"],
"read_only": r["read_only"],
"publish_token": r["publish_token"],
"publish_busy": r["publish_busy"],
"subscription_url": r["subscription_url"],
}
for r in rows
]
@router.post("/calendars", dependencies=[Depends(require_csrf)])
async def create_calendar(body: CalendarIn, user_id: CurrentUserId) -> dict[str, str]:
try:
async with _SDK.session(user_id) as s:
new_id = (
await s.execute(
text(
"INSERT INTO plg_calendar_calendars (user_id, name, color) "
"VALUES (:u, :n, :c) RETURNING id"
),
{"u": str(user_id), "n": body.name, "c": body.color},
)
).scalar_one()
except IntegrityError as exc:
raise HTTPException(status_code=409, detail="calendar_exists") from exc
return {"id": str(new_id)}
@router.patch("/calendars/{calendar_id}", dependencies=[Depends(require_csrf)])
async def patch_calendar(
calendar_id: uuid.UUID, body: CalendarPatch, user_id: CurrentUserId
) -> dict[str, str | None]:
fields = body.model_dump(exclude_unset=True)
# `publish` is an action, not a column: off => unpublish, full/busy => publish.
publish = fields.pop("publish", None)
if not fields and publish is None:
return {"status": "noop"}
async with _SDK.session(user_id) as s:
set_parts = [f"{k} = :{k}" for k in fields]
params: dict[str, object | None] = {**fields, "id": str(calendar_id)}
token: str | None = None
if publish is not None:
if publish == "off":
set_parts += ["publish_token = NULL"]
elif publish in ("full", "busy"):
existing = (
await s.execute(
text("SELECT publish_token FROM plg_calendar_calendars WHERE id = :id"),
{"id": str(calendar_id)},
)
).scalar_one_or_none()
token = existing or _make_publish_token()
set_parts += ["publish_token = :ptok", "publish_busy = :pbusy"]
params["ptok"] = token
params["pbusy"] = publish == "busy"
else:
raise HTTPException(status_code=400, detail="bad_publish")
result = await s.execute(
text(f"UPDATE plg_calendar_calendars SET {', '.join(set_parts)} WHERE id = :id"),
params,
)
if result.rowcount == 0:
raise HTTPException(status_code=404, detail="calendar_not_found")
return {"status": "ok", "publish_token": token}
@router.delete("/calendars/{calendar_id}", dependencies=[Depends(require_csrf)])
async def delete_calendar(calendar_id: uuid.UUID, user_id: CurrentUserId) -> dict[str, str]:
async with _SDK.session(user_id) as s:
result = await s.execute(
text("DELETE FROM plg_calendar_calendars WHERE id = :id"), {"id": str(calendar_id)}
)
if result.rowcount == 0:
raise HTTPException(status_code=404, detail="calendar_not_found")
return {"status": "ok"}
@router.get("/calendars/{calendar_id}/export")
async def export_calendar(calendar_id: uuid.UUID, user_id: CurrentUserId) -> Response:
"""Download a calendar's events as a standard .ics file (VCALENDAR)."""
async with _SDK.session(user_id) as s:
name = (
await s.execute(
text("SELECT name FROM plg_calendar_calendars WHERE id = :id"),
{"id": str(calendar_id)},
)
).scalar_one_or_none()
if name is None:
raise HTTPException(status_code=404, detail="calendar_not_found")
rows = await _calendar_event_rows(s, calendar_id)
safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in str(name)) or "calendar"
return Response(
content=_calendar_ics(name, rows, busy_only=False),
media_type="text/calendar",
headers={"Content-Disposition": f'attachment; filename="{safe}.ics"'},
)
@router.post("/subscriptions", dependencies=[Depends(require_csrf)])
async def subscribe_calendar(body: SubscribeIn, user_id: CurrentUserId) -> dict[str, str | int]:
"""Subscribe to an external .ics URL: fetch it (SSRF-guarded), then import its
events into a new read-only calendar. One-time import (re-sync is A3)."""
from urllib.parse import urlparse
from icalendar import Calendar as ICalendar
raw = await _SDK.http_get(body.url)
if raw is None:
raise HTTPException(status_code=400, detail="fetch_failed")
try:
ics = ICalendar.from_ical(raw)
except Exception as exc: # noqa: BLE001 - malformed feed
raise HTTPException(status_code=400, detail="bad_ics") from exc
name = body.name.strip() or str(ics.get("x-wr-calname") or "") or urlparse(body.url).netloc
events = list(ics.walk("VEVENT"))
async with _SDK.session(user_id) as s:
try:
cal_id = (
await s.execute(
text(
"INSERT INTO plg_calendar_calendars "
"(user_id, name, color, kind, read_only, subscription_url) "
"VALUES (:u, :n, :c, 'ics', true, :url) RETURNING id"
),
{"u": str(user_id), "n": name, "c": body.color, "url": body.url},
)
).scalar_one()
except IntegrityError as exc:
raise HTTPException(status_code=409, detail="calendar_exists") from exc
count = 0
for comp in events:
start = comp.get("dtstart")
if start is None:
continue
dt = start.dt
all_day = not isinstance(dt, datetime)
if not isinstance(dt, datetime):
dt = datetime(dt.year, dt.month, dt.day)
dtstart = _aware(dt)
end = comp.get("dtend")
dtend = None
if end is not None:
ed = end.dt
if not isinstance(ed, datetime):
ed = datetime(ed.year, ed.month, ed.day)
dtend = _aware(ed)
rrule = comp.get("rrule")
rrule_str = rrule.to_ical().decode() if rrule is not None else None
await s.execute(
text(
"INSERT INTO plg_calendar_events "
"(user_id, calendar_id, ical_uid, summary, description, location, dtstart, "
" dtend, all_day, rrule) VALUES "
"(:u, :cal, :uid, :sum, :desc, :loc, :start, :end, :allday, :rrule)"
),
{
"u": str(user_id),
"cal": str(cal_id),
"uid": str(comp.get("uid") or f"{uuid.uuid4()}@import"),
"sum": str(comp.get("summary") or ""),
"desc": str(comp.get("description") or ""),
"loc": str(comp.get("location") or ""),
"start": dtstart,
"end": dtend,
"allday": all_day,
"rrule": rrule_str,
},
)
count += 1
return {"id": str(cal_id), "imported": count, "name": name}
# --- 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,
start: datetime = Query(...),
end: datetime = Query(...),
q: str = Query(""),
) -> dict[str, object]:
"""List events overlapping [start, end). With ``q`` set, only events whose
summary/location/description match (case-insensitive) are returned — used by
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 AND e.dav_deleted = false"
term = q.strip()
if term:
where += (
" AND (e.summary ILIKE :q OR e.location ILIKE :q "
"OR COALESCE(e.description, '') ILIKE :q)"
)
params["q"] = f"%{term}%"
async with _SDK.session(user_id) as s:
rows = (
await s.execute(
text(
"SELECT e.id, e.calendar_id, e.ical_uid, e.summary, e.description, "
"e.location, e.dtstart, e.dtend, e.all_day, e.tz, e.rrule, e.reminders, "
"e.exdates, e.recurrence_id, e.status, "
"e.my_partstat, e.origin_message_id, e.show_as, e.conference_url, "
"e.attendees, e.organizer, "
"COALESCE(e.color, c.color) AS color "
"FROM plg_calendar_events e "
"JOIN plg_calendar_calendars c ON c.id = e.calendar_id "
f"{where}"
),
params,
)
).mappings().all()
base = [
r
for r in rows
if r["recurrence_id"] is None
and (r["rrule"] is not None or (r["dtend"] or r["dtstart"]) > range_start)
]
overrides = [r for r in rows if r["recurrence_id"] is not None]
return {"events": expand_range(base, overrides, range_start, range_end)}
async def _insert_event(
s: AsyncSession, user_id: uuid.UUID, body: EventIn, attendees: list[dict[str, str]]
) -> tuple[uuid.UUID, str]:
ical_uid = f"{uuid.uuid4()}@webmail.local"
organizer = json.dumps(body.organizer.model_dump()) if body.organizer else None
new_id = (
await s.execute(
text(
"INSERT INTO plg_calendar_events "
"(user_id, calendar_id, ical_uid, summary, description, location, dtstart, "
" dtend, all_day, tz, rrule, reminders, attendees, organizer, color, show_as, "
" conference_url) "
"VALUES (:u, :cal, :uid, :sum, :desc, :loc, :start, :end, :allday, :tz, "
" :rrule, :rem, :att, :org, :color, :showas, :conf) RETURNING id"
),
{
"u": str(user_id),
"cal": str(body.calendar_id),
"uid": ical_uid,
"sum": body.summary,
"desc": body.description,
"loc": body.location,
"start": _aware(body.dtstart),
"end": _aware(body.dtend) if body.dtend else None,
"allday": body.all_day,
"tz": body.tz,
"rrule": body.rrule,
"rem": body.reminders,
"att": json.dumps(attendees),
"org": organizer,
"color": body.color,
"showas": body.show_as,
"conf": body.conference_url,
},
)
).scalar_one()
return new_id, ical_uid
def _merge_attendees(
existing: list[dict[str, str]] | None, incoming: list[dict[str, str]]
) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
"""Merge incoming attendees over existing: preserve prior RSVP status by email,
default new guests to needs-action. Returns (merged, newly-added)."""
prior = {str(a.get("email", "")).lower(): a for a in (existing or [])}
merged: list[dict[str, str]] = []
added: list[dict[str, str]] = []
for a in incoming:
email = a.get("email") or ""
if not email:
continue
match = prior.get(email.lower())
a["partstat"] = match.get("partstat", "needs-action") if match else "needs-action"
if match is None:
added.append(a)
merged.append(a)
return merged, added
def _strip_recurrence_end(rrule: str) -> str:
"""Drop any UNTIL=/COUNT= parts so a fresh UNTIL can be appended."""
return ";".join(p for p in rrule.split(";") if not p.startswith(("UNTIL=", "COUNT=")))
async def _send_invites_from(
user_id: uuid.UUID,
event_id: uuid.UUID,
ical_uid: str,
body: EventIn,
attendees: list[dict[str, str]],
*,
sequence: int = 0,
notify: set[str] | None = None,
) -> None:
"""Best-effort iMIP send for an event described by ``body`` + ``attendees``."""
if not attendees:
return
try:
await invites.send_invites(
user_id=user_id,
event_id=event_id,
ical_uid=ical_uid,
summary=body.summary,
description=body.description,
location=body.location,
dtstart=_aware(body.dtstart),
dtend=_aware(body.dtend) if body.dtend else None,
tz=body.tz,
all_day=body.all_day,
organizer=body.organizer.model_dump() if body.organizer else None,
attendees=attendees,
conference_url=body.conference_url,
sequence=sequence,
notify_emails=notify,
)
except Exception: # noqa: BLE001 - invitation delivery is best-effort
_log.warning("calendar: failed to send invitations for %s", event_id, exc_info=True)
@router.post("/events", dependencies=[Depends(require_csrf)])
async def create_event(body: EventIn, user_id: CurrentUserId) -> dict[str, str]:
attendees = [a.model_dump() for a in body.attendees]
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}
@router.post("/events/{event_id}/edit-following", dependencies=[Depends(require_csrf)])
async def edit_following(
event_id: uuid.UUID, body: EditFollowingIn, user_id: CurrentUserId
) -> dict[str, str]:
"""Edit a recurring event with "this and following" semantics: occurrences BEFORE
the clicked one keep the original series; the clicked one onward become a new
series carrying the edits. Editing the first occurrence updates the series in place.
"""
ev = body.event
split = _aware(body.recurrence_start)
incoming = [a.model_dump() for a in ev.attendees]
async with _SDK.session(user_id) as s:
base = (
await s.execute(
text(
"SELECT calendar_id, ical_uid, dtstart, rrule, attendees, sequence "
"FROM plg_calendar_events WHERE id = :id"
),
{"id": str(event_id)},
)
).mappings().first()
if base is None:
raise HTTPException(status_code=404, detail="event_not_found")
await _assert_writable(s, base["calendar_id"])
base_start = _aware(base["dtstart"])
if base["rrule"] is None or split <= base_start:
# First occurrence (or non-recurring): update the whole series in place.
merged, added = _merge_attendees(base["attendees"], incoming)
organizer = json.dumps(ev.organizer.model_dump()) if ev.organizer else None
await s.execute(
text(
"UPDATE plg_calendar_events SET summary=:sum, description=:desc, "
"location=:loc, dtstart=:start, dtend=:end, all_day=:allday, tz=:tz, "
"rrule=:rrule, reminders=:rem, attendees=CAST(:att AS jsonb), "
"organizer=CAST(:org AS jsonb), color=:color, show_as=:showas, "
"conference_url=:conf, sequence=sequence+1 WHERE id=:id"
),
{
"sum": ev.summary, "desc": ev.description, "loc": ev.location,
"start": _aware(ev.dtstart), "end": _aware(ev.dtend) if ev.dtend else None,
"allday": ev.all_day, "tz": ev.tz, "rrule": ev.rrule,
"rem": ev.reminders, "att": json.dumps(merged), "org": organizer,
"color": ev.color, "showas": ev.show_as, "conf": ev.conference_url,
"id": str(event_id),
},
)
target_id, target_uid = event_id, str(base["ical_uid"])
notify: set[str] | None = {str(a["email"]).lower() for a in added}
seq = int(base["sequence"]) + 1
merged_att = merged
else:
# Truncate the original series just before the split, then start a new one.
until = (split - timedelta(seconds=1)).astimezone(UTC).strftime("%Y%m%dT%H%M%SZ")
trimmed = _strip_recurrence_end(str(base["rrule"]))
new_rrule = f"{trimmed};UNTIL={until}" if trimmed else f"UNTIL={until}"
await s.execute(
text("UPDATE plg_calendar_events SET rrule=:r WHERE id=:id"),
{"r": new_rrule, "id": str(event_id)},
)
merged_att = [{**a, "partstat": "needs-action"} for a in incoming]
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
)
return {"status": "ok", "id": str(target_id)}
_PATCH_DATE_FIELDS = {"dtstart", "dtend"}
@router.patch("/events/{event_id}", dependencies=[Depends(require_csrf)])
async def patch_event(
event_id: uuid.UUID, body: EventPatch, user_id: CurrentUserId
) -> dict[str, str]:
fields = body.model_dump(exclude_unset=True)
# Attendees are merged separately (preserve prior RSVP status; find new guests).
new_attendees = fields.pop("attendees", None)
if not fields and new_attendees is None:
return {"status": "noop"}
for key in _PATCH_DATE_FIELDS & fields.keys():
if fields[key] is not None:
fields[key] = _aware(fields[key])
invite_new: list[dict[str, str]] = []
merged_attendees: list[dict[str, str]] | None = None
async with _SDK.session(user_id) as s:
existing = (
await s.execute(
text(
"SELECT calendar_id, ical_uid, summary, description, location, dtstart, "
"dtend, all_day, tz, conference_url, organizer, attendees, sequence "
"FROM plg_calendar_events WHERE id = :id"
),
{"id": str(event_id)},
)
).mappings().first()
if existing is None:
raise HTTPException(status_code=404, detail="event_not_found")
await _assert_writable(s, existing["calendar_id"])
params: dict[str, object] = {"id": str(event_id)}
set_parts = [f"{k} = :{k}" for k in fields]
params.update(fields)
if new_attendees is not None:
prior = {
str(a.get("email", "")).lower(): a for a in (existing["attendees"] or [])
}
merged_attendees = []
for a in new_attendees:
email = a.get("email") or ""
if not email:
continue
match = prior.get(email.lower())
a["partstat"] = match.get("partstat", "needs-action") if match else "needs-action"
if match is None:
invite_new.append(a)
merged_attendees.append(a)
set_parts.append("attendees = CAST(:attendees AS jsonb)")
params["attendees"] = json.dumps(merged_attendees)
# Bump SEQUENCE on every edit — iMIP re-sends require a higher sequence.
await s.execute(
text(
f"UPDATE plg_calendar_events SET {', '.join(set_parts)}, "
"sequence = sequence + 1 WHERE id = :id"
),
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:
def eff(key: str) -> object:
return fields[key] if key in fields else existing[key]
try:
await invites.send_invites(
user_id=user_id,
event_id=event_id,
ical_uid=str(existing["ical_uid"]),
summary=str(eff("summary") or ""),
description=str(eff("description") or ""),
location=str(eff("location") or ""),
dtstart=_aware(eff("dtstart")), # type: ignore[arg-type]
dtend=_aware(eff("dtend")) if eff("dtend") else None, # type: ignore[arg-type]
tz=str(eff("tz") or "UTC"),
all_day=bool(eff("all_day")),
organizer=existing["organizer"],
attendees=merged_attendees,
conference_url=str(eff("conference_url") or ""),
sequence=int(existing["sequence"]) + 1,
notify_emails={str(a["email"]).lower() for a in invite_new},
)
except Exception: # noqa: BLE001 - invitation delivery is best-effort
_log.warning("calendar: edit invitations failed for %s", event_id, exc_info=True)
return {"status": "ok"}
@router.delete("/events/{event_id}", dependencies=[Depends(require_csrf)])
async def delete_event(
event_id: uuid.UUID,
user_id: CurrentUserId,
scope: str = Query("all"),
occurrence: datetime | None = Query(None),
) -> dict[str, str]:
"""Delete an event. For recurring events ``scope`` selects what to remove:
``all`` (whole series), ``this`` (add an EXDATE for ``occurrence``), or
``following`` (truncate the series just before ``occurrence``)."""
async with _SDK.session(user_id) as s:
row = (
await s.execute(
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(remove_sql), {"id": str(event_id)})
elif scope == "this":
await s.execute(
text(
"UPDATE plg_calendar_events "
"SET exdates = array_append(exdates, :occ) WHERE id = :id"
),
{"occ": _aware(occurrence), "id": str(event_id)},
)
elif scope == "following":
occ = _aware(occurrence)
if occ <= _aware(row["dtstart"]):
# Truncating from the first occurrence removes everything.
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"]))
new_rrule = f"{trimmed};UNTIL={until}" if trimmed else f"UNTIL={until}"
await s.execute(
text("UPDATE plg_calendar_events SET rrule = :r WHERE id = :id"),
{"r": new_rrule, "id": str(event_id)},
)
else:
raise HTTPException(status_code=400, detail="bad_scope")
if row["kind"] == "caldav":
await _enqueue_dav_sync(row["calendar_id"])
return {"status": "ok"}
# --- public read-only .ics feed (published calendars; no login) ---------------
@router.get("/public/{token}")
async def public_calendar(token: str) -> Response:
# Public + unauthenticated: resolve the opaque slug directly (privileged session,
# every query filtered explicitly by the token / its calendar id — no RLS).
async with _SDK.public_session() as s:
row = (
await s.execute(
text(
"SELECT id, name, publish_busy FROM plg_calendar_calendars "
"WHERE publish_token = :tok"
),
{"tok": token},
)
).mappings().first()
if row is None:
raise HTTPException(status_code=404, detail="not_found")
rows = await _calendar_event_rows(s, row["id"])
return Response(
content=_calendar_ics(row["name"], rows, busy_only=row["publish_busy"]),
media_type="text/calendar",
)
# --- RSVP (public: guests respond from the invitation email, no login) --------
def _rsvp_page(message: str) -> str:
return (
"<!doctype html><html><head><meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width, initial-scale=1'>"
"<title>Invitation response</title></head>"
"<body style='margin:0;font-family:Arial,Helvetica,sans-serif;background:#f1f3f4;"
"color:#3c4043'><div style='max-width:460px;margin:64px auto;background:#fff;"
"border:1px solid #e0e0e0;border-radius:8px;padding:32px;text-align:center'>"
"<h2 style='margin:0 0 12px;font-size:18px'>Webmail Calendar</h2>"
f"<p style='margin:0;color:#5f6368'>{message}</p></div></body></html>"
)
@router.get("/rsvp")
async def rsvp(t: str = Query(...), r: str = Query(...)) -> HTMLResponse:
parsed = invites.read_token(t)
if parsed is None or r not in invites.RESPONSES:
return HTMLResponse(
_rsvp_page("This invitation link is invalid or has expired."), status_code=400
)
user_id, event_id, email = parsed
async with _SDK.session(user_id) as s:
row = (
await s.execute(
text("SELECT attendees FROM plg_calendar_events WHERE id = :id"),
{"id": str(event_id)},
)
).mappings().first()
if row is None:
return HTMLResponse(_rsvp_page("This event no longer exists."), status_code=404)
attendees = list(row["attendees"] or [])
matched = False
for a in attendees:
if str(a.get("email", "")).lower() == email.lower():
a["partstat"] = r
matched = True
if not matched:
return HTMLResponse(
_rsvp_page("You are not on the guest list for this event."), status_code=404
)
await s.execute(
text("UPDATE plg_calendar_events SET attendees = CAST(:att AS jsonb) WHERE id = :id"),
{"att": json.dumps(attendees), "id": str(event_id)},
)
return HTMLResponse(_rsvp_page(f"Your response “{invites.RESPONSES[r]}” was recorded."))
# --- bundle ------------------------------------------------------------------
@router.get("/ui/{filename}")
async def bundle(filename: str) -> FileResponse:
if filename != "page.js":
raise HTTPException(status_code=404)
# `no-cache` = the browser must revalidate before reusing (ETag makes a 304 cheap),
# so an edited bundle is picked up on the next page reload instead of a stale copy.
return FileResponse(
_UI / filename,
media_type="text/javascript",
headers={"Cache-Control": "no-cache"},
)