Files
webmail-client/plugins/webmail_plugin_calendar/webmail_plugin_calendar/invites.py
T

290 lines
9.9 KiB
Python
Raw Normal View History

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