2026-07-28 16:33:34 +02:00
|
|
|
|
"""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. "
|
2026-10-06 21:50:29 +02:00
|
|
|
|
"Sent by Ultimo Calendar.</p>"
|
2026-07-28 16:33:34 +02:00
|
|
|
|
"</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,
|
|
|
|
|
|
)
|