Deleting an event the user organizes, with other guests, asks "Would you like to inform the participants that this event is canceled?" (Yes, inform them and delete / No, just delete / Cancel; for a recurring one after which occurrences). Yes sends each guest an iTIP CANCEL from the organizer's mailbox — of one occurrence via RECURRENCE-ID — or, for "this and following", the shortened series. Invitations now carry their RRULE, so recurring events repeat for guests too. An organizer's cancellation is applied as the email arrives (message.new hook) or, for older mail, when the card is opened — always, whatever the settings: only from that event's organizer and not older than the calendar's copy; one occurrence becomes an EXDATE. The card says "Event was removed from calendar". Settings → Calendars → Invitations now work, defaulting to "Do not automatically update my calendar": "Only update events that I have already added" applies the organizer's changes (keeping the user's answer), "Add new events…" also adds new invitations, unanswered — from contacts or anyone — to the chosen calendar, and "mark as read and file" archives such messages. With no automatic updates, a changed invitation says so on its card. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
385 lines
14 KiB
Python
385 lines
14 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, vRecur, 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,
|
||
method: str = "REQUEST",
|
||
rrule: str | None = None,
|
||
recurrence_id: datetime | None = None,
|
||
) -> str:
|
||
"""An iTIP message about one event: REQUEST (an invitation or an update) or
|
||
CANCEL (the event — or, with ``recurrence_id``, one occurrence — is off)."""
|
||
cal = Calendar()
|
||
cal.add("prodid", "-//Ultimo//Calendar//EN")
|
||
cal.add("version", "2.0")
|
||
cal.add("method", method)
|
||
ev = Event()
|
||
ev.add("uid", ical_uid)
|
||
ev.add("dtstamp", datetime.now(UTC))
|
||
ev.add("dtstart", dtstart)
|
||
if dtend:
|
||
ev.add("dtend", dtend)
|
||
# A repeating event repeats in the guest's calendar too.
|
||
if rrule:
|
||
ev.add("rrule", vRecur.from_ical(rrule))
|
||
if recurrence_id is not None:
|
||
ev.add("recurrence-id", recurrence_id)
|
||
if method == "CANCEL":
|
||
ev.add("status", "CANCELLED")
|
||
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 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,
|
||
rrule: 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,
|
||
rrule=rrule,
|
||
)
|
||
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,
|
||
)
|
||
|
||
|
||
# --- cancellations ------------------------------------------------------------
|
||
|
||
|
||
def build_cancel_html(*, summary: str, when: str, organizer: dict[str, str] | None) -> str:
|
||
who = (organizer or {}).get("name") or (organizer or {}).get("email") or "The organizer"
|
||
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">'
|
||
'<p style="margin:0 0 6px;color:#d93025;font-weight:700">Event canceled</p>'
|
||
'<p style="margin:0 0 18px;font-size:20px;text-decoration:line-through">'
|
||
f"{_esc(summary)}</p>"
|
||
'<p style="margin:0 0 4px;font-weight:700">When</p>'
|
||
f'<p style="margin:0 0 18px;color:#5f6368">{_esc(when)}</p>'
|
||
f'<p style="margin:0;color:#5f6368">{_esc(who)} has canceled this event.</p>'
|
||
"</div>"
|
||
'<p style="max-width:600px;margin:16px auto;color:#9aa0a6;font-size:12px;padding:0 4px">'
|
||
"You are receiving this message because you were a guest of this event. "
|
||
"Sent by Ultimo Calendar.</p>"
|
||
"</body></html>"
|
||
)
|
||
|
||
|
||
async def send_cancellation(
|
||
*,
|
||
user_id: uuid.UUID,
|
||
ical_uid: str,
|
||
summary: str,
|
||
dtstart: datetime,
|
||
dtend: datetime | None,
|
||
tz: str,
|
||
all_day: bool,
|
||
organizer: dict[str, str] | None,
|
||
attendees: list[dict[str, str]],
|
||
sequence: int,
|
||
notify_emails: set[str],
|
||
rrule: str | None = None,
|
||
recurrence_id: datetime | None = None,
|
||
) -> int:
|
||
"""Tell guests the event is off: one iTIP CANCEL per guest, from the
|
||
organizer's mailbox — their calendar (Google, Outlook, Apple) removes it.
|
||
With ``recurrence_id`` only that occurrence. Returns how many were sent."""
|
||
ics = build_ics(
|
||
ical_uid=ical_uid,
|
||
summary=summary,
|
||
description="",
|
||
location="",
|
||
dtstart=dtstart,
|
||
dtend=dtend,
|
||
organizer=organizer,
|
||
attendees=attendees,
|
||
sequence=sequence,
|
||
method="CANCEL",
|
||
rrule=rrule if recurrence_id is None else None,
|
||
recurrence_id=recurrence_id,
|
||
)
|
||
when = format_when(dtstart, dtend, tz, all_day)
|
||
subject_when = _subject_when(dtstart, dtend, tz, all_day)
|
||
html = build_cancel_html(summary=summary or "(no title)", when=when, organizer=organizer)
|
||
text = f"Event canceled: {summary or '(no title)'}\n{when}\n"
|
||
sent = 0
|
||
for a in attendees:
|
||
email = a.get("email")
|
||
if not email or email.lower() not in notify_emails:
|
||
continue
|
||
await _SDK.mail.send(
|
||
user_id,
|
||
to=[email],
|
||
subject=f"Canceled: {summary or '(no title)'} - {subject_when}",
|
||
html=html,
|
||
text=text,
|
||
from_email=(organizer or {}).get("email"),
|
||
ics=ics,
|
||
ics_method="CANCEL",
|
||
ics_filename="cancel.ics",
|
||
)
|
||
sent += 1
|
||
return sent
|