Event invitations in Mail, video previews, People tab, matching avatars
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>
This commit is contained in:
@@ -21,4 +21,4 @@ calendar = "webmail_plugin_calendar:plugin"
|
||||
packages = ["webmail_plugin_calendar"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
webmail_plugin_calendar = ["ui/*.js", "migrations/versions/*.py"]
|
||||
webmail_plugin_calendar = ["ui/*.js", "ui/*.svg", "migrations/versions/*.py"]
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
A **core, non-removable** plugin (``builtin=True``) that ships a `/calendar` page
|
||||
beside Mail. Its own ``plg_calendar_*`` tables (RLS-isolated) hold calendars and
|
||||
events; recurrence is expanded at query time. Uses the SDK only — zero core diffs.
|
||||
Phase A1 (local events) is the current scope; iMIP (A2) and CalDAV/ICS (A3) build
|
||||
on the same data model and the SDK's vault + multi-method HTTP.
|
||||
Local events (A1), iMIP invitations sent and received (A2) and CalDAV / ICS (A3)
|
||||
share one data model, the SDK's vault and its multi-method HTTP.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.plugins.sdk import JobSpec, Page, PageNav, Plugin, UiManifest
|
||||
from app.plugins.sdk import JobSpec, Page, PageNav, Plugin, Slot, UiManifest
|
||||
from webmail_plugin_calendar.router import router
|
||||
from webmail_plugin_calendar.sync import caldav_sync
|
||||
|
||||
@@ -25,6 +25,9 @@ plugin = Plugin(
|
||||
jobs=[JobSpec(func=caldav_sync, schedule="*/15 * * * *")],
|
||||
router=router,
|
||||
ui_manifest=UiManifest(
|
||||
# An invitation received by email, above the message body (iMIP): renders
|
||||
# only for a message carrying an .ics, so ordinary mail shows nothing.
|
||||
slots=[Slot(name="message.banner", bundle="/api/plugins/calendar/ui/invite.js")],
|
||||
pages=[
|
||||
Page(
|
||||
route="/calendar",
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Invitations received by email (iMIP, RFC 6047 / iTIP, RFC 5546).
|
||||
|
||||
An invitation is an email carrying a ``text/calendar; method=REQUEST`` part (or
|
||||
``CANCEL`` when the organizer calls the event off). ``calendar_part`` finds it,
|
||||
``parse_invite`` turns its event into what the reading pane shows and the
|
||||
calendar stores, and ``build_reply`` makes the ``METHOD:REPLY`` the organizer's
|
||||
calendar (Google, Outlook, …) reads to record the answer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import email
|
||||
import re
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from email import policy
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
# Google wraps its Meet details in this block; it means nothing elsewhere.
|
||||
_GOOGLE_BLOCK = re.compile(r"-::~[:~]*::-.*?-::~[:~]*::-", re.S)
|
||||
_PARTSTATS = {"accepted", "declined", "tentative", "needs-action"}
|
||||
|
||||
|
||||
def calendar_part(raw: bytes) -> str | None:
|
||||
"""The calendar data of an email: its inline ``text/calendar`` part (the one
|
||||
carrying METHOD), else an attached ``.ics``. None for any other email."""
|
||||
msg = email.message_from_bytes(raw, policy=policy.default)
|
||||
attached: str | None = None
|
||||
for part in msg.walk():
|
||||
ctype = part.get_content_type()
|
||||
name = (part.get_filename() or "").lower()
|
||||
if ctype not in ("text/calendar", "application/ics") and not name.endswith(".ics"):
|
||||
continue
|
||||
payload = part.get_payload(decode=True)
|
||||
if not isinstance(payload, bytes):
|
||||
continue
|
||||
text = payload.decode(part.get_content_charset() or "utf-8", "replace")
|
||||
if ctype == "text/calendar" and not part.get_filename():
|
||||
return text
|
||||
attached = attached or text
|
||||
return attached
|
||||
|
||||
|
||||
def _address(prop: Any) -> dict[str, str] | None:
|
||||
if prop is None:
|
||||
return None
|
||||
addr = str(prop)
|
||||
email_addr = addr[7:] if addr.lower().startswith("mailto:") else addr
|
||||
name = str(prop.params.get("CN", "")).strip() if hasattr(prop, "params") else ""
|
||||
email_addr = email_addr.strip().lower()
|
||||
# Some senders put the address itself in CN: that is no name.
|
||||
return {"email": email_addr, "name": "" if name.lower() == email_addr else name}
|
||||
|
||||
|
||||
def _zone(start_prop: Any, start: date | datetime) -> str:
|
||||
"""The event's IANA time zone (for recurrence and display), else UTC."""
|
||||
candidates = [str(start_prop.params.get("TZID", ""))] if hasattr(start_prop, "params") else []
|
||||
if isinstance(start, datetime) and start.tzinfo is not None:
|
||||
zone = getattr(start.tzinfo, "key", "") or getattr(start.tzinfo, "zone", "")
|
||||
candidates.append(str(zone))
|
||||
for name in candidates:
|
||||
if not name:
|
||||
continue
|
||||
try:
|
||||
ZoneInfo(name)
|
||||
return name
|
||||
except (ZoneInfoNotFoundError, ValueError):
|
||||
continue
|
||||
return "UTC"
|
||||
|
||||
|
||||
def _moment(value: date | datetime, tz: str, all_day: bool) -> datetime:
|
||||
if all_day or not isinstance(value, datetime):
|
||||
d = value if not isinstance(value, datetime) else value.date()
|
||||
return datetime(d.year, d.month, d.day, tzinfo=UTC)
|
||||
if value.tzinfo is None: # floating time: read it in the event's zone
|
||||
return value.replace(tzinfo=ZoneInfo(tz))
|
||||
return value
|
||||
|
||||
|
||||
def parse_invite(ics: str) -> dict[str, Any] | None:
|
||||
"""The event an invitation is about, or None if there is none."""
|
||||
from icalendar import Calendar
|
||||
|
||||
try:
|
||||
cal = Calendar.from_ical(ics)
|
||||
except Exception: # noqa: BLE001 - malformed calendar data: no invitation
|
||||
return None
|
||||
events = list(cal.walk("VEVENT"))
|
||||
if not events:
|
||||
return None
|
||||
# A series with changed occurrences: the series itself (no RECURRENCE-ID).
|
||||
comp = next((e for e in events if e.get("recurrence-id") is None), events[0])
|
||||
start_prop = comp.get("dtstart")
|
||||
if start_prop is None:
|
||||
return None
|
||||
start = start_prop.dt
|
||||
all_day = not isinstance(start, datetime)
|
||||
tz = _zone(start_prop, start)
|
||||
dtstart = _moment(start, tz, all_day)
|
||||
end_prop = comp.get("dtend")
|
||||
if end_prop is not None:
|
||||
dtend = _moment(end_prop.dt, tz, all_day)
|
||||
elif comp.get("duration") is not None:
|
||||
dtend = dtstart + comp.get("duration").dt
|
||||
else:
|
||||
dtend = dtstart + (timedelta(days=1) if all_day else timedelta(hours=1))
|
||||
recurrence = comp.get("recurrence-id")
|
||||
rrule = comp.get("rrule")
|
||||
attendees = []
|
||||
raw_attendees = comp.get("attendee") or []
|
||||
for prop in raw_attendees if isinstance(raw_attendees, list) else [raw_attendees]:
|
||||
who = _address(prop)
|
||||
if who is None or not who["email"]:
|
||||
continue
|
||||
stat = str(prop.params.get("PARTSTAT", "NEEDS-ACTION")).lower()
|
||||
who["partstat"] = stat if stat in _PARTSTATS else "needs-action"
|
||||
attendees.append(who)
|
||||
description = _GOOGLE_BLOCK.sub("", str(comp.get("description") or "")).strip()
|
||||
location = str(comp.get("location") or "").strip()
|
||||
conference = str(comp.get("x-google-conference") or "").strip()
|
||||
if not conference and location.startswith(("https://meet.", "https://teams.", "https://zoom.")):
|
||||
conference = location
|
||||
return {
|
||||
"method": str(cal.get("method") or "REQUEST").upper(),
|
||||
"uid": str(comp.get("uid") or ""),
|
||||
"sequence": int(comp.get("sequence") or 0),
|
||||
"summary": str(comp.get("summary") or ""),
|
||||
"description": description,
|
||||
"location": location,
|
||||
"conference_url": conference,
|
||||
"dtstart": dtstart,
|
||||
"dtend": dtend,
|
||||
"all_day": all_day,
|
||||
"tz": tz,
|
||||
"rrule": rrule.to_ical().decode() if rrule is not None else None,
|
||||
"recurrence_id": _moment(recurrence.dt, tz, all_day) if recurrence is not None else None,
|
||||
"status": str(comp.get("status") or "confirmed").lower(),
|
||||
"organizer": _address(comp.get("organizer")),
|
||||
"attendees": attendees,
|
||||
"show_as": "free" if str(comp.get("transp") or "").upper() == "TRANSPARENT" else "busy",
|
||||
}
|
||||
|
||||
|
||||
_REPLY_WORD = {"accepted": "Accepted", "declined": "Declined", "tentative": "Tentatively accepted"}
|
||||
|
||||
|
||||
def reply_subject(partstat: str, summary: str) -> str:
|
||||
return f"{_REPLY_WORD.get(partstat, 'Reply')}: {summary}"
|
||||
|
||||
|
||||
def build_reply(ics: str, me: dict[str, str], partstat: str) -> str:
|
||||
"""The ``METHOD:REPLY`` telling the organizer ``me`` answered ``partstat``:
|
||||
the invitation's own event identity (UID, SEQUENCE, times, RECURRENCE-ID)
|
||||
with just the replying attendee, as RFC 5546 asks."""
|
||||
from icalendar import Calendar, Event, vCalAddress
|
||||
|
||||
source = Calendar.from_ical(ics)
|
||||
events = list(source.walk("VEVENT"))
|
||||
original = next((e for e in events if e.get("recurrence-id") is None), events[0])
|
||||
cal = Calendar()
|
||||
cal.add("prodid", "-//Ultimo//Calendar//EN")
|
||||
cal.add("version", "2.0")
|
||||
cal.add("method", "REPLY")
|
||||
for tz in source.walk("VTIMEZONE"):
|
||||
cal.add_component(tz)
|
||||
ev = Event()
|
||||
keep = (
|
||||
"uid",
|
||||
"sequence",
|
||||
"dtstart",
|
||||
"dtend",
|
||||
"duration",
|
||||
"recurrence-id",
|
||||
"summary",
|
||||
"organizer",
|
||||
)
|
||||
for key in keep:
|
||||
if original.get(key) is not None:
|
||||
ev[key] = original[key]
|
||||
ev.add("dtstamp", datetime.now(UTC))
|
||||
attendee = vCalAddress(f"mailto:{me['email']}")
|
||||
attendee.params["PARTSTAT"] = partstat.upper()
|
||||
if me.get("name"):
|
||||
attendee.params["CN"] = me["name"]
|
||||
ev.add("attendee", attendee)
|
||||
cal.add_component(ev)
|
||||
return cal.to_ical().decode()
|
||||
@@ -193,7 +193,7 @@ def build_html(
|
||||
"</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>"
|
||||
"Sent by Ultimo Calendar.</p>"
|
||||
"</body></html>"
|
||||
)
|
||||
|
||||
|
||||
@@ -10,10 +10,14 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from html import escape as html_escape
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||
@@ -22,7 +26,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.plugins.sdk import CurrentUserId, require_csrf, sdk
|
||||
from webmail_plugin_calendar import invites
|
||||
from webmail_plugin_calendar import imip, invites
|
||||
from webmail_plugin_calendar.queries import expand_range
|
||||
from webmail_plugin_calendar.schemas import (
|
||||
CalendarIn,
|
||||
@@ -31,6 +35,7 @@ from webmail_plugin_calendar.schemas import (
|
||||
EditFollowingIn,
|
||||
EventIn,
|
||||
EventPatch,
|
||||
InvitationAnswer,
|
||||
SubscribeIn,
|
||||
)
|
||||
|
||||
@@ -473,7 +478,15 @@ async def list_events(
|
||||
"""List events overlapping [start, end). With ``q`` set, only events whose
|
||||
summary/location/description match (case-insensitive) are returned — used by
|
||||
the calendar search panel over a wide date window."""
|
||||
range_start, range_end = _aware(start), _aware(end)
|
||||
async with _SDK.session(user_id) as s:
|
||||
return {"events": await _events_between(s, _aware(start), _aware(end), q)}
|
||||
|
||||
|
||||
async def _events_between(
|
||||
s: AsyncSession, range_start: datetime, range_end: datetime, q: str = ""
|
||||
) -> list[dict[str, object]]:
|
||||
"""Every event occurrence overlapping [range_start, range_end), recurrence
|
||||
expanded (the calendar views, search, and an invitation's conflicts)."""
|
||||
params: dict[str, object] = {"end": range_end}
|
||||
where = "WHERE e.dtstart < :end AND e.dav_deleted = false"
|
||||
term = q.strip()
|
||||
@@ -483,23 +496,22 @@ async def list_events(
|
||||
"OR COALESCE(e.description, '') ILIKE :q)"
|
||||
)
|
||||
params["q"] = f"%{term}%"
|
||||
async with _SDK.session(user_id) as s:
|
||||
rows = (
|
||||
await s.execute(
|
||||
text(
|
||||
"SELECT e.id, e.calendar_id, e.ical_uid, e.summary, e.description, "
|
||||
"e.location, e.dtstart, e.dtend, e.all_day, e.tz, e.rrule, e.reminders, "
|
||||
"e.exdates, e.recurrence_id, e.status, "
|
||||
"e.my_partstat, e.origin_message_id, e.show_as, e.conference_url, "
|
||||
"e.attendees, e.organizer, "
|
||||
"COALESCE(e.color, c.color) AS color "
|
||||
"FROM plg_calendar_events e "
|
||||
"JOIN plg_calendar_calendars c ON c.id = e.calendar_id "
|
||||
f"{where}"
|
||||
),
|
||||
params,
|
||||
)
|
||||
).mappings().all()
|
||||
rows = (
|
||||
await s.execute(
|
||||
text(
|
||||
"SELECT e.id, e.calendar_id, e.ical_uid, e.summary, e.description, "
|
||||
"e.location, e.dtstart, e.dtend, e.all_day, e.tz, e.rrule, e.reminders, "
|
||||
"e.exdates, e.recurrence_id, e.status, "
|
||||
"e.my_partstat, e.origin_message_id, e.show_as, e.conference_url, "
|
||||
"e.attendees, e.organizer, "
|
||||
"COALESCE(e.color, c.color) AS color "
|
||||
"FROM plg_calendar_events e "
|
||||
"JOIN plg_calendar_calendars c ON c.id = e.calendar_id "
|
||||
f"{where}"
|
||||
),
|
||||
params,
|
||||
)
|
||||
).mappings().all()
|
||||
base = [
|
||||
r
|
||||
for r in rows
|
||||
@@ -507,7 +519,7 @@ async def list_events(
|
||||
and (r["rrule"] is not None or (r["dtend"] or r["dtstart"]) > range_start)
|
||||
]
|
||||
overrides = [r for r in rows if r["recurrence_id"] is not None]
|
||||
return {"events": expand_range(base, overrides, range_start, range_end)}
|
||||
return expand_range(base, overrides, range_start, range_end)
|
||||
|
||||
|
||||
async def _insert_event(
|
||||
@@ -882,15 +894,32 @@ async def public_calendar(token: str) -> Response:
|
||||
|
||||
# --- RSVP (public: guests respond from the invitation email, no login) --------
|
||||
|
||||
def _logo_svg() -> str:
|
||||
"""The Ultimo signet, inline (a guest's browser loads nothing else), 56 px wide."""
|
||||
try:
|
||||
svg = (_UI / "ultimo-logo-signet.svg").read_text(encoding="utf-8")
|
||||
except OSError: # never let a missing picture stop the calendar from loading
|
||||
return ""
|
||||
svg = re.sub(r"<\?xml[^>]*\?>|<!--.*?-->", "", svg, flags=re.S).strip()
|
||||
# Inkscape sizes it in mm: let the box decide (the viewBox keeps its shape).
|
||||
return re.sub(r"<svg\b([^>]*?)\swidth=\"[^\"]*\"([^>]*?)\sheight=\"[^\"]*\"",
|
||||
r'<svg\1 width="56"\2 height="54"', svg, count=1)
|
||||
|
||||
|
||||
_LOGO = _logo_svg()
|
||||
|
||||
|
||||
def _rsvp_page(message: str) -> str:
|
||||
"""What a guest sees after clicking Yes / No / Maybe in our invitation."""
|
||||
return (
|
||||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||
"<meta name='viewport' content='width=device-width, initial-scale=1'>"
|
||||
"<title>Invitation response</title></head>"
|
||||
"<title>Invitation response · Ultimo Calendar</title></head>"
|
||||
"<body style='margin:0;font-family:Arial,Helvetica,sans-serif;background:#f1f3f4;"
|
||||
"color:#3c4043'><div style='max-width:460px;margin:64px auto;background:#fff;"
|
||||
"border:1px solid #e0e0e0;border-radius:8px;padding:32px;text-align:center'>"
|
||||
"<h2 style='margin:0 0 12px;font-size:18px'>Webmail Calendar</h2>"
|
||||
f"<div style='display:flex;justify-content:center;margin:0 0 14px'>{_LOGO}</div>"
|
||||
"<h2 style='margin:0 0 12px;font-size:18px'>Ultimo Calendar</h2>"
|
||||
f"<p style='margin:0;color:#5f6368'>{message}</p></div></body></html>"
|
||||
)
|
||||
|
||||
@@ -929,11 +958,327 @@ async def rsvp(t: str = Query(...), r: str = Query(...)) -> HTMLResponse:
|
||||
return HTMLResponse(_rsvp_page(f"Your response “{invites.RESPONSES[r]}” was recorded."))
|
||||
|
||||
|
||||
# --- invitations received by email (iMIP) -----------------------------------
|
||||
#
|
||||
# The reading pane shows an invitation email as a card (core MessageInvitation):
|
||||
# when, what, where, who organizes, what else is in the calendar then, and
|
||||
# Yes / No / Maybe. Answering adds (or updates, or removes) the event in the
|
||||
# user's calendar and sends the organizer an iTIP REPLY, so their calendar
|
||||
# records the answer too.
|
||||
|
||||
_ANSWERS = ("accepted", "declined", "tentative")
|
||||
|
||||
|
||||
def _declined_key(inv: dict[str, Any]) -> str:
|
||||
"""Where a "No" is remembered: declining takes the event out of the calendar,
|
||||
so the calendar no longer holds the answer the card should show."""
|
||||
rid = inv["recurrence_id"].isoformat() if inv["recurrence_id"] else ""
|
||||
return f"declined:{inv['uid']}:{rid}"
|
||||
|
||||
|
||||
async def _invitation(user_id: uuid.UUID, message_id: uuid.UUID) -> tuple[str, dict[str, Any]]:
|
||||
raw = await _SDK.messages.raw(user_id, message_id)
|
||||
ics = imip.calendar_part(raw) if raw else None
|
||||
inv = imip.parse_invite(ics) if ics else None
|
||||
if ics is None or inv is None or not inv["uid"]:
|
||||
raise HTTPException(status_code=404, detail="no_invitation")
|
||||
return ics, inv
|
||||
|
||||
|
||||
async def _me(user_id: uuid.UUID, inv: dict[str, Any]) -> dict[str, str] | None:
|
||||
"""The user among the invitation's attendees (any of their accounts)."""
|
||||
mine = {e.lower(): n for _a, e, n in await _SDK.accounts.of_user(user_id)}
|
||||
for a in inv["attendees"]:
|
||||
if a["email"] in mine:
|
||||
name = a.get("name") or mine[a["email"]]
|
||||
return {"email": a["email"], "name": name, "partstat": a["partstat"]}
|
||||
return None
|
||||
|
||||
|
||||
async def _invited_copy(s: AsyncSession, inv: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""The event this invitation already put in the user's calendar, if any."""
|
||||
row = (
|
||||
await s.execute(
|
||||
text(
|
||||
"SELECT e.id, e.calendar_id, e.sequence, e.my_partstat, c.kind, c.name "
|
||||
"FROM plg_calendar_events e JOIN plg_calendar_calendars c ON c.id = e.calendar_id "
|
||||
"WHERE e.ical_uid = :uid AND e.recurrence_id IS NOT DISTINCT FROM :rid "
|
||||
"AND e.dav_deleted = false ORDER BY e.sequence DESC LIMIT 1"
|
||||
),
|
||||
{"uid": inv["uid"], "rid": inv["recurrence_id"]},
|
||||
)
|
||||
).mappings().first()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def _invitation_calendar(s: AsyncSession, user_id: uuid.UUID) -> uuid.UUID:
|
||||
"""Where accepted invitations go: Settings → Calendars → Invitations, else
|
||||
the default calendar, else the first one that can be written to."""
|
||||
settings = await _SDK.accounts.settings_of(user_id)
|
||||
await _ensure_default_calendar(s, user_id)
|
||||
rows = (
|
||||
await s.execute(
|
||||
text("SELECT id FROM plg_calendar_calendars WHERE read_only = false ORDER BY name")
|
||||
)
|
||||
).scalars().all()
|
||||
writable = [str(r) for r in rows]
|
||||
for wanted in (settings.get("invitation_calendar"), settings.get("default_calendar")):
|
||||
if wanted and str(wanted) in writable:
|
||||
return uuid.UUID(str(wanted))
|
||||
return uuid.UUID(writable[0])
|
||||
|
||||
|
||||
def _brief(ev: dict[str, Any]) -> dict[str, Any]:
|
||||
return {k: ev.get(k) for k in ("summary", "start", "end", "all_day", "color")}
|
||||
|
||||
|
||||
async def _invitation_view(user_id: uuid.UUID, message_id: uuid.UUID) -> dict[str, Any]:
|
||||
_ics, inv = await _invitation(user_id, message_id)
|
||||
me = await _me(user_id, inv)
|
||||
start, end = inv["dtstart"], inv["dtend"]
|
||||
settings = await _SDK.accounts.settings_of(user_id)
|
||||
try:
|
||||
zone = ZoneInfo(str(settings.get("tz") or inv["tz"]))
|
||||
except (ZoneInfoNotFoundError, ValueError):
|
||||
zone = ZoneInfo("UTC")
|
||||
day_start = start.astimezone(zone).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
async with _SDK.session(user_id) as s:
|
||||
copy = await _invited_copy(s, inv)
|
||||
around = await _events_between(s, day_start, max(end, day_start + timedelta(days=1)))
|
||||
others = [
|
||||
e
|
||||
for e in around
|
||||
if e.get("ical_uid") != inv["uid"]
|
||||
and e.get("status") != "cancelled"
|
||||
and e.get("my_partstat") != "declined"
|
||||
]
|
||||
|
||||
def times(e: dict[str, Any]) -> tuple[datetime, datetime]:
|
||||
return datetime.fromisoformat(str(e["start"])), datetime.fromisoformat(str(e["end"]))
|
||||
|
||||
# What the user is already busy with then (timed events, unless this one is
|
||||
# all-day), and else the event just before it that day.
|
||||
conflicts = [
|
||||
_brief(e)
|
||||
for e in others
|
||||
if e.get("show_as") != "free"
|
||||
and (inv["all_day"] or not e.get("all_day"))
|
||||
and times(e)[0] < end
|
||||
and times(e)[1] > start
|
||||
]
|
||||
earlier = sorted(
|
||||
(e for e in others if not e.get("all_day") and times(e)[1] <= start),
|
||||
key=lambda e: times(e)[1],
|
||||
)
|
||||
declined = await _SDK.kv(user_id).get(_declined_key(inv))
|
||||
response = (
|
||||
(copy or {}).get("my_partstat")
|
||||
or ("declined" if declined else None)
|
||||
or (me or {}).get("partstat")
|
||||
or "needs-action"
|
||||
)
|
||||
return {
|
||||
"method": inv["method"],
|
||||
"uid": inv["uid"],
|
||||
"summary": inv["summary"],
|
||||
"description": inv["description"],
|
||||
"location": inv["location"],
|
||||
"conference_url": inv["conference_url"],
|
||||
"start": start.isoformat(),
|
||||
"end": end.isoformat(),
|
||||
"all_day": inv["all_day"],
|
||||
"tz": inv["tz"],
|
||||
"recurring": inv["rrule"] is not None,
|
||||
"status": inv["status"],
|
||||
"organizer": inv["organizer"],
|
||||
"attendees": inv["attendees"],
|
||||
"me": {"email": me["email"], "name": me["name"]} if me else None,
|
||||
"response": response,
|
||||
"in_calendar": (
|
||||
{"event_id": str(copy["id"]), "calendar": copy["name"]} if copy else None
|
||||
),
|
||||
# The calendar holds a newer version (the organizer changed it since).
|
||||
"outdated": bool(copy and int(copy["sequence"]) > inv["sequence"]),
|
||||
"conflicts": conflicts,
|
||||
"previous": _brief(earlier[-1]) if earlier and not conflicts else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/invitations/{message_id}")
|
||||
async def get_invitation(message_id: uuid.UUID, user_id: CurrentUserId) -> dict[str, Any]:
|
||||
"""The invitation in an email, as the reading pane shows it; 404 if none."""
|
||||
return await _invitation_view(user_id, message_id)
|
||||
|
||||
|
||||
def _default_reminders(settings: dict[str, Any]) -> list[int]:
|
||||
out = []
|
||||
for r in settings.get("default_reminders_timed") or []:
|
||||
minutes = r.get("minutes") if isinstance(r, dict) else r
|
||||
if isinstance(minutes, int):
|
||||
out.append(minutes)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/invitations/{message_id}/respond", dependencies=[Depends(require_csrf)])
|
||||
async def respond_to_invitation(
|
||||
message_id: uuid.UUID, body: InvitationAnswer, user_id: CurrentUserId
|
||||
) -> dict[str, Any]:
|
||||
"""Answer an invitation: Yes / Maybe put it in the calendar (or update it),
|
||||
No takes it out; then the organizer gets the iTIP REPLY."""
|
||||
if body.response not in _ANSWERS:
|
||||
raise HTTPException(status_code=400, detail="bad_response")
|
||||
ics, inv = await _invitation(user_id, message_id)
|
||||
if inv["method"] != "REQUEST":
|
||||
raise HTTPException(status_code=400, detail="not_a_request")
|
||||
me = await _me(user_id, inv)
|
||||
if me is None:
|
||||
raise HTTPException(status_code=400, detail="not_invited")
|
||||
attendees = [
|
||||
{**a, "partstat": body.response} if a["email"] == me["email"] else a
|
||||
for a in inv["attendees"]
|
||||
]
|
||||
dav_calendar: str | None = None
|
||||
async with _SDK.session(user_id) as s:
|
||||
copy = await _invited_copy(s, inv)
|
||||
if body.response == "declined":
|
||||
if copy is not None:
|
||||
await s.execute(
|
||||
text(
|
||||
"UPDATE plg_calendar_events SET dav_deleted = true WHERE id = :id"
|
||||
if copy["kind"] == "caldav"
|
||||
else "DELETE FROM plg_calendar_events WHERE id = :id"
|
||||
),
|
||||
{"id": str(copy["id"])},
|
||||
)
|
||||
dav_calendar = str(copy["calendar_id"]) if copy["kind"] == "caldav" else None
|
||||
elif copy is not None and int(copy["sequence"]) > inv["sequence"]:
|
||||
# An older copy of the invitation: keep the newer event, just answer.
|
||||
await s.execute(
|
||||
text("UPDATE plg_calendar_events SET my_partstat = :p WHERE id = :id"),
|
||||
{"p": body.response, "id": str(copy["id"])},
|
||||
)
|
||||
dav_calendar = str(copy["calendar_id"]) if copy["kind"] == "caldav" else None
|
||||
else:
|
||||
fields = {
|
||||
"sum": inv["summary"],
|
||||
"desc": inv["description"],
|
||||
"loc": inv["location"],
|
||||
"start": inv["dtstart"],
|
||||
"end": inv["dtend"],
|
||||
"allday": inv["all_day"],
|
||||
"tz": inv["tz"],
|
||||
"rrule": inv["rrule"],
|
||||
"rid": inv["recurrence_id"],
|
||||
"status": "cancelled" if inv["status"] == "cancelled" else "confirmed",
|
||||
"org": json.dumps(inv["organizer"]) if inv["organizer"] else None,
|
||||
"att": json.dumps(attendees),
|
||||
"mine": body.response,
|
||||
"seq": inv["sequence"],
|
||||
"raw": ics,
|
||||
"msg": str(message_id),
|
||||
"conf": inv["conference_url"],
|
||||
"showas": inv["show_as"],
|
||||
}
|
||||
if copy is not None:
|
||||
await s.execute(
|
||||
text(
|
||||
"UPDATE plg_calendar_events SET summary = :sum, description = :desc, "
|
||||
"location = :loc, dtstart = :start, dtend = :end, all_day = :allday, "
|
||||
"tz = :tz, rrule = :rrule, status = :status, "
|
||||
"organizer = CAST(:org AS jsonb), attendees = CAST(:att AS jsonb), "
|
||||
"my_partstat = :mine, sequence = :seq, raw_ical = :raw, "
|
||||
"origin_message_id = :msg, conference_url = :conf, show_as = :showas "
|
||||
"WHERE id = :id"
|
||||
),
|
||||
{**fields, "id": str(copy["id"])},
|
||||
)
|
||||
dav_calendar = str(copy["calendar_id"]) if copy["kind"] == "caldav" else None
|
||||
else:
|
||||
calendar_id = await _invitation_calendar(s, user_id)
|
||||
settings = await _SDK.accounts.settings_of(user_id)
|
||||
await s.execute(
|
||||
text(
|
||||
"INSERT INTO plg_calendar_events "
|
||||
"(user_id, calendar_id, ical_uid, summary, description, location, "
|
||||
" dtstart, dtend, all_day, tz, rrule, recurrence_id, status, organizer, "
|
||||
" attendees, my_partstat, sequence, raw_ical, origin_message_id, "
|
||||
" conference_url, show_as, reminders) "
|
||||
"VALUES (:u, :cal, :uid, :sum, :desc, :loc, :start, :end, :allday, :tz, "
|
||||
" :rrule, :rid, :status, CAST(:org AS jsonb), CAST(:att AS jsonb), :mine, "
|
||||
" :seq, :raw, :msg, :conf, :showas, :rem)"
|
||||
),
|
||||
{
|
||||
**fields,
|
||||
"u": str(user_id),
|
||||
"cal": str(calendar_id),
|
||||
"uid": inv["uid"],
|
||||
"rem": _default_reminders(settings),
|
||||
},
|
||||
)
|
||||
if await _is_caldav(s, calendar_id):
|
||||
dav_calendar = str(calendar_id)
|
||||
if dav_calendar:
|
||||
await _enqueue_dav_sync(uuid.UUID(dav_calendar))
|
||||
kv = _SDK.kv(user_id)
|
||||
if body.response == "declined":
|
||||
await kv.set(_declined_key(inv), True)
|
||||
else:
|
||||
await kv.delete(_declined_key(inv))
|
||||
|
||||
# Tell the organizer — unless the user organized it themselves.
|
||||
organizer = inv["organizer"]
|
||||
reply_sent = False
|
||||
if organizer and organizer["email"] != me["email"]:
|
||||
who = me["name"] or me["email"]
|
||||
word = {"accepted": "accepted", "declined": "declined", "tentative": "tentatively accepted"}
|
||||
sentence = f"{who} has {word[body.response]} this invitation."
|
||||
try:
|
||||
await _SDK.mail.send(
|
||||
user_id,
|
||||
to=[organizer["email"]],
|
||||
subject=imip.reply_subject(body.response, inv["summary"]),
|
||||
html=f"<p>{html_escape(sentence)}</p>",
|
||||
text=sentence,
|
||||
from_email=me["email"],
|
||||
ics=imip.build_reply(ics, me, body.response),
|
||||
ics_method="REPLY",
|
||||
ics_filename="reply.ics",
|
||||
)
|
||||
reply_sent = True
|
||||
except Exception: # noqa: BLE001 - the calendar is updated either way
|
||||
_log.exception("invitation reply to %s failed", organizer["email"])
|
||||
view = await _invitation_view(user_id, message_id)
|
||||
view["reply_sent"] = reply_sent
|
||||
return view
|
||||
|
||||
|
||||
@router.post("/invitations/{message_id}/remove", dependencies=[Depends(require_csrf)])
|
||||
async def remove_cancelled_event(message_id: uuid.UUID, user_id: CurrentUserId) -> dict[str, Any]:
|
||||
"""The organizer cancelled the event: take it out of the calendar."""
|
||||
_ics, inv = await _invitation(user_id, message_id)
|
||||
dav_calendar: str | None = None
|
||||
async with _SDK.session(user_id) as s:
|
||||
copy = await _invited_copy(s, inv)
|
||||
if copy is not None:
|
||||
await s.execute(
|
||||
text(
|
||||
"UPDATE plg_calendar_events SET dav_deleted = true WHERE id = :id"
|
||||
if copy["kind"] == "caldav"
|
||||
else "DELETE FROM plg_calendar_events WHERE id = :id"
|
||||
),
|
||||
{"id": str(copy["id"])},
|
||||
)
|
||||
dav_calendar = str(copy["calendar_id"]) if copy["kind"] == "caldav" else None
|
||||
if dav_calendar:
|
||||
await _enqueue_dav_sync(uuid.UUID(dav_calendar))
|
||||
return await _invitation_view(user_id, message_id)
|
||||
|
||||
|
||||
# --- bundle ------------------------------------------------------------------
|
||||
|
||||
@router.get("/ui/{filename}")
|
||||
async def bundle(filename: str) -> FileResponse:
|
||||
if filename != "page.js":
|
||||
if filename not in ("page.js", "invite.js"):
|
||||
raise HTTPException(status_code=404)
|
||||
# `no-cache` = the browser must revalidate before reusing (ETag makes a 304 cheap),
|
||||
# so an edited bundle is picked up on the next page reload instead of a stale copy.
|
||||
|
||||
@@ -78,3 +78,9 @@ class EventPatch(BaseModel):
|
||||
color: str | None = None
|
||||
show_as: str | None = None
|
||||
conference_url: str | None = None
|
||||
|
||||
|
||||
class InvitationAnswer(BaseModel):
|
||||
"""Yes / No / Maybe to an emailed invitation (iTIP PARTSTAT)."""
|
||||
|
||||
response: str # accepted | declined | tentative
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
// calendar · message.banner slot: an invitation received by email (iMIP).
|
||||
//
|
||||
// Above the message body: when, what, where, who organizes, what else is in the
|
||||
// calendar then, and Yes / No / Maybe. Answering puts the event in the calendar
|
||||
// (or takes it out) and sends the organizer the reply their calendar reads.
|
||||
//
|
||||
// Mounts on every opened message, so it stays silent and cheap for ordinary
|
||||
// mail: nothing without attachments, and only a message with an .ics attachment
|
||||
// asks the server about an invitation.
|
||||
|
||||
const STYLE_ID = "cal-invite-styles";
|
||||
const CSS = `
|
||||
.cal-inv { margin: 4px 16px 12px; padding: 16px 18px; border: 1px solid rgb(var(--app-border));
|
||||
border-radius: 10px; background: rgb(var(--app-surface)); color: rgb(var(--app-fg)); font-size: 14px; }
|
||||
.cal-inv-when { font-weight: 600; font-size: 13px; }
|
||||
.cal-inv-when .cal-inv-sep { margin: 0 6px; opacity: .6; }
|
||||
.cal-inv-title { margin: 4px 0 14px; font-size: 21px; line-height: 1.25; word-break: break-word; }
|
||||
.cal-inv-cancelled .cal-inv-title { text-decoration: line-through; opacity: .7; }
|
||||
.cal-inv-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px 24px; }
|
||||
.cal-inv-row { display: flex; align-items: flex-start; gap: 12px; min-width: 0; color: rgb(var(--app-fg)); }
|
||||
.cal-inv-row svg { flex: none; margin-top: 1px; color: rgb(var(--app-muted)); }
|
||||
.cal-inv-row .cal-inv-text { min-width: 0; overflow-wrap: anywhere; }
|
||||
.cal-inv-row a { color: rgb(var(--app-accent)); text-decoration: none; }
|
||||
.cal-inv-row a:hover { text-decoration: underline; }
|
||||
.cal-inv-faint { color: rgb(var(--app-muted)); }
|
||||
.cal-inv-cal { margin-top: 12px; }
|
||||
.cal-inv-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .03em;
|
||||
color: rgb(var(--app-muted)); margin-bottom: 2px; }
|
||||
.cal-inv-conflict { color: rgb(217 119 6); }
|
||||
.cal-inv-btns { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 16px; }
|
||||
.cal-inv-btn { min-width: 72px; padding: 8px 18px; border-radius: 999px; font-weight: 600; font-size: 14px;
|
||||
border: 1px solid rgb(var(--app-border)); background: rgb(var(--app-hover)); color: rgb(var(--app-fg));
|
||||
cursor: pointer; }
|
||||
.cal-inv-btn:hover:not(:disabled) { border-color: rgb(var(--app-accent)); }
|
||||
.cal-inv-btn.on { background: rgb(var(--app-accent)); border-color: rgb(var(--app-accent));
|
||||
color: rgb(var(--app-accent-fg)); }
|
||||
.cal-inv-btn:disabled { opacity: .6; cursor: default; }
|
||||
.cal-inv-note { font-size: 12px; color: rgb(var(--app-muted)); }
|
||||
.cal-inv-note.err { color: rgb(220 38 38); }
|
||||
.cal-inv-badge { display: inline-block; margin-left: 8px; padding: 1px 7px; border-radius: 6px; font-size: 11px;
|
||||
font-weight: 700; text-transform: uppercase; vertical-align: 2px; background: rgb(220 38 38 / .15); color: rgb(220 38 38); }
|
||||
`;
|
||||
|
||||
const ICON = {
|
||||
place:
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21s-7-6.2-7-11.5a7 7 0 0 1 14 0C19 14.8 12 21 12 21z"/><circle cx="12" cy="9.5" r="2.5"/></svg>',
|
||||
video:
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="6" width="13" height="12" rx="2"/><path d="M16 10.5l5-3v9l-5-3z"/></svg>',
|
||||
people:
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M3 19c.6-3.3 3-5 6-5s5.4 1.7 6 5"/><circle cx="17" cy="9" r="2.4"/><path d="M15.5 14.2c2.6-.3 4.7 1.2 5.5 4.3"/></svg>',
|
||||
calendar:
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="5" width="17" height="15.5" rx="2"/><path d="M3.5 10h17M8 3v4M16 3v4"/></svg>',
|
||||
};
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
||||
}
|
||||
|
||||
// 12/24-hour and date order follow Settings → Appearance, like the rest of Mail.
|
||||
let hour12Promise = null;
|
||||
function hour12(ctx) {
|
||||
hour12Promise ??= ctx.api
|
||||
.fetch("/settings")
|
||||
.then((s) => s && s.time_format === "12")
|
||||
.catch(() => false);
|
||||
return hour12Promise;
|
||||
}
|
||||
|
||||
function clock(d, h12) {
|
||||
return d.toLocaleTimeString(h12 ? "en-US" : "en-GB", { hour: h12 ? "numeric" : "2-digit", minute: "2-digit", hour12: h12 });
|
||||
}
|
||||
|
||||
function dayLabel(d, h12) {
|
||||
const today = new Date();
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(today.getDate() + 1);
|
||||
if (d.toDateString() === today.toDateString()) return "Today";
|
||||
if (d.toDateString() === tomorrow.toDateString()) return "Tomorrow";
|
||||
const sameYear = d.getFullYear() === today.getFullYear();
|
||||
return d.toLocaleDateString(h12 ? "en-US" : "en-GB", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
...(sameYear ? {} : { year: "numeric" }),
|
||||
});
|
||||
}
|
||||
|
||||
function whenLine(inv, h12) {
|
||||
const s = new Date(inv.start);
|
||||
const e = new Date(inv.end);
|
||||
const sep = '<span class="cal-inv-sep">•</span>';
|
||||
if (inv.all_day) {
|
||||
// An all-day event ends at the start of the day after its last one.
|
||||
const last = new Date(e.getTime() - 1);
|
||||
const days = last.toDateString() === s.toDateString() ? dayLabel(s, h12) : `${dayLabel(s, h12)} – ${dayLabel(last, h12)}`;
|
||||
return `${esc(days)}${sep}All day`;
|
||||
}
|
||||
const sameDay = s.toDateString() === e.toDateString();
|
||||
const range = sameDay
|
||||
? `${clock(s, h12)} – ${clock(e, h12)}`
|
||||
: `${clock(s, h12)} – ${esc(dayLabel(e, h12))} ${clock(e, h12)}`;
|
||||
return `${esc(dayLabel(s, h12))}${sep}${range}${inv.recurring ? `${sep}Repeats` : ""}`;
|
||||
}
|
||||
|
||||
function eventSpan(ev, h12) {
|
||||
if (ev.all_day) return "all day";
|
||||
return `${clock(new Date(ev.start), h12)} – ${clock(new Date(ev.end), h12)}`;
|
||||
}
|
||||
|
||||
function calendarLine(inv, h12) {
|
||||
if (inv.conflicts && inv.conflicts.length) {
|
||||
const first = inv.conflicts[0];
|
||||
const more = inv.conflicts.length > 1 ? ` and ${inv.conflicts.length - 1} more` : "";
|
||||
return `<span class="cal-inv-conflict">Conflicts with: ${esc(first.summary || "(no title)")} at ${eventSpan(first, h12)}${more}</span>`;
|
||||
}
|
||||
if (inv.previous) {
|
||||
return `Previous event: ${esc(inv.previous.summary || "(no title)")} at ${eventSpan(inv.previous, h12)}`;
|
||||
}
|
||||
return '<span class="cal-inv-faint">No other events at that time</span>';
|
||||
}
|
||||
|
||||
const ANSWERS = [
|
||||
["accepted", "Yes"],
|
||||
["declined", "No"],
|
||||
["tentative", "Maybe"],
|
||||
];
|
||||
|
||||
function render(el, inv, h12, note) {
|
||||
const cancelled = inv.method === "CANCEL" || inv.status === "cancelled";
|
||||
const org = inv.organizer;
|
||||
const orgLabel = org ? `${esc(org.name || org.email)} <span class="cal-inv-faint">– organizer</span>` : "";
|
||||
const conf = inv.conference_url;
|
||||
// A meeting link given as the location reads better as a link.
|
||||
const place =
|
||||
conf && (!inv.location || inv.location === conf)
|
||||
? `<div class="cal-inv-row">${ICON.video}<span class="cal-inv-text"><a href="${esc(conf)}" target="_blank" rel="noopener noreferrer">Join video call</a></span></div>`
|
||||
: inv.location
|
||||
? `<div class="cal-inv-row">${ICON.place}<span class="cal-inv-text">${esc(inv.location)}${
|
||||
conf ? ` · <a href="${esc(conf)}" target="_blank" rel="noopener noreferrer">Join video call</a>` : ""
|
||||
}</span></div>`
|
||||
: "";
|
||||
let actions = "";
|
||||
if (cancelled) {
|
||||
actions = inv.in_calendar
|
||||
? '<div class="cal-inv-btns"><button class="cal-inv-btn" data-remove>Remove from calendar</button></div>'
|
||||
: '<div class="cal-inv-btns"><span class="cal-inv-note">Not in your calendar.</span></div>';
|
||||
} else if (inv.method === "REQUEST" && inv.me) {
|
||||
actions =
|
||||
'<div class="cal-inv-btns">' +
|
||||
ANSWERS.map(
|
||||
([value, label]) =>
|
||||
`<button class="cal-inv-btn${inv.response === value ? " on" : ""}" data-answer="${value}" aria-pressed="${inv.response === value}">${label}</button>`,
|
||||
).join("") +
|
||||
(note ? `<span class="cal-inv-note${note.err ? " err" : ""}">${esc(note.text)}</span>` : "") +
|
||||
"</div>";
|
||||
} else if (inv.method === "REQUEST") {
|
||||
actions = '<div class="cal-inv-btns"><span class="cal-inv-note">You are not on the guest list of this event.</span></div>';
|
||||
}
|
||||
el.innerHTML = `
|
||||
<div class="cal-inv${cancelled ? " cal-inv-cancelled" : ""}" role="group" aria-label="Event invitation">
|
||||
<div class="cal-inv-when">${whenLine(inv, h12)}${cancelled ? '<span class="cal-inv-badge">Cancelled</span>' : ""}</div>
|
||||
<div class="cal-inv-title">${esc(inv.summary || "(no title)")}</div>
|
||||
<div class="cal-inv-grid">
|
||||
${place}
|
||||
${org ? `<div class="cal-inv-row">${ICON.people}<span class="cal-inv-text">${orgLabel}</span></div>` : ""}
|
||||
</div>
|
||||
${
|
||||
cancelled
|
||||
? ""
|
||||
: `<div class="cal-inv-row cal-inv-cal">${ICON.calendar}<span class="cal-inv-text">
|
||||
<div class="cal-inv-label">In calendar${inv.in_calendar ? ` · ${esc(inv.in_calendar.calendar)}` : ""}</div>
|
||||
${calendarLine(inv, h12)}
|
||||
${inv.outdated ? '<div class="cal-inv-faint">Your calendar has a newer version of this event.</div>' : ""}
|
||||
</span></div>`
|
||||
}
|
||||
${actions}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export default function mount(el, ctx, props) {
|
||||
const messageId = props && props.messageId;
|
||||
// Ordinary mail stays silent: invitations come with an .ics attached.
|
||||
if (!messageId || props.hasAttachments === false) return;
|
||||
let disposed = false;
|
||||
if (!document.getElementById(STYLE_ID)) {
|
||||
const style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = CSS;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
async function show(inv, note) {
|
||||
const h12 = await hour12(ctx);
|
||||
if (disposed) return;
|
||||
render(el, inv, h12, note);
|
||||
el.querySelectorAll("[data-answer]").forEach((b) =>
|
||||
b.addEventListener("click", () => answer(b.dataset.answer, inv)),
|
||||
);
|
||||
const remove = el.querySelector("[data-remove]");
|
||||
if (remove) remove.addEventListener("click", () => send(`/invitations/${messageId}/remove`, {}, inv, "Removed from your calendar."));
|
||||
}
|
||||
|
||||
async function send(path, body, inv, okText) {
|
||||
el.querySelectorAll("button").forEach((b) => (b.disabled = true));
|
||||
try {
|
||||
const next = await ctx.api.pluginFetch(path, { method: "POST", body: JSON.stringify(body) });
|
||||
window.dispatchEvent(new CustomEvent("webmail:calendar-changed"));
|
||||
await show(next, { text: typeof okText === "function" ? okText(next) : okText });
|
||||
} catch {
|
||||
await show(inv, { text: "Couldn't save your answer. Try again.", err: true });
|
||||
}
|
||||
}
|
||||
|
||||
function answer(value, inv) {
|
||||
send(`/invitations/${messageId}/respond`, { response: value }, inv, (next) => {
|
||||
// The reply going out is the toast's news (Mail's corner toast, like Undo).
|
||||
if (next.reply_sent) {
|
||||
window.dispatchEvent(new CustomEvent("webmail:toast", { detail: { text: "Reply sent" } }));
|
||||
}
|
||||
return value === "declined"
|
||||
? "Not in your calendar."
|
||||
: `Saved in ${next.in_calendar ? next.in_calendar.calendar : "your calendar"}.`;
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const files = await ctx.api.fetch(`/messages/${messageId}/attachments`);
|
||||
const hasIcs = (files || []).some(
|
||||
(a) => /\.ics$/i.test(a.filename || "") || /calendar|ics/i.test(a.mime_type || ""),
|
||||
);
|
||||
if (!hasIcs || disposed) return;
|
||||
const inv = await ctx.api.pluginFetch(`/invitations/${messageId}`);
|
||||
if (!disposed && inv) await show(inv, null);
|
||||
} catch {
|
||||
/* not an invitation (404), or offline: no banner — never block the message */
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
el.innerHTML = "";
|
||||
};
|
||||
}
|
||||
@@ -132,7 +132,10 @@ const videoLinkHtml = (link) => `<div class="cal-videolink">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>
|
||||
<a href="${esc(link)}" target="_blank" rel="noopener noreferrer">${esc(link)}</a>
|
||||
<button class="cal-iconbtn" type="button" data-act="copy-jitsi" title="Copy link"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
|
||||
<button class="cal-iconbtn" type="button" data-act="remove-video" title="Remove video call" aria-label="Remove video call"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
|
||||
</div>`;
|
||||
// The Video-call section without a link: the button that adds one.
|
||||
const ADD_VIDEO_HTML = `<button class="cal-btn" data-act="add-jitsi" style="display:inline-flex;align-items:center;gap:6px"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="flex:0 0 auto"><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>Add a video call in Jitsi Meet</button>`;
|
||||
|
||||
const COLORS = [
|
||||
["Blue", "#3b82f6"], ["Green", "#22c55e"], ["Red", "#ef4444"], ["Orange", "#f97316"],
|
||||
@@ -335,6 +338,9 @@ const CSS = `
|
||||
.cal-agenda .day .dt{width:44px;flex:0 0 auto;font-size:12px;color:rgb(var(--app-muted))}
|
||||
.cal-agenda .day .dt b{display:block;font-size:18px;color:rgb(var(--app-fg))}
|
||||
.cal-agenda .none{color:rgb(var(--app-faint));font-size:13px}
|
||||
.cal-agenda .day.empty{cursor:pointer}
|
||||
.cal-agenda .day.empty:hover{background:rgb(var(--app-hover))}
|
||||
.cal-agenda .day.empty:hover .none{color:rgb(var(--app-accent))}
|
||||
/* new-event form (floating box) */
|
||||
/* Content-sized box: grows in height as sections are added, scrolls only past the
|
||||
viewport cap; the Save/Cancel footer sticks to the bottom while scrolling. */
|
||||
@@ -935,6 +941,19 @@ export default function mount(el, ctx, props) {
|
||||
box.innerHTML = videoLinkHtml(link);
|
||||
const copyBtn = box.querySelector("[data-act=copy-jitsi]");
|
||||
if (copyBtn) copyBtn.addEventListener("click", () => copyLink(link, copyBtn));
|
||||
const removeBtn = box.querySelector("[data-act=remove-video]");
|
||||
if (removeBtn) removeBtn.addEventListener("click", clearVideo);
|
||||
}
|
||||
// ✕ on the link: no video call — and none re-added when another guest is invited.
|
||||
function clearVideo() {
|
||||
if (!state.composing) return;
|
||||
state.composing.conference = "";
|
||||
state.composing.videoRemoved = true;
|
||||
const box = el.querySelector("[data-video]");
|
||||
if (!box) return;
|
||||
box.innerHTML = ADD_VIDEO_HTML;
|
||||
const add = box.querySelector("[data-act=add-jitsi]");
|
||||
if (add) add.addEventListener("click", () => fillVideo(jitsiLink(state.jitsi)));
|
||||
}
|
||||
function copyLink(link, btn) {
|
||||
try { navigator.clipboard.writeText(link); } catch { /* ignore */ }
|
||||
@@ -968,7 +987,9 @@ export default function mount(el, ctx, props) {
|
||||
guests.appendChild(span);
|
||||
// Adding a NEW guest auto-provisions a video call if none exists yet
|
||||
// (skipped when restoring an existing event's guests during edit).
|
||||
if (autoVideo && !state.composing.conference) fillVideo(jitsiLink(state.jitsi));
|
||||
if (autoVideo && !state.composing.conference && !state.composing.videoRemoved) {
|
||||
fillVideo(jitsiLink(state.jitsi));
|
||||
}
|
||||
}
|
||||
inp.value = "";
|
||||
acpop.style.display = "none";
|
||||
@@ -1296,7 +1317,7 @@ export default function mount(el, ctx, props) {
|
||||
<h4>Video call</h4>
|
||||
<div data-video>${c.conference
|
||||
? videoLinkHtml(c.conference)
|
||||
: `<button class="cal-btn" data-act="add-jitsi" style="display:inline-flex;align-items:center;gap:6px"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="flex:0 0 auto"><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>Add a video call in Jitsi Meet</button>`}</div>
|
||||
: ADD_VIDEO_HTML}</div>
|
||||
<h4>Reminders</h4>
|
||||
<div data-reminders></div>
|
||||
<button class="cal-btn" data-act="add-reminder" style="margin-top:6px">+ Add reminder</button>
|
||||
@@ -1482,7 +1503,10 @@ export default function mount(el, ctx, props) {
|
||||
const d = addDays(start, i);
|
||||
if (!short && d.getMonth() !== start.getMonth()) break;
|
||||
const dayEvs = evs.filter((ev) => sameDay(ev._s, d)).sort((a, b) => a._s - b._s);
|
||||
html += `<div class="day" data-agday="${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}"><div class="dt">${DAYNAMES[(d.getDay() + 6) % 7].toUpperCase()}<b>${d.getDate()}</b></div><div style="flex:1">${
|
||||
const key = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||
// An empty day is a place to add one: click → new event on that date.
|
||||
const empty = dayEvs.length ? "" : ` empty" data-newday="${key}" role="button" title="New event on this day`;
|
||||
html += `<div class="day${empty}" data-agday="${key}"><div class="dt">${DAYNAMES[(d.getDay() + 6) % 7].toUpperCase()}<b>${d.getDate()}</b></div><div style="flex:1">${
|
||||
dayEvs.length ? dayEvs.map((ev) => `<div class="cal-ev" data-ev="${ev.id}" data-start="${esc(ev.start)}" style="border-color:${esc(ev.color)}">${ev.all_day ? "" : hhmm(ev._s) + " "}${esc(ev.summary || "(no title)")}</div>`).join("") : `<span class="none">No events</span>`
|
||||
}</div></div>`;
|
||||
}
|
||||
@@ -1540,6 +1564,12 @@ export default function mount(el, ctx, props) {
|
||||
act("add-calendar", () => ctx.navigate("/settings/calendars?from=%2Fp%2Fcalendar&create=1"));
|
||||
act("signout", async () => { await api.fetch("/auth/logout", { method: "POST" }); ctx.navigate("/login"); });
|
||||
act("new", () => openForm(null));
|
||||
on("[data-newday]", "click", (e) => {
|
||||
// That day, at the current time of day (as clicking a day in Month view).
|
||||
const [y, m, dd] = e.currentTarget.dataset.newday.split("-").map(Number);
|
||||
const now = new Date();
|
||||
openForm(new Date(y, m - 1, dd, now.getHours(), now.getMinutes()));
|
||||
});
|
||||
act("today", () => { state.anchor = startOfDay(new Date()); state.mini = startOfMonth(state.anchor); reload(); });
|
||||
act("prev", () => go(-1));
|
||||
act("next", () => go(1));
|
||||
@@ -1710,6 +1740,7 @@ export default function mount(el, ctx, props) {
|
||||
act("add-jitsi", () => fillVideo(jitsiLink(state.jitsi)));
|
||||
// Copy handler for a link rendered on initial form paint (edit mode).
|
||||
act("copy-jitsi", (e) => copyLink(state.composing.conference, e.currentTarget));
|
||||
act("remove-video", clearVideo);
|
||||
act("add-reminder", () => {
|
||||
const box = el.querySelector("[data-reminders]");
|
||||
const wrap = document.createElement("div");
|
||||
@@ -1789,7 +1820,16 @@ export default function mount(el, ctx, props) {
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
|
||||
// An invitation answered in Mail (calendar · invite.js) changed the calendar.
|
||||
// Not while a form is open: re-rendering would throw away what is being typed.
|
||||
const onChanged = async () => {
|
||||
await loadEvents();
|
||||
if (!state.composing && !state.confirm) render();
|
||||
};
|
||||
window.addEventListener("webmail:calendar-changed", onChanged);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("webmail:calendar-changed", onChanged);
|
||||
el.innerHTML = "";
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="44.943924mm"
|
||||
height="42.975948mm"
|
||||
viewBox="0 0 44.943923 42.975948"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xml:space="preserve"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs1"><linearGradient
|
||||
id="linearGradient11"><stop
|
||||
style="stop-color:#f89629;stop-opacity:1;"
|
||||
offset="0.20198774"
|
||||
id="stop13" /><stop
|
||||
style="stop-color:#f74176;stop-opacity:1;"
|
||||
offset="1"
|
||||
id="stop12" /></linearGradient><linearGradient
|
||||
xlink:href="#linearGradient11"
|
||||
id="linearGradient12"
|
||||
x1="54.340946"
|
||||
y1="122.84999"
|
||||
x2="54.340946"
|
||||
y2="162.66415"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="matrix(0.75684298,0,0,0.80151105,3.0076311,94.135157)" /></defs><g
|
||||
id="layer1"
|
||||
transform="translate(-21.70051,-187.41349)"><path
|
||||
id="path1-2"
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;opacity:1;vector-effect:none;fill:url(#linearGradient12);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000;stop-opacity:1"
|
||||
d="m 58.875622,191.58757 c -2.152336,-7.4e-4 -4.067804,1.84724 -4.067104,4.1266 v 2.85212 c -0.073,-0.005 -0.146556,-0.007 -0.220195,-0.007 H 44.088203 33.589648 c -0.04176,-7e-5 -0.0835,5.9e-4 -0.125155,0.002 v -2.78918 c -1.66e-4,-2.27807 -1.959264,-4.12477 -4.110377,-4.12494 -2.151113,1.8e-4 -4.208743,1.84687 -4.208908,4.12494 v 15.55127 c -3.9e-5,0.005 -0.02954,3.84339 2.173004,7.60498 2.20396,3.76402 7.038201,7.38257 14.452668,7.38257 h 4.580673 c 7.417703,0 12.26918,-3.66617 14.505859,-7.44221 2.23668,-3.77604 2.27039,-7.63481 2.27039,-7.63481 l 0.0016,-0.0344 V 195.7141 c 7e-4,-2.27872 -2.102019,-4.12642 -4.253744,-4.1266 z m -2.007917,7.94794 v 11.57584 0.0286 c 0,0 -0.06644,2.0824 -1.311391,4.18416 -0.572022,0.96572 -1.445415,2.15762 -2.917676,2.99793 -1.472263,0.8403 -3.460652,1.33079 -6.272222,1.33079 H 41.78535 c -2.82381,0 -4.798635,-0.47938 -6.256969,-1.30429 -1.458334,-0.8249 -2.313565,-2.00343 -2.85901,-2.93496 -1.196636,-2.04365 -1.230039,-4.07193 -1.230039,-4.07193 l -0.0011,-0.0141 v -0.0157 -11.69637 l 0.02191,0.0178 7.82e-4,10e-4 c 0.03515,0.0487 0.07994,0.0958 0.133761,0.14041 l 5.513859,4.57144 5.515036,4.57144 c 0.651946,0.53945 2.279028,0.53945 2.930974,0 l 5.515034,-4.57144 5.514252,-4.57144 c 0.01986,-0.0164 0.03852,-0.0331 0.05593,-0.0501 l 7.82e-4,-8.2e-4 z" /></g></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
Reference in New Issue
Block a user