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>
290 lines
9.9 KiB
Python
290 lines
9.9 KiB
Python
"""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,
|
||
)
|