Files
webmail-client/plugins/webmail_plugin_calendar/webmail_plugin_calendar/router.py
T
lukasandClaude Opus 5.5 1982e8964b Calendar: cancel events for guests, apply organizers' cancellations, invitation settings
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>
2026-10-07 20:50:17 +02:00

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"},
)