Files
webmail-client/plugins/webmail_plugin_calendar/webmail_plugin_calendar/invites.py
T
lukasandClaude Opus 4.8 8c634f7967 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>
2026-07-28 16:33:34 +02:00

290 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
)