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:
2026-10-06 21:50:29 +02:00
co-authored by Claude Opus 5.5
parent fba3c947bf
commit 2337375bb7
24 changed files with 1443 additions and 100 deletions
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[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