Invitations received by email (iMIP): above the message body, a card with when, what, where (or "Join video call"), the organizer, what else is in the calendar then (a conflict, else the previous event that day), and Yes / No / Maybe. Yes and Maybe put the event in the calendar (or update it), No takes it out — and is still shown as the answer given; each sends the organizer the iTIP REPLY, then a "Reply sent" toast, and an open calendar refreshes. A cancellation offers "Remove from calendar". It is the calendar plugin's message.banner slot (ui/invite.js, imip.py, /invitations endpoints); the SDK gains the user's own accounts and settings. An Outlook-style invitation sent only as text/calendar is now kept as invite.ics too. Calendar: clicking an empty day in the side panel's agenda opens a new event on that date; a video-call link can be removed (✕ beside Copy); the page guests see after answering, and our invitations' footer, say Ultimo Calendar (with the logo). Attachments: videos (mp4, mov, webm, …) play in the full-screen preview, paused until played; attachments are now served from disk with HTTP Range, so a video starts at once and can be seeked. People tab: everyone in the thread, the user included, A–Z, under the name any message gives them. Initials circles share one colour rule in the list, the reading pane and the People tab. The contact view drops its duplicate "Message" button. A general corner toast (webmail:toast) joins Undo's. Co-Authored-By: Claude Opus 5.5 <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 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,
|
||
)
|