M4: Calendar plugin (builtin) + mail HTML/attachment fixes

Calendar plugin (webmail_plugin_calendar, non-removable core plugin):
- Local calendars/events CRUD with RLS isolation; recurrence expanded at
  query time (DST-correct), EXDATE + detached-override merge.
- Calendar app bundle: month/week/day/3-day views, mini-month, event form
  (color, show-as, timezone combobox, repeat, reminders, guests w/ recipient
  autocomplete, Jitsi video call), event popover with RSVP badges, resizable/
  collapsible left panel + theme toggle, overlap layout.
- iMIP invites: sdk.mail sends REQUEST from the organizer's mailbox (HTML +
  text/calendar + invite.ics); public /rsvp endpoint (vault-sealed token)
  records accepted/declined/tentative per attendee.
- "This and following" edit + scoped delete (this / this-and-following), with
  a delete confirmation dialog for recurring events.

Core:
- Settings: time_format (12/24h, default 24h) + jitsi_server; new Integrations
  section; date order follows time format (en-GB vs en-US) across Mail.
- Plugin SDK: mail (send from a user's account), vault (envelope encrypt),
  multi-method SSRF-guarded http; AppSwitcher product switcher; full-bleed
  PluginPage.

Mail HTML rendering:
- Sanitizer allows <style> blocks + class (CSS sanitized: drop script-like,
  strip @import/url() when remote images blocked); SANITIZER_VERSION 3 so
  already-indexed mail re-sanitizes on view.
- Attachments: a Content-Disposition: attachment part is a real attachment
  even when it also carries a Content-ID (fixes hidden invite.ics).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 16:33:34 +02:00
co-authored by Claude Opus 4.8
parent d46ad13b7b
commit 8c634f7967
56 changed files with 3308 additions and 37 deletions
@@ -0,0 +1,24 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "webmail-plugin-calendar"
version = "0.1.0"
description = "Calendar — the reference heavy plugin (SPEC Appendix A). Local events, iMIP, CalDAV/ICS."
requires-python = ">=3.11"
# Plugin owns its dependencies (SPEC §A.2). caldav is only needed for A3.
dependencies = [
"python-dateutil>=2.9",
"icalendar>=5.0,<7",
"tzdata",
]
[project.entry-points."webmail.plugins"]
calendar = "webmail_plugin_calendar:plugin"
[tool.setuptools]
packages = ["webmail_plugin_calendar"]
[tool.setuptools.package-data]
webmail_plugin_calendar = ["ui/*.js", "migrations/versions/*.py"]
@@ -0,0 +1,34 @@
"""``calendar`` — the reference heavy plugin (SPEC Appendix A).
A **core, non-removable** plugin (``builtin=True``) that ships a `/calendar` page
beside Mail. Its own ``plg_calendar_*`` tables (RLS-isolated) hold calendars and
events; recurrence is expanded at query time. Uses the SDK only — zero core diffs.
Phase A1 (local events) is the current scope; iMIP (A2) and CalDAV/ICS (A3) build
on the same data model and the SDK's vault + multi-method HTTP.
"""
from __future__ import annotations
from pathlib import Path
from app.plugins.sdk import Page, PageNav, Plugin, UiManifest
from webmail_plugin_calendar.router import router
_HERE = Path(__file__).resolve().parent
plugin = Plugin(
name="calendar",
version="0.1.0",
builtin=True,
router=router,
ui_manifest=UiManifest(
pages=[
Page(
route="/calendar",
nav=PageNav(icon="calendar", label="Calendar", order=10),
bundle="/api/plugins/calendar/ui/page.js",
)
],
),
migrations=str(_HERE / "migrations" / "versions"),
)
@@ -0,0 +1,289 @@
"""iMIP invitations (SPEC §A.2): build the ICS + HTML invite and send it from the
organizer's mailbox via ``sdk.mail``, plus signed RSVP tokens.
An invite carries a ``text/calendar; method=REQUEST`` part (so a recipient's own
client shows its native Yes/Maybe/No UI) *and* Reply buttons that link back to the
public ``/rsvp`` endpoint. The RSVP token is the event coordinates
(``user_id``/``event_id``/``email``) sealed with the SDK vault — tamper-proof and
opaque, so an external guest can respond without a webmail login.
"""
from __future__ import annotations
import base64
import html as html_mod
import json
import uuid
from datetime import UTC, datetime
from zoneinfo import ZoneInfo
from icalendar import Calendar, Event, vCalAddress, vText
from app.plugins.sdk import sdk
_SDK = sdk("calendar")
RESPONSES = {"accepted": "Yes", "declined": "No", "tentative": "Maybe"}
# --- RSVP tokens (sealed with the vault, so guests need no login) -------------
def make_token(user_id: uuid.UUID, event_id: uuid.UUID, email: str) -> str:
payload = json.dumps({"u": str(user_id), "e": str(event_id), "m": email}).encode()
return base64.urlsafe_b64encode(_SDK.vault.encrypt(payload)).decode()
def read_token(token: str) -> tuple[uuid.UUID, uuid.UUID, str] | None:
try:
data = json.loads(_SDK.vault.decrypt(base64.urlsafe_b64decode(token.encode())))
return uuid.UUID(data["u"]), uuid.UUID(data["e"]), str(data["m"])
except Exception:
return None
# --- date formatting ----------------------------------------------------------
def _local(dt: datetime, tz: str) -> datetime:
try:
return dt.astimezone(ZoneInfo(tz))
except Exception:
return dt.astimezone(UTC)
def format_when(dtstart: datetime, dtend: datetime | None, tz: str, all_day: bool) -> str:
s = _local(dtstart, tz)
if all_day:
return s.strftime("%A %Y-%m-%d")
e = _local(dtend, tz) if dtend else None
times = s.strftime("%H:%M") + (f" – {e.strftime('%H:%M')}" if e else "")
return f"{s.strftime('%A %Y-%m-%d')} · {times} ({s.tzname() or tz})"
def _subject_when(dtstart: datetime, dtend: datetime | None, tz: str, all_day: bool) -> str:
s = _local(dtstart, tz)
if all_day:
return s.strftime("%a %Y-%m-%d")
e = _local(dtend, tz) if dtend else None
times = s.strftime("%H:%M") + (f" - {e.strftime('%H:%M')}" if e else "")
return f"{s.strftime('%a %Y-%m-%d')} {times} ({s.tzname() or tz})"
# --- ICS ----------------------------------------------------------------------
def build_ics(
*,
ical_uid: str,
summary: str,
description: str,
location: str,
dtstart: datetime,
dtend: datetime | None,
organizer: dict[str, str] | None,
attendees: list[dict[str, str]],
conference_url: str = "",
sequence: int = 0,
) -> str:
cal = Calendar()
cal.add("prodid", "-//Webmail//Calendar//EN")
cal.add("version", "2.0")
cal.add("method", "REQUEST")
ev = Event()
ev.add("uid", ical_uid)
ev.add("dtstamp", datetime.now(UTC))
ev.add("dtstart", dtstart)
if dtend:
ev.add("dtend", dtend)
ev.add("summary", summary or "(no title)")
desc = description or ""
if conference_url:
desc = f"{desc}\n\nJoin: {conference_url}".strip()
ev.add("url", conference_url)
if desc:
ev.add("description", desc)
if location:
ev.add("location", location)
ev.add("sequence", sequence)
if organizer and organizer.get("email"):
org = vCalAddress(f"MAILTO:{organizer['email']}")
if organizer.get("name"):
org.params["cn"] = vText(organizer["name"])
ev.add("organizer", org)
for a in attendees:
att = vCalAddress(f"MAILTO:{a['email']}")
if a.get("name"):
att.params["cn"] = vText(a["name"])
att.params["role"] = vText("REQ-PARTICIPANT")
att.params["partstat"] = vText((a.get("partstat") or "needs-action").upper())
att.params["rsvp"] = vText("TRUE")
ev.add("attendee", att)
cal.add_component(ev)
return cal.to_ical().decode("utf-8")
# --- HTML / text body ---------------------------------------------------------
def _esc(s: object) -> str:
return html_mod.escape(str(s or ""))
def _guest_lines_html(organizer: dict[str, str] | None, attendees: list[dict[str, str]]) -> str:
rows = ""
if organizer and organizer.get("email"):
name = organizer.get("name") or organizer["email"]
rows += f'<div>{_esc(name)} <span style="color:#5f6368">&ndash; organizer</span></div>'
for a in attendees:
rows += f'<div>{_esc(a.get("name") or a["email"])}</div>'
return rows
def build_html(
*,
when: str,
description: str,
location: str,
conference_url: str,
organizer: dict[str, str] | None,
attendees: list[dict[str, str]],
rsvp_base: str,
token: str,
) -> str:
join = ""
link = ""
if conference_url:
join = (
f'<div style="margin:0 0 22px"><a href="{_esc(conference_url)}" '
'style="display:inline-block;background:#1a73e8;color:#fff;text-decoration:none;'
'padding:10px 20px;border-radius:6px;font-weight:600">Join Jitsi Meet</a></div>'
)
link = (
'<p style="margin:0 0 4px;font-weight:700">Meeting link</p>'
f'<p style="margin:0 0 22px"><a href="{_esc(conference_url)}" '
f'style="color:#1a73e8;text-decoration:none">{_esc(conference_url)}</a></p>'
)
desc = f'<p style="margin:0 0 22px">{_esc(description)}</p>' if description else ""
loc = (
'<p style="margin:0 0 4px;font-weight:700">Where</p>'
f'<p style="margin:0 0 22px;color:#5f6368">{_esc(location)}</p>'
if location
else ""
)
def btn(resp: str, label: str) -> str:
url = f"{rsvp_base}?t={token}&r={resp}"
return (
f'<a href="{url}" style="display:inline-block;border:1px solid #dadce0;'
'border-radius:4px;padding:8px 20px;margin-right:8px;color:#1a73e8;'
f'text-decoration:none">{label}</a>'
)
rsvp = btn("accepted", "Yes") + btn("declined", "No") + btn("tentative", "Maybe")
return (
'<html><body style="margin:0;background:#f1f3f4;font-family:Arial,Helvetica,sans-serif;'
'color:#3c4043;line-height:1.5">'
'<div style="max-width:600px;margin:0 auto;background:#fff;border:1px solid #e0e0e0;'
'border-radius:8px;padding:28px 32px">'
f"{join}{link}{desc}"
'<p style="margin:0 0 4px;font-weight:700">When</p>'
f'<p style="margin:0 0 22px;color:#5f6368">{_esc(when)}</p>'
f"{loc}"
'<p style="margin:0 0 6px;font-weight:700">Guests</p>'
f'<div style="margin:0 0 22px">{_guest_lines_html(organizer, attendees)}</div>'
'<p style="margin:0 0 8px"><b>Reply</b> to this invitation</p>'
f"<div>{rsvp}</div>"
"</div>"
'<p style="max-width:600px;margin:16px auto;color:#9aa0a6;font-size:12px;padding:0 4px">'
"You are receiving this message because you are a guest of this event. "
"Sent by Webmail Calendar.</p>"
"</body></html>"
)
def build_text(when: str, description: str, conference_url: str, rsvp_base: str, token: str) -> str:
lines = []
if conference_url:
lines.append(f"Join Jitsi Meet: {conference_url}")
lines.append("")
if description:
lines.append(description)
lines.append("")
lines.append(f"When: {when}")
lines.append("")
lines.append("Reply to this invitation:")
lines.append(f" Yes: {rsvp_base}?t={token}&r=accepted")
lines.append(f" No: {rsvp_base}?t={token}&r=declined")
lines.append(f" Maybe: {rsvp_base}?t={token}&r=tentative")
return "\n".join(lines)
# --- send ---------------------------------------------------------------------
async def send_invites(
*,
user_id: uuid.UUID,
event_id: uuid.UUID,
ical_uid: str,
summary: str,
description: str,
location: str,
dtstart: datetime,
dtend: datetime | None,
tz: str,
all_day: bool,
organizer: dict[str, str] | None,
attendees: list[dict[str, str]],
conference_url: str,
sequence: int = 0,
notify_emails: set[str] | None = None,
) -> None:
"""Send one iMIP REQUEST per attendee, from the organizer's mailbox.
The ICS always lists the full ``attendees`` roster; ``notify_emails`` (lower-
cased) restricts *who is actually emailed* — used on edit to invite only the
newly-added guests without re-notifying everyone.
"""
if not attendees:
return
from_email = organizer.get("email") if organizer else None
rsvp_base = f"https://{_SDK.settings.app_domain}/api/plugins/calendar/rsvp"
when = format_when(dtstart, dtend, tz, all_day)
subject_when = _subject_when(dtstart, dtend, tz, all_day)
ics = build_ics(
ical_uid=ical_uid,
summary=summary,
description=description,
location=location,
dtstart=dtstart,
dtend=dtend,
organizer=organizer,
attendees=attendees,
conference_url=conference_url,
sequence=sequence,
)
for a in attendees:
email = a.get("email")
if not email:
continue
if notify_emails is not None and email.lower() not in notify_emails:
continue
token = make_token(user_id, event_id, email)
html = build_html(
when=when,
description=description,
location=location,
conference_url=conference_url,
organizer=organizer,
attendees=attendees,
rsvp_base=rsvp_base,
token=token,
)
text = build_text(when, description, conference_url, rsvp_base, token)
subject = f"Invitation: {summary or '(no title)'} - {subject_when} ({email})"
await _SDK.mail.send(
user_id,
to=[email],
subject=subject,
html=html,
text=text,
from_email=from_email,
ics=ics,
)
@@ -0,0 +1,79 @@
"""calendar plugin: calendars + events tables with RLS (SPEC §A.3)
Revision ID: calendar0001
Revises:
Create Date: 2026-07-28
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
from app.plugins.rls import enable_rls
revision: str = "calendar0001"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
CREATE TABLE plg_calendar_calendars (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
name text NOT NULL,
color text NOT NULL DEFAULT '#3b82f6',
kind text NOT NULL DEFAULT 'local', -- local|caldav|ics_subscription
source_config jsonb NOT NULL DEFAULT '{}',
secret_ct bytea, -- CalDAV creds (vault, §10.2)
sync_token text,
read_only boolean NOT NULL DEFAULT false,
UNIQUE (user_id, name)
)
"""
)
op.execute(
"""
CREATE TABLE plg_calendar_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
calendar_id uuid NOT NULL
REFERENCES plg_calendar_calendars(id) ON DELETE CASCADE,
ical_uid text NOT NULL,
sequence int NOT NULL DEFAULT 0,
summary text NOT NULL DEFAULT '',
description text NOT NULL DEFAULT '',
location text NOT NULL DEFAULT '',
dtstart timestamptz NOT NULL,
dtend timestamptz,
all_day boolean NOT NULL DEFAULT false,
tz text NOT NULL DEFAULT 'UTC',
rrule text,
exdates timestamptz[] NOT NULL DEFAULT '{}',
recurrence_id timestamptz,
organizer jsonb,
attendees jsonb NOT NULL DEFAULT '[]',
status text NOT NULL DEFAULT 'confirmed', -- confirmed|tentative|cancelled
my_partstat text,
reminders int[] NOT NULL DEFAULT '{}',
origin_message_id uuid,
raw_ical text,
UNIQUE (calendar_id, ical_uid, recurrence_id)
)
"""
)
op.execute(
"CREATE INDEX ix_plg_calendar_events_user_dtstart "
"ON plg_calendar_events (user_id, dtstart)"
)
enable_rls("plg_calendar_calendars")
enable_rls("plg_calendar_events")
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS plg_calendar_events")
op.execute("DROP TABLE IF EXISTS plg_calendar_calendars")
@@ -0,0 +1,28 @@
"""calendar: per-event color + show_as (busy/free) (SPEC §A form)
Revision ID: calendar0002
Revises: calendar0001
Create Date: 2026-07-28
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "calendar0002"
down_revision: str | None = "calendar0001"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# color NULL = inherit the calendar's colour; show_as maps to iCalendar TRANSP.
op.execute("ALTER TABLE plg_calendar_events ADD COLUMN color text")
op.execute("ALTER TABLE plg_calendar_events ADD COLUMN show_as text NOT NULL DEFAULT 'busy'")
def downgrade() -> None:
op.execute("ALTER TABLE plg_calendar_events DROP COLUMN IF EXISTS show_as")
op.execute("ALTER TABLE plg_calendar_events DROP COLUMN IF EXISTS color")
@@ -0,0 +1,26 @@
"""calendar: per-event conference_url (Jitsi Meet video-call link) (SPEC §A form)
Revision ID: calendar0003
Revises: calendar0002
Create Date: 2026-07-28
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "calendar0003"
down_revision: str | None = "calendar0002"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# Video-call URL (Jitsi Meet room), generated in the event form; empty = none.
op.execute("ALTER TABLE plg_calendar_events ADD COLUMN conference_url text NOT NULL DEFAULT ''")
def downgrade() -> None:
op.execute("ALTER TABLE plg_calendar_events DROP COLUMN IF EXISTS conference_url")
@@ -0,0 +1,93 @@
"""Range expansion + override merge for the events list (SPEC §A.3).
Given the base events (``recurrence_id IS NULL``) and detached override rows
(``recurrence_id`` set) that overlap a window, produce the flat list of instances
the UI renders: expand each base event's RRULE (DST-correct, EXDATE-aware), drop the
occurrence an override replaces, and emit the override at its own moved time.
"""
from __future__ import annotations
from collections.abc import Mapping
from datetime import UTC, datetime, timedelta
from typing import Any
from webmail_plugin_calendar.recurrence import expand_occurrences
Row = Mapping[str, Any]
def _duration(dtstart: datetime, dtend: datetime | None, all_day: bool) -> timedelta:
if dtend is not None:
return dtend - dtstart
return timedelta(days=1) if all_day else timedelta(0)
def _instance(
row: Row, start: datetime, end: datetime, recurrence_id: datetime | None
) -> dict[str, Any]:
origin = row.get("origin_message_id")
return {
"id": str(row["id"]),
"calendar_id": str(row["calendar_id"]),
"ical_uid": row["ical_uid"],
"summary": row["summary"],
"description": row.get("description") or "",
"location": row["location"],
"tz": row.get("tz"),
"rrule": row.get("rrule"),
"reminders": list(row.get("reminders") or []),
"start": start.isoformat(),
"end": end.isoformat(),
"all_day": row["all_day"],
"status": row["status"],
"recurring": row["rrule"] is not None,
"recurrence_id": recurrence_id.isoformat() if recurrence_id else None,
"color": row["color"],
"show_as": row.get("show_as", "busy"),
"conference_url": row.get("conference_url", ""),
"attendees": row.get("attendees") or [],
"organizer": row.get("organizer"),
"my_partstat": row.get("my_partstat"),
"origin_message_id": str(origin) if origin else None,
}
def expand_range(
base_rows: list[Row],
override_rows: list[Row],
range_start: datetime,
range_end: datetime,
) -> list[dict[str, Any]]:
# Overrides keyed by (ical_uid, original-occurrence-start in UTC); also emitted
# as their own instance at the moved time.
replaced: set[tuple[str, datetime]] = set()
out: list[dict[str, Any]] = []
for o in override_rows:
replaced.add((o["ical_uid"], o["recurrence_id"].astimezone(UTC)))
if o["status"] == "cancelled":
continue
dur = _duration(o["dtstart"], o["dtend"], o["all_day"])
start, end = o["dtstart"], o["dtstart"] + dur
if end > range_start and start < range_end:
out.append(_instance(o, start, end, o["recurrence_id"]))
for b in base_rows:
dur = _duration(b["dtstart"], b["dtend"], b["all_day"])
occ = expand_occurrences(
dtstart=b["dtstart"],
duration=dur,
rrule=b["rrule"],
exdates=list(b["exdates"] or []),
range_start=range_start,
range_end=range_end,
tz=b["tz"],
all_day=b["all_day"],
)
for start, end in occ:
if (b["ical_uid"], start.astimezone(UTC)) in replaced:
continue # a detached override provides this instance
out.append(_instance(b, start, end, start if b["rrule"] else None))
out.sort(key=lambda i: i["start"])
return out
@@ -0,0 +1,81 @@
"""Recurrence expansion (SPEC §A.3).
Events store ``dtstart`` in UTC plus the original ``tz``. RRULEs are expanded at
query time (no materialization) over the requested range only. DST correctness:
a weekly "Monday 10:00 Europe/Warsaw" event stays at 10:00 *local* on both sides of
a DST change (its UTC offset shifts). dateutil computes occurrences by naive
arithmetic, so we expand on the wall-clock time in the event's own zone, then
localize each occurrence back to UTC — that keeps the local time fixed and lets the
offset move. Iterations are capped per event; EXDATEs remove instances. Detached
overrides (``recurrence_id`` rows) are merged by the caller, not here.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from itertools import islice
from zoneinfo import ZoneInfo
from dateutil.rrule import rrulestr
MAX_INSTANCES = 1000
def _localize_until(rrule: str, zone: ZoneInfo) -> str:
"""Rewrite a UTC (``…Z``) ``UNTIL`` to naive wall-clock in ``zone``.
We expand on naive wall-clock (see module docstring), and dateutil requires
``UNTIL`` to match ``DTSTART``'s awareness — so a standards-compliant UTC UNTIL
must be converted to the same naive local clock the expansion runs in.
"""
parts = []
for part in rrule.split(";"):
if part.startswith("UNTIL=") and part.endswith("Z"):
try:
dt = datetime.strptime(part[6:-1], "%Y%m%dT%H%M%S").replace(tzinfo=UTC)
part = "UNTIL=" + dt.astimezone(zone).replace(tzinfo=None).strftime("%Y%m%dT%H%M%S")
except ValueError:
pass
parts.append(part)
return ";".join(parts)
def expand_occurrences(
*,
dtstart: datetime,
duration: timedelta,
rrule: str | None,
exdates: list[datetime],
range_start: datetime,
range_end: datetime,
tz: str,
all_day: bool = False,
) -> list[tuple[datetime, datetime]]:
"""Return ``(start, end)`` occurrences (UTC, aware) overlapping ``[range_start,
range_end)``. All datetimes in/out are timezone-aware."""
# All-day events are date-anchored — expand in UTC so they don't shift.
zone = ZoneInfo("UTC") if all_day else ZoneInfo(tz or "UTC")
local_start = dtstart.astimezone(zone).replace(tzinfo=None)
def to_utc(naive: datetime) -> datetime:
return naive.replace(tzinfo=zone).astimezone(UTC)
excluded = {e.astimezone(UTC) for e in exdates}
if rrule:
lo = range_start.astimezone(zone).replace(tzinfo=None) - duration
hi = range_end.astimezone(zone).replace(tzinfo=None)
rule = rrulestr(_localize_until(rrule, zone), dtstart=local_start)
naive_starts = list(islice(rule.between(lo, hi, inc=True), MAX_INSTANCES))
else:
naive_starts = [local_start]
out: list[tuple[datetime, datetime]] = []
for naive in naive_starts:
start = to_utc(naive)
if start in excluded:
continue
end = start + duration
if end > range_start and start < range_end: # overlaps the window
out.append((start, end))
return out
@@ -0,0 +1,556 @@
"""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 uuid
from datetime import UTC, datetime, timedelta
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse, HTMLResponse
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.sdk import CurrentUserId, require_csrf, sdk
from webmail_plugin_calendar import invites
from webmail_plugin_calendar.queries import expand_range
from webmail_plugin_calendar.schemas import (
CalendarIn,
CalendarPatch,
EditFollowingIn,
EventIn,
EventPatch,
)
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")
# --- calendars ---------------------------------------------------------------
@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 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"],
}
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]:
fields = body.model_dump(exclude_unset=True)
if not fields:
return {"status": "noop"}
set_sql = ", ".join(f"{k} = :{k}" for k in fields)
async with _SDK.session(user_id) as s:
result = await s.execute(
text(f"UPDATE plg_calendar_calendars SET {set_sql} WHERE id = :id"),
{**fields, "id": str(calendar_id)},
)
if result.rowcount == 0:
raise HTTPException(status_code=404, detail="calendar_not_found")
return {"status": "ok"}
@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"}
# --- events ------------------------------------------------------------------
@router.get("/events")
async def list_events(
user_id: CurrentUserId,
start: datetime = Query(...),
end: datetime = Query(...),
) -> dict[str, object]:
range_start, range_end = _aware(start), _aware(end)
async with _SDK.session(user_id) as s:
rows = (
await s.execute(
text(
"SELECT e.id, e.calendar_id, e.ical_uid, e.summary, e.description, "
"e.location, e.dtstart, e.dtend, e.all_day, e.tz, e.rrule, e.reminders, "
"e.exdates, e.recurrence_id, e.status, "
"e.my_partstat, e.origin_message_id, e.show_as, e.conference_url, "
"e.attendees, e.organizer, "
"COALESCE(e.color, c.color) AS color "
"FROM plg_calendar_events e "
"JOIN plg_calendar_calendars c ON c.id = e.calendar_id "
"WHERE e.dtstart < :end"
),
{"end": range_end},
)
).mappings().all()
base = [
r
for r in rows
if r["recurrence_id"] is None
and (r["rrule"] is not None or (r["dtend"] or r["dtstart"]) > range_start)
]
overrides = [r for r in rows if r["recurrence_id"] is not None]
return {"events": expand_range(base, overrides, range_start, range_end)}
async def _insert_event(
s: AsyncSession, user_id: uuid.UUID, body: EventIn, attendees: list[dict[str, str]]
) -> tuple[uuid.UUID, str]:
ical_uid = f"{uuid.uuid4()}@webmail.local"
organizer = json.dumps(body.organizer.model_dump()) if body.organizer else None
new_id = (
await s.execute(
text(
"INSERT INTO plg_calendar_events "
"(user_id, calendar_id, ical_uid, summary, description, location, dtstart, "
" dtend, all_day, tz, rrule, reminders, attendees, organizer, color, show_as, "
" conference_url) "
"VALUES (:u, :cal, :uid, :sum, :desc, :loc, :start, :end, :allday, :tz, "
" :rrule, :rem, :att, :org, :color, :showas, :conf) RETURNING id"
),
{
"u": str(user_id),
"cal": str(body.calendar_id),
"uid": ical_uid,
"sum": body.summary,
"desc": body.description,
"loc": body.location,
"start": _aware(body.dtstart),
"end": _aware(body.dtend) if body.dtend else None,
"allday": body.all_day,
"tz": body.tz,
"rrule": body.rrule,
"rem": body.reminders,
"att": json.dumps(attendees),
"org": organizer,
"color": body.color,
"showas": body.show_as,
"conf": body.conference_url,
},
)
).scalar_one()
return new_id, ical_uid
def _merge_attendees(
existing: list[dict[str, str]] | None, incoming: list[dict[str, str]]
) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
"""Merge incoming attendees over existing: preserve prior RSVP status by email,
default new guests to needs-action. Returns (merged, newly-added)."""
prior = {str(a.get("email", "")).lower(): a for a in (existing or [])}
merged: list[dict[str, str]] = []
added: list[dict[str, str]] = []
for a in incoming:
email = a.get("email") or ""
if not email:
continue
match = prior.get(email.lower())
a["partstat"] = match.get("partstat", "needs-action") if match else "needs-action"
if match is None:
added.append(a)
merged.append(a)
return merged, added
def _strip_recurrence_end(rrule: str) -> str:
"""Drop any UNTIL=/COUNT= parts so a fresh UNTIL can be appended."""
return ";".join(p for p in rrule.split(";") if not p.startswith(("UNTIL=", "COUNT=")))
async def _send_invites_from(
user_id: uuid.UUID,
event_id: uuid.UUID,
ical_uid: str,
body: EventIn,
attendees: list[dict[str, str]],
*,
sequence: int = 0,
notify: set[str] | None = None,
) -> None:
"""Best-effort iMIP send for an event described by ``body`` + ``attendees``."""
if not attendees:
return
try:
await invites.send_invites(
user_id=user_id,
event_id=event_id,
ical_uid=ical_uid,
summary=body.summary,
description=body.description,
location=body.location,
dtstart=_aware(body.dtstart),
dtend=_aware(body.dtend) if body.dtend else None,
tz=body.tz,
all_day=body.all_day,
organizer=body.organizer.model_dump() if body.organizer else None,
attendees=attendees,
conference_url=body.conference_url,
sequence=sequence,
notify_emails=notify,
)
except Exception: # noqa: BLE001 - invitation delivery is best-effort
_log.warning("calendar: failed to send invitations for %s", event_id, exc_info=True)
@router.post("/events", dependencies=[Depends(require_csrf)])
async def create_event(body: EventIn, user_id: CurrentUserId) -> dict[str, str]:
attendees = [a.model_dump() for a in body.attendees]
async with _SDK.session(user_id) as s:
await _assert_writable(s, body.calendar_id)
new_id, ical_uid = await _insert_event(s, user_id, body, attendees)
# 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
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,
)
# Email only the newly-added guests; the ICS lists the full (merged) roster.
if invite_new and merged_attendees is not None:
def eff(key: str) -> object:
return fields[key] if key in fields else existing[key]
try:
await invites.send_invites(
user_id=user_id,
event_id=event_id,
ical_uid=str(existing["ical_uid"]),
summary=str(eff("summary") or ""),
description=str(eff("description") or ""),
location=str(eff("location") or ""),
dtstart=_aware(eff("dtstart")), # type: ignore[arg-type]
dtend=_aware(eff("dtend")) if eff("dtend") else None, # type: ignore[arg-type]
tz=str(eff("tz") or "UTC"),
all_day=bool(eff("all_day")),
organizer=existing["organizer"],
attendees=merged_attendees,
conference_url=str(eff("conference_url") or ""),
sequence=int(existing["sequence"]) + 1,
notify_emails={str(a["email"]).lower() for a in invite_new},
)
except Exception: # noqa: BLE001 - invitation delivery is best-effort
_log.warning("calendar: edit invitations failed for %s", event_id, exc_info=True)
return {"status": "ok"}
@router.delete("/events/{event_id}", dependencies=[Depends(require_csrf)])
async def delete_event(
event_id: uuid.UUID,
user_id: CurrentUserId,
scope: str = Query("all"),
occurrence: datetime | None = Query(None),
) -> dict[str, str]:
"""Delete an event. For recurring events ``scope`` selects what to remove:
``all`` (whole series), ``this`` (add an EXDATE for ``occurrence``), or
``following`` (truncate the series just before ``occurrence``)."""
async with _SDK.session(user_id) as s:
row = (
await s.execute(
text("SELECT calendar_id, dtstart, rrule FROM plg_calendar_events WHERE 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"])
recurring = row["rrule"] is not None
if not recurring or scope == "all" or occurrence is None:
await s.execute(
text("DELETE FROM plg_calendar_events WHERE id = :id"), {"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("DELETE FROM plg_calendar_events WHERE id = :id"), {"id": str(event_id)}
)
else:
until = (occ - timedelta(seconds=1)).astimezone(UTC).strftime("%Y%m%dT%H%M%SZ")
trimmed = _strip_recurrence_end(str(row["rrule"]))
new_rrule = f"{trimmed};UNTIL={until}" if trimmed else f"UNTIL={until}"
await s.execute(
text("UPDATE plg_calendar_events SET rrule = :r WHERE id = :id"),
{"r": new_rrule, "id": str(event_id)},
)
else:
raise HTTPException(status_code=400, detail="bad_scope")
return {"status": "ok"}
# --- RSVP (public: guests respond from the invitation email, no login) --------
def _rsvp_page(message: str) -> str:
return (
"<!doctype html><html><head><meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width, initial-scale=1'>"
"<title>Invitation response</title></head>"
"<body style='margin:0;font-family:Arial,Helvetica,sans-serif;background:#f1f3f4;"
"color:#3c4043'><div style='max-width:460px;margin:64px auto;background:#fff;"
"border:1px solid #e0e0e0;border-radius:8px;padding:32px;text-align:center'>"
"<h2 style='margin:0 0 12px;font-size:18px'>Webmail Calendar</h2>"
f"<p style='margin:0;color:#5f6368'>{message}</p></div></body></html>"
)
@router.get("/rsvp")
async def rsvp(t: str = Query(...), r: str = Query(...)) -> HTMLResponse:
parsed = invites.read_token(t)
if parsed is None or r not in invites.RESPONSES:
return HTMLResponse(
_rsvp_page("This invitation link is invalid or has expired."), status_code=400
)
user_id, event_id, email = parsed
async with _SDK.session(user_id) as s:
row = (
await s.execute(
text("SELECT attendees FROM plg_calendar_events WHERE id = :id"),
{"id": str(event_id)},
)
).mappings().first()
if row is None:
return HTMLResponse(_rsvp_page("This event no longer exists."), status_code=404)
attendees = list(row["attendees"] or [])
matched = False
for a in attendees:
if str(a.get("email", "")).lower() == email.lower():
a["partstat"] = r
matched = True
if not matched:
return HTMLResponse(
_rsvp_page("You are not on the guest list for this event."), status_code=404
)
await s.execute(
text("UPDATE plg_calendar_events SET attendees = CAST(:att AS jsonb) WHERE id = :id"),
{"att": json.dumps(attendees), "id": str(event_id)},
)
return HTMLResponse(_rsvp_page(f"Your response “{invites.RESPONSES[r]}” was recorded."))
# --- bundle ------------------------------------------------------------------
@router.get("/ui/{filename}")
async def bundle(filename: str) -> FileResponse:
if filename != "page.js":
raise HTTPException(status_code=404)
return FileResponse(_UI / filename, media_type="text/javascript")
@@ -0,0 +1,66 @@
"""Request models for the calendar API (SPEC §A)."""
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel
class CalendarIn(BaseModel):
name: str
color: str = "#3b82f6"
class CalendarPatch(BaseModel):
name: str | None = None
color: str | None = None
class Attendee(BaseModel):
email: str
name: str = ""
# RSVP status: needs-action | accepted | declined | tentative
partstat: str = "needs-action"
class EventIn(BaseModel):
calendar_id: uuid.UUID
summary: str = ""
description: str = ""
location: str = ""
dtstart: datetime
dtend: datetime | None = None
all_day: bool = False
tz: str = "UTC"
rrule: str | None = None
reminders: list[int] = []
attendees: list[Attendee] = [] # stored in A1; iMIP invites are sent in A2
organizer: Attendee | None = None
color: str | None = None # None = inherit the calendar colour
show_as: str = "busy" # busy | free
conference_url: str = "" # Jitsi Meet video-call link; empty = none
class EditFollowingIn(BaseModel):
# ``recurrence_start`` = the clicked occurrence's original start; ``event`` = the
# edited fields. Splits the series so occurrences BEFORE it are untouched.
recurrence_start: datetime
event: EventIn
class EventPatch(BaseModel):
summary: str | None = None
description: str | None = None
location: str | None = None
dtstart: datetime | None = None
dtend: datetime | None = None
all_day: bool | None = None
tz: str | None = None
rrule: str | None = None
reminders: list[int] | None = None
attendees: list[Attendee] | None = None
color: str | None = None
show_as: str | None = None
conference_url: str | None = None
File diff suppressed because it is too large Load Diff