Deleting an event the user organizes, with other guests, asks "Would you like to inform the participants that this event is canceled?" (Yes, inform them and delete / No, just delete / Cancel; for a recurring one after which occurrences). Yes sends each guest an iTIP CANCEL from the organizer's mailbox — of one occurrence via RECURRENCE-ID — or, for "this and following", the shortened series. Invitations now carry their RRULE, so recurring events repeat for guests too. An organizer's cancellation is applied as the email arrives (message.new hook) or, for older mail, when the card is opened — always, whatever the settings: only from that event's organizer and not older than the calendar's copy; one occurrence becomes an EXDATE. The card says "Event was removed from calendar". Settings → Calendars → Invitations now work, defaulting to "Do not automatically update my calendar": "Only update events that I have already added" applies the organizer's changes (keeping the user's answer), "Add new events…" also adds new invitations, unanswered — from contacts or anyone — to the chosen calendar, and "mark as read and file" archives such messages. With no automatic updates, a changed invitation says so on its card. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1560 lines
64 KiB
Python
1560 lines
64 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 re
|
|
import secrets
|
|
import uuid
|
|
from datetime import UTC, datetime, timedelta
|
|
from html import escape as html_escape
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
|
|
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 imip, invites
|
|
from webmail_plugin_calendar.queries import expand_range
|
|
from webmail_plugin_calendar.schemas import (
|
|
CalendarIn,
|
|
CalendarPatch,
|
|
DavConnectIn,
|
|
EditFollowingIn,
|
|
EventIn,
|
|
EventPatch,
|
|
InvitationAnswer,
|
|
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."""
|
|
async with _SDK.session(user_id) as s:
|
|
return {"events": await _events_between(s, _aware(start), _aware(end), q)}
|
|
|
|
|
|
async def _events_between(
|
|
s: AsyncSession, range_start: datetime, range_end: datetime, q: str = ""
|
|
) -> list[dict[str, object]]:
|
|
"""Every event occurrence overlapping [range_start, range_end), recurrence
|
|
expanded (the calendar views, search, and an invitation's conflicts)."""
|
|
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}%"
|
|
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 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,
|
|
rrule=body.rrule,
|
|
)
|
|
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),
|
|
notify: bool = Query(False),
|
|
) -> dict[str, object]:
|
|
"""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``).
|
|
|
|
With ``notify``, the guests of an event the user organizes are told: an iTIP
|
|
CANCEL (for ``this``, of that one occurrence), or for ``following`` the
|
|
shortened series — so their calendars drop it too."""
|
|
async with _SDK.session(user_id) as s:
|
|
row = (
|
|
await s.execute(
|
|
text(
|
|
"SELECT e.calendar_id, e.dtstart, e.dtend, e.rrule, e.ical_uid, e.sequence, "
|
|
"e.summary, e.description, e.location, e.all_day, e.tz, e.organizer, "
|
|
"e.attendees, e.conference_url, 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
|
|
whole = not recurring or scope == "all" or occurrence is None
|
|
new_rrule: str | None = None
|
|
if whole:
|
|
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)})
|
|
whole = True
|
|
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 not whole: # the event lives on, changed: guests' copies must yield to it
|
|
await s.execute(
|
|
text("UPDATE plg_calendar_events SET sequence = sequence + 1 WHERE id = :id"),
|
|
{"id": str(event_id)},
|
|
)
|
|
if row["kind"] == "caldav":
|
|
await _enqueue_dav_sync(row["calendar_id"])
|
|
notified = 0
|
|
if notify:
|
|
notified = await _notify_guests_of_deletion(
|
|
user_id, event_id, dict(row), whole=whole, scope=scope,
|
|
occurrence=_aware(occurrence) if occurrence else None, new_rrule=new_rrule,
|
|
)
|
|
return {"status": "ok", "notified": notified}
|
|
|
|
|
|
async def _notify_guests_of_deletion(
|
|
user_id: uuid.UUID,
|
|
event_id: uuid.UUID,
|
|
row: dict[str, Any],
|
|
*,
|
|
whole: bool,
|
|
scope: str,
|
|
occurrence: datetime | None,
|
|
new_rrule: str | None,
|
|
) -> int:
|
|
"""Tell the guests of a deleted (or shortened) event the user organized.
|
|
Best-effort: the deletion stands whatever the mail does."""
|
|
mine = {e.lower() for _a, e, _n in await _SDK.accounts.of_user(user_id)}
|
|
organizer = row["organizer"] or None
|
|
if organizer and str(organizer.get("email", "")).lower() not in mine:
|
|
return 0 # someone else's event: not the user's to cancel for its guests
|
|
attendees = list(row["attendees"] or [])
|
|
guests = {
|
|
str(a.get("email", "")).lower()
|
|
for a in attendees
|
|
if a.get("email") and str(a["email"]).lower() not in mine
|
|
}
|
|
if not guests:
|
|
return 0
|
|
sequence = int(row["sequence"] or 0) + 1
|
|
start, end = _aware(row["dtstart"]), _aware(row["dtend"]) if row["dtend"] else None
|
|
try:
|
|
if scope == "following" and not whole and new_rrule:
|
|
# Not a cancellation: the series ends earlier — an updated invitation.
|
|
await invites.send_invites(
|
|
user_id=user_id, event_id=event_id, ical_uid=row["ical_uid"],
|
|
summary=row["summary"], description=row["description"],
|
|
location=row["location"], dtstart=start, dtend=end, tz=row["tz"],
|
|
all_day=row["all_day"], organizer=organizer, attendees=attendees,
|
|
conference_url=row["conference_url"] or "", sequence=sequence,
|
|
notify_emails=guests, rrule=new_rrule,
|
|
)
|
|
return len(guests)
|
|
one = scope == "this" and not whole and occurrence is not None
|
|
if one: # just that occurrence: its own times
|
|
assert occurrence is not None
|
|
length = (end - start) if end else timedelta(hours=1)
|
|
start, end = occurrence, occurrence + length
|
|
return await invites.send_cancellation(
|
|
user_id=user_id, ical_uid=row["ical_uid"], summary=row["summary"],
|
|
dtstart=start, dtend=end, tz=row["tz"], all_day=row["all_day"],
|
|
organizer=organizer, attendees=attendees, sequence=sequence,
|
|
notify_emails=guests, rrule=row["rrule"],
|
|
recurrence_id=occurrence if one else None,
|
|
)
|
|
except Exception: # noqa: BLE001 - the event is deleted either way
|
|
_log.warning("calendar: telling guests of %s failed", event_id, exc_info=True)
|
|
return 0
|
|
|
|
|
|
# --- 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 _logo_svg() -> str:
|
|
"""The Ultimo signet, inline (a guest's browser loads nothing else), 56 px wide."""
|
|
try:
|
|
svg = (_UI / "ultimo-logo-signet.svg").read_text(encoding="utf-8")
|
|
except OSError: # never let a missing picture stop the calendar from loading
|
|
return ""
|
|
svg = re.sub(r"<\?xml[^>]*\?>|<!--.*?-->", "", svg, flags=re.S).strip()
|
|
# Inkscape sizes it in mm: let the box decide (the viewBox keeps its shape).
|
|
return re.sub(r"<svg\b([^>]*?)\swidth=\"[^\"]*\"([^>]*?)\sheight=\"[^\"]*\"",
|
|
r'<svg\1 width="56"\2 height="54"', svg, count=1)
|
|
|
|
|
|
_LOGO = _logo_svg()
|
|
|
|
|
|
def _rsvp_page(message: str) -> str:
|
|
"""What a guest sees after clicking Yes / No / Maybe in our invitation."""
|
|
return (
|
|
"<!doctype html><html><head><meta charset='utf-8'>"
|
|
"<meta name='viewport' content='width=device-width, initial-scale=1'>"
|
|
"<title>Invitation response · Ultimo Calendar</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'>"
|
|
f"<div style='display:flex;justify-content:center;margin:0 0 14px'>{_LOGO}</div>"
|
|
"<h2 style='margin:0 0 12px;font-size:18px'>Ultimo 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."))
|
|
|
|
|
|
# --- invitations received by email (iMIP) -----------------------------------
|
|
#
|
|
# The reading pane shows an invitation email as a card (core MessageInvitation):
|
|
# when, what, where, who organizes, what else is in the calendar then, and
|
|
# Yes / No / Maybe. Answering adds (or updates, or removes) the event in the
|
|
# user's calendar and sends the organizer an iTIP REPLY, so their calendar
|
|
# records the answer too.
|
|
|
|
_ANSWERS = ("accepted", "declined", "tentative")
|
|
|
|
|
|
def _declined_key(inv: dict[str, Any]) -> str:
|
|
"""Where a "No" is remembered: declining takes the event out of the calendar,
|
|
so the calendar no longer holds the answer the card should show."""
|
|
rid = inv["recurrence_id"].isoformat() if inv["recurrence_id"] else ""
|
|
return f"declined:{inv['uid']}:{rid}"
|
|
|
|
|
|
async def _invitation(user_id: uuid.UUID, message_id: uuid.UUID) -> tuple[str, dict[str, Any]]:
|
|
raw = await _SDK.messages.raw(user_id, message_id)
|
|
ics = imip.calendar_part(raw) if raw else None
|
|
inv = imip.parse_invite(ics) if ics else None
|
|
if ics is None or inv is None or not inv["uid"]:
|
|
raise HTTPException(status_code=404, detail="no_invitation")
|
|
return ics, inv
|
|
|
|
|
|
async def _me(user_id: uuid.UUID, inv: dict[str, Any]) -> dict[str, str] | None:
|
|
"""The user among the invitation's attendees (any of their accounts)."""
|
|
mine = {e.lower(): n for _a, e, n in await _SDK.accounts.of_user(user_id)}
|
|
for a in inv["attendees"]:
|
|
if a["email"] in mine:
|
|
name = a.get("name") or mine[a["email"]]
|
|
return {"email": a["email"], "name": name, "partstat": a["partstat"]}
|
|
return None
|
|
|
|
|
|
async def _invited_copy(s: AsyncSession, inv: dict[str, Any]) -> dict[str, Any] | None:
|
|
"""The event this invitation already put in the user's calendar, if any."""
|
|
row = (
|
|
await s.execute(
|
|
text(
|
|
"SELECT e.id, e.calendar_id, e.sequence, e.my_partstat, e.organizer, e.rrule, "
|
|
"c.kind, c.name "
|
|
"FROM plg_calendar_events e JOIN plg_calendar_calendars c ON c.id = e.calendar_id "
|
|
"WHERE e.ical_uid = :uid AND e.recurrence_id IS NOT DISTINCT FROM :rid "
|
|
"AND e.dav_deleted = false ORDER BY e.sequence DESC LIMIT 1"
|
|
),
|
|
{"uid": inv["uid"], "rid": inv["recurrence_id"]},
|
|
)
|
|
).mappings().first()
|
|
return dict(row) if row else None
|
|
|
|
|
|
async def _invitation_calendar(s: AsyncSession, user_id: uuid.UUID) -> uuid.UUID:
|
|
"""Where accepted invitations go: Settings → Calendars → Invitations, else
|
|
the default calendar, else the first one that can be written to."""
|
|
settings = await _SDK.accounts.settings_of(user_id)
|
|
await _ensure_default_calendar(s, user_id)
|
|
rows = (
|
|
await s.execute(
|
|
text("SELECT id FROM plg_calendar_calendars WHERE read_only = false ORDER BY name")
|
|
)
|
|
).scalars().all()
|
|
writable = [str(r) for r in rows]
|
|
for wanted in (settings.get("invitation_calendar"), settings.get("default_calendar")):
|
|
if wanted and str(wanted) in writable:
|
|
return uuid.UUID(str(wanted))
|
|
return uuid.UUID(writable[0])
|
|
|
|
|
|
def _cancelled_key(inv: dict[str, Any]) -> str:
|
|
rid = inv["recurrence_id"].isoformat() if inv["recurrence_id"] else ""
|
|
return f"cancelled:{inv['uid']}:{rid}"
|
|
|
|
|
|
async def apply_cancellation(user_id: uuid.UUID, inv: dict[str, Any]) -> str:
|
|
"""Apply an organizer's cancellation (iTIP CANCEL) to the user's calendar.
|
|
|
|
Only a genuine one: the same event, sent by that event's own organizer, and
|
|
not older than the version in the calendar — anyone can send an email that
|
|
claims to cancel a meeting. A cancelled occurrence of a series becomes an
|
|
EXDATE. Returns ``removed`` | ``not_in_calendar`` | ``not_organizer`` |
|
|
``outdated``. Applied whatever Settings → Invitations say (the one exception).
|
|
Idempotent: safe from both the new-mail hook and the card.
|
|
"""
|
|
kv = _SDK.kv(user_id)
|
|
dav_calendar: str | None = None
|
|
async with _SDK.session(user_id) as s:
|
|
target = await _invited_copy(s, inv)
|
|
occurrence_of_series = False
|
|
if target is None and inv["recurrence_id"] is not None:
|
|
# One occurrence of a series held as a single row: cancel that date.
|
|
target = await _invited_copy(s, {**inv, "recurrence_id": None})
|
|
occurrence_of_series = target is not None and target["rrule"] is not None
|
|
if target is not None and not occurrence_of_series:
|
|
target = None
|
|
if target is None:
|
|
return "removed" if await kv.get(_cancelled_key(inv)) else "not_in_calendar"
|
|
stored = target["organizer"] or {}
|
|
sender = inv["organizer"] or {}
|
|
if str(stored.get("email", "")).lower() != str(sender.get("email", "")).lower():
|
|
return "not_organizer"
|
|
if inv["sequence"] < int(target["sequence"] or 0):
|
|
return "outdated"
|
|
if occurrence_of_series:
|
|
await s.execute(
|
|
text(
|
|
"UPDATE plg_calendar_events SET exdates = array_append(exdates, :occ) "
|
|
"WHERE id = :id AND NOT (:occ = ANY(exdates))"
|
|
),
|
|
{"occ": inv["recurrence_id"], "id": str(target["id"])},
|
|
)
|
|
else:
|
|
await s.execute(
|
|
text(
|
|
"UPDATE plg_calendar_events SET dav_deleted = true WHERE id = :id"
|
|
if target["kind"] == "caldav"
|
|
else "DELETE FROM plg_calendar_events WHERE id = :id"
|
|
),
|
|
{"id": str(target["id"])},
|
|
)
|
|
if target["kind"] == "caldav":
|
|
dav_calendar = str(target["calendar_id"])
|
|
if dav_calendar:
|
|
await _enqueue_dav_sync(uuid.UUID(dav_calendar))
|
|
await kv.set(_cancelled_key(inv), True)
|
|
_log.info("calendar: applied the organizer's cancellation of %s", inv["uid"])
|
|
return "removed"
|
|
|
|
|
|
# --- Settings → Calendars → Invitations: what an emailed event does on its own -----
|
|
|
|
|
|
async def _invitation_settings(user_id: uuid.UUID) -> dict[str, Any]:
|
|
s = await _SDK.accounts.settings_of(user_id)
|
|
policy = str(s.get("invitation_policy") or "none")
|
|
return {
|
|
# none: wait for the user · update_only: keep added events current (changes,
|
|
# cancellations) · add_update: that, and add new invitations too.
|
|
"policy": policy if policy in ("none", "update_only", "add_update") else "none",
|
|
"from": str(s.get("invitation_from") or "contacts"),
|
|
"file": bool(s.get("invitation_file", False)),
|
|
}
|
|
|
|
|
|
async def _is_contact(s: AsyncSession, email: str) -> bool:
|
|
"""Whether ``email`` is in the user's contacts (Settings: "Add new events
|
|
from My contacts"). No contacts plugin, no contacts."""
|
|
try:
|
|
async with s.begin_nested(): # a missing table must not spoil the transaction
|
|
row = await s.execute(
|
|
text(
|
|
"SELECT 1 FROM plg_contacts_contacts WHERE lower(default_email) = :e "
|
|
"OR EXISTS (SELECT 1 FROM jsonb_array_elements(emails) x "
|
|
" WHERE lower(x->>'value') = :e) LIMIT 1"
|
|
),
|
|
{"e": email.lower()},
|
|
)
|
|
return row.first() is not None
|
|
except Exception: # noqa: BLE001
|
|
return False
|
|
|
|
|
|
async def apply_update(
|
|
user_id: uuid.UUID, inv: dict[str, Any], ics: str, message_id: uuid.UUID
|
|
) -> str:
|
|
"""An organizer's changed invitation (new time, place, …) for an event the
|
|
user has: bring it up to date, keeping the user's own answer. Only from that
|
|
event's organizer, and only newer. ``updated`` | ``unchanged`` |
|
|
``not_in_calendar`` | ``not_organizer``."""
|
|
dav_calendar: str | None = None
|
|
async with _SDK.session(user_id) as s:
|
|
copy = await _invited_copy(s, inv)
|
|
if copy is None:
|
|
return "not_in_calendar"
|
|
stored = copy["organizer"] or {}
|
|
sender = inv["organizer"] or {}
|
|
if str(stored.get("email", "")).lower() != str(sender.get("email", "")).lower():
|
|
return "not_organizer"
|
|
if inv["sequence"] <= int(copy["sequence"] or 0):
|
|
return "unchanged"
|
|
me = await _me(user_id, inv)
|
|
mine = copy["my_partstat"] or "needs-action"
|
|
attendees = [
|
|
{**a, "partstat": mine} if me and a["email"] == me["email"] else a
|
|
for a in inv["attendees"]
|
|
]
|
|
dav_calendar = await _store_invited_event(
|
|
s, user_id, inv, ics, message_id, attendees, mine, copy
|
|
)
|
|
if dav_calendar:
|
|
await _enqueue_dav_sync(uuid.UUID(dav_calendar))
|
|
return "updated"
|
|
|
|
|
|
async def _auto_add(
|
|
user_id: uuid.UUID, inv: dict[str, Any], ics: str, message_id: uuid.UUID, from_: str
|
|
) -> str:
|
|
"""A new invitation, added to the calendar as it arrives — not yet answered
|
|
(the card still asks). ``added`` or why not."""
|
|
me = await _me(user_id, inv)
|
|
if me is None:
|
|
return "not_invited"
|
|
organizer = (inv["organizer"] or {}).get("email", "")
|
|
if not organizer or organizer == me["email"]:
|
|
return "own_event"
|
|
if await _SDK.kv(user_id).get(_declined_key(inv)):
|
|
return "declined"
|
|
dav_calendar: str | None = None
|
|
async with _SDK.session(user_id) as s:
|
|
if from_ == "contacts" and not await _is_contact(s, organizer):
|
|
return "not_from_contact"
|
|
if await _invited_copy(s, inv) is not None:
|
|
return "exists"
|
|
dav_calendar = await _store_invited_event(
|
|
s, user_id, inv, ics, message_id, inv["attendees"], "needs-action", None
|
|
)
|
|
if dav_calendar:
|
|
await _enqueue_dav_sync(uuid.UUID(dav_calendar))
|
|
return "added"
|
|
|
|
|
|
async def process_incoming(user_id: uuid.UUID, message_id: uuid.UUID) -> str | None:
|
|
"""New mail with an invitation, cancellation or change: do what Settings →
|
|
Calendars → Invitations says (nothing, by default). Returns what changed in
|
|
the calendar — ``added`` | ``updated`` | ``removed`` — or None."""
|
|
try:
|
|
ics, inv = await _invitation(user_id, message_id)
|
|
except HTTPException:
|
|
return None
|
|
rules = await _invitation_settings(user_id)
|
|
outcome: str | None = None
|
|
if inv["method"] == "CANCEL":
|
|
# The one exception to the setting: an organizer's cancellation always
|
|
# takes the event out — an event that is off has no business staying.
|
|
outcome = await apply_cancellation(user_id, inv)
|
|
elif rules["policy"] == "none":
|
|
return None
|
|
elif inv["method"] == "REQUEST":
|
|
outcome = await apply_update(user_id, inv, ics, message_id)
|
|
if outcome == "not_in_calendar" and rules["policy"] == "add_update":
|
|
outcome = await _auto_add(user_id, inv, ics, message_id, rules["from"])
|
|
if outcome not in ("added", "updated", "removed"):
|
|
return None
|
|
if rules["file"]: # "Also mark the message as read and file it"
|
|
await _SDK.actions.set_flags(user_id, message_id, add=["\\Seen"])
|
|
await _SDK.actions.move_to_special(user_id, message_id, "archive")
|
|
_log.info("calendar: invitation email %s → %s", message_id, outcome)
|
|
return outcome
|
|
|
|
|
|
def _brief(ev: dict[str, Any]) -> dict[str, Any]:
|
|
return {k: ev.get(k) for k in ("summary", "start", "end", "all_day", "color")}
|
|
|
|
|
|
async def _invitation_view(user_id: uuid.UUID, message_id: uuid.UUID) -> dict[str, Any]:
|
|
ics, inv = await _invitation(user_id, message_id)
|
|
# Normally already applied when the email arrived (hook.py); this catches up
|
|
# on mail from before that. Cancellations always; changes to an event only
|
|
# when Settings allow automatic updates.
|
|
rules = await _invitation_settings(user_id)
|
|
automatic = rules["policy"] != "none"
|
|
cancellation = None
|
|
if inv["method"] == "CANCEL":
|
|
cancellation = await apply_cancellation(user_id, inv)
|
|
elif automatic and inv["method"] == "REQUEST":
|
|
await apply_update(user_id, inv, ics, message_id)
|
|
me = await _me(user_id, inv)
|
|
start, end = inv["dtstart"], inv["dtend"]
|
|
settings = await _SDK.accounts.settings_of(user_id)
|
|
try:
|
|
zone = ZoneInfo(str(settings.get("tz") or inv["tz"]))
|
|
except (ZoneInfoNotFoundError, ValueError):
|
|
zone = ZoneInfo("UTC")
|
|
day_start = start.astimezone(zone).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
async with _SDK.session(user_id) as s:
|
|
copy = await _invited_copy(s, inv)
|
|
around = await _events_between(s, day_start, max(end, day_start + timedelta(days=1)))
|
|
others = [
|
|
e
|
|
for e in around
|
|
if e.get("ical_uid") != inv["uid"]
|
|
and e.get("status") != "cancelled"
|
|
and e.get("my_partstat") != "declined"
|
|
]
|
|
|
|
def times(e: dict[str, Any]) -> tuple[datetime, datetime]:
|
|
return datetime.fromisoformat(str(e["start"])), datetime.fromisoformat(str(e["end"]))
|
|
|
|
# What the user is already busy with then (timed events, unless this one is
|
|
# all-day), and else the event just before it that day.
|
|
conflicts = [
|
|
_brief(e)
|
|
for e in others
|
|
if e.get("show_as") != "free"
|
|
and (inv["all_day"] or not e.get("all_day"))
|
|
and times(e)[0] < end
|
|
and times(e)[1] > start
|
|
]
|
|
earlier = sorted(
|
|
(e for e in others if not e.get("all_day") and times(e)[1] <= start),
|
|
key=lambda e: times(e)[1],
|
|
)
|
|
declined = await _SDK.kv(user_id).get(_declined_key(inv))
|
|
response = (
|
|
(copy or {}).get("my_partstat")
|
|
or ("declined" if declined else None)
|
|
or (me or {}).get("partstat")
|
|
or "needs-action"
|
|
)
|
|
return {
|
|
"method": inv["method"],
|
|
"uid": inv["uid"],
|
|
"summary": inv["summary"],
|
|
"description": inv["description"],
|
|
"location": inv["location"],
|
|
"conference_url": inv["conference_url"],
|
|
"start": start.isoformat(),
|
|
"end": end.isoformat(),
|
|
"all_day": inv["all_day"],
|
|
"tz": inv["tz"],
|
|
"recurring": inv["rrule"] is not None,
|
|
"status": inv["status"],
|
|
"organizer": inv["organizer"],
|
|
"attendees": inv["attendees"],
|
|
"me": {"email": me["email"], "name": me["name"]} if me else None,
|
|
"response": response,
|
|
"in_calendar": (
|
|
{"event_id": str(copy["id"]), "calendar": copy["name"]} if copy else None
|
|
),
|
|
# The calendar holds a newer version (the organizer changed it since).
|
|
"outdated": bool(copy and int(copy["sequence"]) > inv["sequence"]),
|
|
"conflicts": conflicts,
|
|
"previous": _brief(earlier[-1]) if earlier and not conflicts else None,
|
|
# For a cancellation: removed | not_in_calendar | not_organizer | outdated.
|
|
"cancellation": cancellation,
|
|
# The organizer changed the event since the calendar's copy (answer again
|
|
# to take the change — automatic only when Settings allow it).
|
|
"update_pending": bool(
|
|
copy and inv["method"] == "REQUEST" and inv["sequence"] > int(copy["sequence"])
|
|
),
|
|
}
|
|
|
|
|
|
@router.get("/invitations/{message_id}")
|
|
async def get_invitation(message_id: uuid.UUID, user_id: CurrentUserId) -> dict[str, Any]:
|
|
"""The invitation in an email, as the reading pane shows it; 404 if none."""
|
|
return await _invitation_view(user_id, message_id)
|
|
|
|
|
|
def _default_reminders(settings: dict[str, Any]) -> list[int]:
|
|
out = []
|
|
for r in settings.get("default_reminders_timed") or []:
|
|
minutes = r.get("minutes") if isinstance(r, dict) else r
|
|
if isinstance(minutes, int):
|
|
out.append(minutes)
|
|
return out
|
|
|
|
|
|
async def _store_invited_event(
|
|
s: AsyncSession,
|
|
user_id: uuid.UUID,
|
|
inv: dict[str, Any],
|
|
ics: str,
|
|
message_id: uuid.UUID,
|
|
attendees: list[dict[str, Any]],
|
|
partstat: str,
|
|
copy: dict[str, Any] | None,
|
|
) -> str | None:
|
|
"""Write an invitation's event into the calendar: update ``copy`` when the
|
|
calendar holds it, else add it to the invitations calendar. Returns the
|
|
calendar id when it is a CalDAV one (to be pushed), else None."""
|
|
fields = {
|
|
"sum": inv["summary"],
|
|
"desc": inv["description"],
|
|
"loc": inv["location"],
|
|
"start": inv["dtstart"],
|
|
"end": inv["dtend"],
|
|
"allday": inv["all_day"],
|
|
"tz": inv["tz"],
|
|
"rrule": inv["rrule"],
|
|
"rid": inv["recurrence_id"],
|
|
"status": "cancelled" if inv["status"] == "cancelled" else "confirmed",
|
|
"org": json.dumps(inv["organizer"]) if inv["organizer"] else None,
|
|
"att": json.dumps(attendees),
|
|
"mine": partstat,
|
|
"seq": inv["sequence"],
|
|
"raw": ics,
|
|
"msg": str(message_id),
|
|
"conf": inv["conference_url"],
|
|
"showas": inv["show_as"],
|
|
}
|
|
if copy is not 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, status = :status, "
|
|
"organizer = CAST(:org AS jsonb), attendees = CAST(:att AS jsonb), "
|
|
"my_partstat = :mine, sequence = :seq, raw_ical = :raw, "
|
|
"origin_message_id = :msg, conference_url = :conf, show_as = :showas "
|
|
"WHERE id = :id"
|
|
),
|
|
{**fields, "id": str(copy["id"])},
|
|
)
|
|
return str(copy["calendar_id"]) if copy["kind"] == "caldav" else None
|
|
calendar_id = await _invitation_calendar(s, user_id)
|
|
settings = await _SDK.accounts.settings_of(user_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, recurrence_id, status, organizer, "
|
|
" attendees, my_partstat, sequence, raw_ical, origin_message_id, "
|
|
" conference_url, show_as, reminders) "
|
|
"VALUES (:u, :cal, :uid, :sum, :desc, :loc, :start, :end, :allday, :tz, "
|
|
" :rrule, :rid, :status, CAST(:org AS jsonb), CAST(:att AS jsonb), :mine, "
|
|
" :seq, :raw, :msg, :conf, :showas, :rem)"
|
|
),
|
|
{
|
|
**fields,
|
|
"u": str(user_id),
|
|
"cal": str(calendar_id),
|
|
"uid": inv["uid"],
|
|
"rem": _default_reminders(settings),
|
|
},
|
|
)
|
|
return str(calendar_id) if await _is_caldav(s, calendar_id) else None
|
|
|
|
|
|
@router.post("/invitations/{message_id}/respond", dependencies=[Depends(require_csrf)])
|
|
async def respond_to_invitation(
|
|
message_id: uuid.UUID, body: InvitationAnswer, user_id: CurrentUserId
|
|
) -> dict[str, Any]:
|
|
"""Answer an invitation: Yes / Maybe put it in the calendar (or update it),
|
|
No takes it out; then the organizer gets the iTIP REPLY."""
|
|
if body.response not in _ANSWERS:
|
|
raise HTTPException(status_code=400, detail="bad_response")
|
|
ics, inv = await _invitation(user_id, message_id)
|
|
if inv["method"] != "REQUEST":
|
|
raise HTTPException(status_code=400, detail="not_a_request")
|
|
me = await _me(user_id, inv)
|
|
if me is None:
|
|
raise HTTPException(status_code=400, detail="not_invited")
|
|
attendees = [
|
|
{**a, "partstat": body.response} if a["email"] == me["email"] else a
|
|
for a in inv["attendees"]
|
|
]
|
|
dav_calendar: str | None = None
|
|
async with _SDK.session(user_id) as s:
|
|
copy = await _invited_copy(s, inv)
|
|
if body.response == "declined":
|
|
if copy is not None:
|
|
await s.execute(
|
|
text(
|
|
"UPDATE plg_calendar_events SET dav_deleted = true WHERE id = :id"
|
|
if copy["kind"] == "caldav"
|
|
else "DELETE FROM plg_calendar_events WHERE id = :id"
|
|
),
|
|
{"id": str(copy["id"])},
|
|
)
|
|
dav_calendar = str(copy["calendar_id"]) if copy["kind"] == "caldav" else None
|
|
elif copy is not None and int(copy["sequence"]) > inv["sequence"]:
|
|
# An older copy of the invitation: keep the newer event, just answer.
|
|
await s.execute(
|
|
text("UPDATE plg_calendar_events SET my_partstat = :p WHERE id = :id"),
|
|
{"p": body.response, "id": str(copy["id"])},
|
|
)
|
|
dav_calendar = str(copy["calendar_id"]) if copy["kind"] == "caldav" else None
|
|
else:
|
|
dav_calendar = await _store_invited_event(
|
|
s, user_id, inv, ics, message_id, attendees, body.response, copy
|
|
)
|
|
if dav_calendar:
|
|
await _enqueue_dav_sync(uuid.UUID(dav_calendar))
|
|
kv = _SDK.kv(user_id)
|
|
if body.response == "declined":
|
|
await kv.set(_declined_key(inv), True)
|
|
else:
|
|
await kv.delete(_declined_key(inv))
|
|
|
|
# Tell the organizer — unless the user organized it themselves.
|
|
organizer = inv["organizer"]
|
|
reply_sent = False
|
|
if organizer and organizer["email"] != me["email"]:
|
|
who = me["name"] or me["email"]
|
|
word = {"accepted": "accepted", "declined": "declined", "tentative": "tentatively accepted"}
|
|
sentence = f"{who} has {word[body.response]} this invitation."
|
|
try:
|
|
await _SDK.mail.send(
|
|
user_id,
|
|
to=[organizer["email"]],
|
|
subject=imip.reply_subject(body.response, inv["summary"]),
|
|
html=f"<p>{html_escape(sentence)}</p>",
|
|
text=sentence,
|
|
from_email=me["email"],
|
|
ics=imip.build_reply(ics, me, body.response),
|
|
ics_method="REPLY",
|
|
ics_filename="reply.ics",
|
|
)
|
|
reply_sent = True
|
|
except Exception: # noqa: BLE001 - the calendar is updated either way
|
|
_log.exception("invitation reply to %s failed", organizer["email"])
|
|
view = await _invitation_view(user_id, message_id)
|
|
view["reply_sent"] = reply_sent
|
|
return view
|
|
|
|
|
|
# --- bundle ------------------------------------------------------------------
|
|
|
|
@router.get("/ui/{filename}")
|
|
async def bundle(filename: str) -> FileResponse:
|
|
if filename not in ("page.js", "invite.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"},
|
|
)
|