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:
@@ -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">– 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,
|
||||
)
|
||||
+79
@@ -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")
|
||||
+28
@@ -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")
|
||||
+26
@@ -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
Reference in New Issue
Block a user