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>
945 lines
38 KiB
Python
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"},
|
|
)
|