Calendar: cancel events for guests, apply organizers' cancellations, invitation settings

Deleting an event the user organizes, with other guests, asks "Would you like
to inform the participants that this event is canceled?" (Yes, inform them and
delete / No, just delete / Cancel; for a recurring one after which occurrences).
Yes sends each guest an iTIP CANCEL from the organizer's mailbox — of one
occurrence via RECURRENCE-ID — or, for "this and following", the shortened
series. Invitations now carry their RRULE, so recurring events repeat for
guests too.

An organizer's cancellation is applied as the email arrives (message.new hook)
or, for older mail, when the card is opened — always, whatever the settings:
only from that event's organizer and not older than the calendar's copy; one
occurrence becomes an EXDATE. The card says "Event was removed from calendar".

Settings → Calendars → Invitations now work, defaulting to "Do not
automatically update my calendar": "Only update events that I have already
added" applies the organizer's changes (keeping the user's answer), "Add new
events…" also adds new invitations, unanswered — from contacts or anyone — to
the chosen calendar, and "mark as read and file" archives such messages. With
no automatic updates, a changed invitation says so on its card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 20:50:17 +02:00
co-authored by Claude Opus 5.5
parent 2337375bb7
commit 1982e8964b
10 changed files with 805 additions and 110 deletions
@@ -12,6 +12,7 @@ from __future__ import annotations
from pathlib import Path
from app.plugins.sdk import JobSpec, Page, PageNav, Plugin, Slot, UiManifest
from webmail_plugin_calendar.hook import on_message_new
from webmail_plugin_calendar.router import router
from webmail_plugin_calendar.sync import caldav_sync
@@ -21,6 +22,8 @@ plugin = Plugin(
name="calendar",
version="0.1.0",
builtin=True,
# An organizer's cancellation, applied to the calendar as the email arrives.
hooks={"message.new": on_message_new},
# Refresh connected CalDAV calendars every 15 minutes (also runs on connect).
jobs=[JobSpec(func=caldav_sync, schedule="*/15 * * * *")],
router=router,
@@ -0,0 +1,41 @@
"""New mail → calendar, as Settings → Calendars → Invitations say.
``message.new`` fires for every stored message; this stays cheap for ordinary
mail (only messages with attachments are read). For an invitation, an update or
a change it does what the user chose: nothing (the default — the card in the
message waits for them), keep added events current, or also add new ones. A
cancellation from the event's organizer is always applied.
"""
from __future__ import annotations
import logging
import uuid
from typing import Any
from app.plugins.sdk import sdk
from webmail_plugin_calendar.router import process_incoming
_SDK = sdk("calendar")
_log = logging.getLogger("webmail_plugin_calendar")
async def on_message_new(payload: dict[str, Any]) -> None:
uid_raw, mid_raw = payload.get("user_id"), payload.get("message_id")
if not isinstance(uid_raw, str) or not isinstance(mid_raw, str):
return
user_id, message_id = uuid.UUID(uid_raw), uuid.UUID(mid_raw)
env = await _SDK.messages.envelope(user_id, message_id)
# Received mail with a file only: not what the user sent or binned, and not
# history being imported (the card applies an old cancellation when opened).
if (
env is None
or not env.get("has_attachments")
or env.get("mailbox_special_use") in ("sent", "drafts", "trash", "junk")
or env.get("account_state") != "live"
):
return
try:
await process_incoming(user_id, message_id)
except Exception: # noqa: BLE001 - never break mail delivery over a calendar
_log.warning("calendar: handling an invitation email failed", exc_info=True)
@@ -17,7 +17,7 @@ import uuid
from datetime import UTC, datetime
from zoneinfo import ZoneInfo
from icalendar import Calendar, Event, vCalAddress, vText
from icalendar import Calendar, Event, vCalAddress, vRecur, vText
from app.plugins.sdk import sdk
@@ -82,17 +82,29 @@ def build_ics(
attendees: list[dict[str, str]],
conference_url: str = "",
sequence: int = 0,
method: str = "REQUEST",
rrule: str | None = None,
recurrence_id: datetime | None = None,
) -> str:
"""An iTIP message about one event: REQUEST (an invitation or an update) or
CANCEL (the event — or, with ``recurrence_id``, one occurrence — is off)."""
cal = Calendar()
cal.add("prodid", "-//Webmail//Calendar//EN")
cal.add("prodid", "-//Ultimo//Calendar//EN")
cal.add("version", "2.0")
cal.add("method", "REQUEST")
cal.add("method", method)
ev = Event()
ev.add("uid", ical_uid)
ev.add("dtstamp", datetime.now(UTC))
ev.add("dtstart", dtstart)
if dtend:
ev.add("dtend", dtend)
# A repeating event repeats in the guest's calendar too.
if rrule:
ev.add("rrule", vRecur.from_ical(rrule))
if recurrence_id is not None:
ev.add("recurrence-id", recurrence_id)
if method == "CANCEL":
ev.add("status", "CANCELLED")
ev.add("summary", summary or "(no title)")
desc = description or ""
if conference_url:
@@ -234,6 +246,7 @@ async def send_invites(
conference_url: str,
sequence: int = 0,
notify_emails: set[str] | None = None,
rrule: str | None = None,
) -> None:
"""Send one iMIP REQUEST per attendee, from the organizer's mailbox.
@@ -258,6 +271,7 @@ async def send_invites(
attendees=attendees,
conference_url=conference_url,
sequence=sequence,
rrule=rrule,
)
for a in attendees:
email = a.get("email")
@@ -287,3 +301,84 @@ async def send_invites(
from_email=from_email,
ics=ics,
)
# --- cancellations ------------------------------------------------------------
def build_cancel_html(*, summary: str, when: str, organizer: dict[str, str] | None) -> str:
who = (organizer or {}).get("name") or (organizer or {}).get("email") or "The organizer"
return (
'<html><body style="margin:0;background:#f1f3f4;font-family:Arial,Helvetica,sans-serif;'
'color:#3c4043;line-height:1.5">'
'<div style="max-width:600px;margin:0 auto;background:#fff;border:1px solid #e0e0e0;'
'border-radius:8px;padding:28px 32px">'
'<p style="margin:0 0 6px;color:#d93025;font-weight:700">Event canceled</p>'
'<p style="margin:0 0 18px;font-size:20px;text-decoration:line-through">'
f"{_esc(summary)}</p>"
'<p style="margin:0 0 4px;font-weight:700">When</p>'
f'<p style="margin:0 0 18px;color:#5f6368">{_esc(when)}</p>'
f'<p style="margin:0;color:#5f6368">{_esc(who)} has canceled this event.</p>'
"</div>"
'<p style="max-width:600px;margin:16px auto;color:#9aa0a6;font-size:12px;padding:0 4px">'
"You are receiving this message because you were a guest of this event. "
"Sent by Ultimo Calendar.</p>"
"</body></html>"
)
async def send_cancellation(
*,
user_id: uuid.UUID,
ical_uid: str,
summary: str,
dtstart: datetime,
dtend: datetime | None,
tz: str,
all_day: bool,
organizer: dict[str, str] | None,
attendees: list[dict[str, str]],
sequence: int,
notify_emails: set[str],
rrule: str | None = None,
recurrence_id: datetime | None = None,
) -> int:
"""Tell guests the event is off: one iTIP CANCEL per guest, from the
organizer's mailbox — their calendar (Google, Outlook, Apple) removes it.
With ``recurrence_id`` only that occurrence. Returns how many were sent."""
ics = build_ics(
ical_uid=ical_uid,
summary=summary,
description="",
location="",
dtstart=dtstart,
dtend=dtend,
organizer=organizer,
attendees=attendees,
sequence=sequence,
method="CANCEL",
rrule=rrule if recurrence_id is None else None,
recurrence_id=recurrence_id,
)
when = format_when(dtstart, dtend, tz, all_day)
subject_when = _subject_when(dtstart, dtend, tz, all_day)
html = build_cancel_html(summary=summary or "(no title)", when=when, organizer=organizer)
text = f"Event canceled: {summary or '(no title)'}\n{when}\n"
sent = 0
for a in attendees:
email = a.get("email")
if not email or email.lower() not in notify_emails:
continue
await _SDK.mail.send(
user_id,
to=[email],
subject=f"Canceled: {summary or '(no title)'} - {subject_when}",
html=html,
text=text,
from_email=(organizer or {}).get("email"),
ics=ics,
ics_method="CANCEL",
ics_filename="cancel.ics",
)
sent += 1
return sent
@@ -616,6 +616,7 @@ async def _send_invites_from(
conference_url=body.conference_url,
sequence=sequence,
notify_emails=notify,
rrule=body.rrule,
)
except Exception: # noqa: BLE001 - invitation delivery is best-effort
_log.warning("calendar: failed to send invitations for %s", event_id, exc_info=True)
@@ -808,15 +809,22 @@ async def delete_event(
user_id: CurrentUserId,
scope: str = Query("all"),
occurrence: datetime | None = Query(None),
) -> dict[str, str]:
notify: bool = Query(False),
) -> dict[str, object]:
"""Delete an event. For recurring events ``scope`` selects what to remove:
``all`` (whole series), ``this`` (add an EXDATE for ``occurrence``), or
``following`` (truncate the series just before ``occurrence``)."""
``following`` (truncate the series just before ``occurrence``).
With ``notify``, the guests of an event the user organizes are told: an iTIP
CANCEL (for ``this``, of that one occurrence), or for ``following`` the
shortened series — so their calendars drop it too."""
async with _SDK.session(user_id) as s:
row = (
await s.execute(
text(
"SELECT e.calendar_id, e.dtstart, e.rrule, c.kind "
"SELECT e.calendar_id, e.dtstart, e.dtend, e.rrule, e.ical_uid, e.sequence, "
"e.summary, e.description, e.location, e.all_day, e.tz, e.organizer, "
"e.attendees, e.conference_url, c.kind "
"FROM plg_calendar_events e JOIN plg_calendar_calendars c "
"ON c.id = e.calendar_id WHERE e.id = :id"
),
@@ -837,7 +845,9 @@ async def delete_event(
)
recurring = row["rrule"] is not None
if not recurring or scope == "all" or occurrence is None:
whole = not recurring or scope == "all" or occurrence is None
new_rrule: str | None = None
if whole:
await s.execute(text(remove_sql), {"id": str(event_id)})
elif scope == "this":
await s.execute(
@@ -852,6 +862,7 @@ async def delete_event(
if occ <= _aware(row["dtstart"]):
# Truncating from the first occurrence removes everything.
await s.execute(text(remove_sql), {"id": str(event_id)})
whole = True
else:
until = (occ - timedelta(seconds=1)).astimezone(UTC).strftime("%Y%m%dT%H%M%SZ")
trimmed = _strip_recurrence_end(str(row["rrule"]))
@@ -862,9 +873,75 @@ async def delete_event(
)
else:
raise HTTPException(status_code=400, detail="bad_scope")
if not whole: # the event lives on, changed: guests' copies must yield to it
await s.execute(
text("UPDATE plg_calendar_events SET sequence = sequence + 1 WHERE id = :id"),
{"id": str(event_id)},
)
if row["kind"] == "caldav":
await _enqueue_dav_sync(row["calendar_id"])
return {"status": "ok"}
notified = 0
if notify:
notified = await _notify_guests_of_deletion(
user_id, event_id, dict(row), whole=whole, scope=scope,
occurrence=_aware(occurrence) if occurrence else None, new_rrule=new_rrule,
)
return {"status": "ok", "notified": notified}
async def _notify_guests_of_deletion(
user_id: uuid.UUID,
event_id: uuid.UUID,
row: dict[str, Any],
*,
whole: bool,
scope: str,
occurrence: datetime | None,
new_rrule: str | None,
) -> int:
"""Tell the guests of a deleted (or shortened) event the user organized.
Best-effort: the deletion stands whatever the mail does."""
mine = {e.lower() for _a, e, _n in await _SDK.accounts.of_user(user_id)}
organizer = row["organizer"] or None
if organizer and str(organizer.get("email", "")).lower() not in mine:
return 0 # someone else's event: not the user's to cancel for its guests
attendees = list(row["attendees"] or [])
guests = {
str(a.get("email", "")).lower()
for a in attendees
if a.get("email") and str(a["email"]).lower() not in mine
}
if not guests:
return 0
sequence = int(row["sequence"] or 0) + 1
start, end = _aware(row["dtstart"]), _aware(row["dtend"]) if row["dtend"] else None
try:
if scope == "following" and not whole and new_rrule:
# Not a cancellation: the series ends earlier — an updated invitation.
await invites.send_invites(
user_id=user_id, event_id=event_id, ical_uid=row["ical_uid"],
summary=row["summary"], description=row["description"],
location=row["location"], dtstart=start, dtend=end, tz=row["tz"],
all_day=row["all_day"], organizer=organizer, attendees=attendees,
conference_url=row["conference_url"] or "", sequence=sequence,
notify_emails=guests, rrule=new_rrule,
)
return len(guests)
one = scope == "this" and not whole and occurrence is not None
if one: # just that occurrence: its own times
assert occurrence is not None
length = (end - start) if end else timedelta(hours=1)
start, end = occurrence, occurrence + length
return await invites.send_cancellation(
user_id=user_id, ical_uid=row["ical_uid"], summary=row["summary"],
dtstart=start, dtend=end, tz=row["tz"], all_day=row["all_day"],
organizer=organizer, attendees=attendees, sequence=sequence,
notify_emails=guests, rrule=row["rrule"],
recurrence_id=occurrence if one else None,
)
except Exception: # noqa: BLE001 - the event is deleted either way
_log.warning("calendar: telling guests of %s failed", event_id, exc_info=True)
return 0
# --- public read-only .ics feed (published calendars; no login) ---------------
@@ -1000,7 +1077,8 @@ async def _invited_copy(s: AsyncSession, inv: dict[str, Any]) -> dict[str, Any]
row = (
await s.execute(
text(
"SELECT e.id, e.calendar_id, e.sequence, e.my_partstat, c.kind, c.name "
"SELECT e.id, e.calendar_id, e.sequence, e.my_partstat, e.organizer, e.rrule, "
"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"
@@ -1028,12 +1106,203 @@ async def _invitation_calendar(s: AsyncSession, user_id: uuid.UUID) -> uuid.UUID
return uuid.UUID(writable[0])
def _cancelled_key(inv: dict[str, Any]) -> str:
rid = inv["recurrence_id"].isoformat() if inv["recurrence_id"] else ""
return f"cancelled:{inv['uid']}:{rid}"
async def apply_cancellation(user_id: uuid.UUID, inv: dict[str, Any]) -> str:
"""Apply an organizer's cancellation (iTIP CANCEL) to the user's calendar.
Only a genuine one: the same event, sent by that event's own organizer, and
not older than the version in the calendar — anyone can send an email that
claims to cancel a meeting. A cancelled occurrence of a series becomes an
EXDATE. Returns ``removed`` | ``not_in_calendar`` | ``not_organizer`` |
``outdated``. Applied whatever Settings → Invitations say (the one exception).
Idempotent: safe from both the new-mail hook and the card.
"""
kv = _SDK.kv(user_id)
dav_calendar: str | None = None
async with _SDK.session(user_id) as s:
target = await _invited_copy(s, inv)
occurrence_of_series = False
if target is None and inv["recurrence_id"] is not None:
# One occurrence of a series held as a single row: cancel that date.
target = await _invited_copy(s, {**inv, "recurrence_id": None})
occurrence_of_series = target is not None and target["rrule"] is not None
if target is not None and not occurrence_of_series:
target = None
if target is None:
return "removed" if await kv.get(_cancelled_key(inv)) else "not_in_calendar"
stored = target["organizer"] or {}
sender = inv["organizer"] or {}
if str(stored.get("email", "")).lower() != str(sender.get("email", "")).lower():
return "not_organizer"
if inv["sequence"] < int(target["sequence"] or 0):
return "outdated"
if occurrence_of_series:
await s.execute(
text(
"UPDATE plg_calendar_events SET exdates = array_append(exdates, :occ) "
"WHERE id = :id AND NOT (:occ = ANY(exdates))"
),
{"occ": inv["recurrence_id"], "id": str(target["id"])},
)
else:
await s.execute(
text(
"UPDATE plg_calendar_events SET dav_deleted = true WHERE id = :id"
if target["kind"] == "caldav"
else "DELETE FROM plg_calendar_events WHERE id = :id"
),
{"id": str(target["id"])},
)
if target["kind"] == "caldav":
dav_calendar = str(target["calendar_id"])
if dav_calendar:
await _enqueue_dav_sync(uuid.UUID(dav_calendar))
await kv.set(_cancelled_key(inv), True)
_log.info("calendar: applied the organizer's cancellation of %s", inv["uid"])
return "removed"
# --- Settings → Calendars → Invitations: what an emailed event does on its own -----
async def _invitation_settings(user_id: uuid.UUID) -> dict[str, Any]:
s = await _SDK.accounts.settings_of(user_id)
policy = str(s.get("invitation_policy") or "none")
return {
# none: wait for the user · update_only: keep added events current (changes,
# cancellations) · add_update: that, and add new invitations too.
"policy": policy if policy in ("none", "update_only", "add_update") else "none",
"from": str(s.get("invitation_from") or "contacts"),
"file": bool(s.get("invitation_file", False)),
}
async def _is_contact(s: AsyncSession, email: str) -> bool:
"""Whether ``email`` is in the user's contacts (Settings: "Add new events
from My contacts"). No contacts plugin, no contacts."""
try:
async with s.begin_nested(): # a missing table must not spoil the transaction
row = await s.execute(
text(
"SELECT 1 FROM plg_contacts_contacts WHERE lower(default_email) = :e "
"OR EXISTS (SELECT 1 FROM jsonb_array_elements(emails) x "
" WHERE lower(x->>'value') = :e) LIMIT 1"
),
{"e": email.lower()},
)
return row.first() is not None
except Exception: # noqa: BLE001
return False
async def apply_update(
user_id: uuid.UUID, inv: dict[str, Any], ics: str, message_id: uuid.UUID
) -> str:
"""An organizer's changed invitation (new time, place, …) for an event the
user has: bring it up to date, keeping the user's own answer. Only from that
event's organizer, and only newer. ``updated`` | ``unchanged`` |
``not_in_calendar`` | ``not_organizer``."""
dav_calendar: str | None = None
async with _SDK.session(user_id) as s:
copy = await _invited_copy(s, inv)
if copy is None:
return "not_in_calendar"
stored = copy["organizer"] or {}
sender = inv["organizer"] or {}
if str(stored.get("email", "")).lower() != str(sender.get("email", "")).lower():
return "not_organizer"
if inv["sequence"] <= int(copy["sequence"] or 0):
return "unchanged"
me = await _me(user_id, inv)
mine = copy["my_partstat"] or "needs-action"
attendees = [
{**a, "partstat": mine} if me and a["email"] == me["email"] else a
for a in inv["attendees"]
]
dav_calendar = await _store_invited_event(
s, user_id, inv, ics, message_id, attendees, mine, copy
)
if dav_calendar:
await _enqueue_dav_sync(uuid.UUID(dav_calendar))
return "updated"
async def _auto_add(
user_id: uuid.UUID, inv: dict[str, Any], ics: str, message_id: uuid.UUID, from_: str
) -> str:
"""A new invitation, added to the calendar as it arrives — not yet answered
(the card still asks). ``added`` or why not."""
me = await _me(user_id, inv)
if me is None:
return "not_invited"
organizer = (inv["organizer"] or {}).get("email", "")
if not organizer or organizer == me["email"]:
return "own_event"
if await _SDK.kv(user_id).get(_declined_key(inv)):
return "declined"
dav_calendar: str | None = None
async with _SDK.session(user_id) as s:
if from_ == "contacts" and not await _is_contact(s, organizer):
return "not_from_contact"
if await _invited_copy(s, inv) is not None:
return "exists"
dav_calendar = await _store_invited_event(
s, user_id, inv, ics, message_id, inv["attendees"], "needs-action", None
)
if dav_calendar:
await _enqueue_dav_sync(uuid.UUID(dav_calendar))
return "added"
async def process_incoming(user_id: uuid.UUID, message_id: uuid.UUID) -> str | None:
"""New mail with an invitation, cancellation or change: do what Settings →
Calendars → Invitations says (nothing, by default). Returns what changed in
the calendar — ``added`` | ``updated`` | ``removed`` — or None."""
try:
ics, inv = await _invitation(user_id, message_id)
except HTTPException:
return None
rules = await _invitation_settings(user_id)
outcome: str | None = None
if inv["method"] == "CANCEL":
# The one exception to the setting: an organizer's cancellation always
# takes the event out — an event that is off has no business staying.
outcome = await apply_cancellation(user_id, inv)
elif rules["policy"] == "none":
return None
elif inv["method"] == "REQUEST":
outcome = await apply_update(user_id, inv, ics, message_id)
if outcome == "not_in_calendar" and rules["policy"] == "add_update":
outcome = await _auto_add(user_id, inv, ics, message_id, rules["from"])
if outcome not in ("added", "updated", "removed"):
return None
if rules["file"]: # "Also mark the message as read and file it"
await _SDK.actions.set_flags(user_id, message_id, add=["\\Seen"])
await _SDK.actions.move_to_special(user_id, message_id, "archive")
_log.info("calendar: invitation email %s → %s", message_id, outcome)
return outcome
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)
ics, inv = await _invitation(user_id, message_id)
# Normally already applied when the email arrived (hook.py); this catches up
# on mail from before that. Cancellations always; changes to an event only
# when Settings allow automatic updates.
rules = await _invitation_settings(user_id)
automatic = rules["policy"] != "none"
cancellation = None
if inv["method"] == "CANCEL":
cancellation = await apply_cancellation(user_id, inv)
elif automatic and inv["method"] == "REQUEST":
await apply_update(user_id, inv, ics, message_id)
me = await _me(user_id, inv)
start, end = inv["dtstart"], inv["dtend"]
settings = await _SDK.accounts.settings_of(user_id)
@@ -1101,6 +1370,13 @@ async def _invitation_view(user_id: uuid.UUID, message_id: uuid.UUID) -> dict[st
"outdated": bool(copy and int(copy["sequence"]) > inv["sequence"]),
"conflicts": conflicts,
"previous": _brief(earlier[-1]) if earlier and not conflicts else None,
# For a cancellation: removed | not_in_calendar | not_organizer | outdated.
"cancellation": cancellation,
# The organizer changed the event since the calendar's copy (answer again
# to take the change — automatic only when Settings allow it).
"update_pending": bool(
copy and inv["method"] == "REQUEST" and inv["sequence"] > int(copy["sequence"])
),
}
@@ -1119,6 +1395,77 @@ def _default_reminders(settings: dict[str, Any]) -> list[int]:
return out
async def _store_invited_event(
s: AsyncSession,
user_id: uuid.UUID,
inv: dict[str, Any],
ics: str,
message_id: uuid.UUID,
attendees: list[dict[str, Any]],
partstat: str,
copy: dict[str, Any] | None,
) -> str | None:
"""Write an invitation's event into the calendar: update ``copy`` when the
calendar holds it, else add it to the invitations calendar. Returns the
calendar id when it is a CalDAV one (to be pushed), else None."""
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": partstat,
"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"])},
)
return str(copy["calendar_id"]) if copy["kind"] == "caldav" else None
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),
},
)
return str(calendar_id) if await _is_caldav(s, calendar_id) else None
@router.post("/invitations/{message_id}/respond", dependencies=[Depends(require_csrf)])
async def respond_to_invitation(
message_id: uuid.UUID, body: InvitationAnswer, user_id: CurrentUserId
@@ -1159,64 +1506,9 @@ async def respond_to_invitation(
)
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)
dav_calendar = await _store_invited_event(
s, user_id, inv, ics, message_id, attendees, body.response, copy
)
if dav_calendar:
await _enqueue_dav_sync(uuid.UUID(dav_calendar))
kv = _SDK.kv(user_id)
@@ -1252,28 +1544,6 @@ async def respond_to_invitation(
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}")
@@ -37,6 +37,7 @@ const CSS = `
.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-note.done { font-size: 14px; color: rgb(var(--app-fg)); }
.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); }
`;
@@ -141,9 +142,14 @@ function render(el, inv, h12, note) {
: "";
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>';
// Always applied as it arrived (or when first opened) — say what happened.
const outcome = {
removed: "Event was removed from calendar",
not_in_calendar: "This event isn't in your calendar",
not_organizer: "Not removed from your calendar: this cancellation didn't come from the event's organizer",
outdated: "Not removed from your calendar: it has a newer version of this event",
}[inv.cancellation] || "This event isn't in your calendar";
actions = `<div class="cal-inv-btns"><span class="cal-inv-note${inv.cancellation === "removed" ? " done" : ""}">${esc(outcome)}</span></div>`;
} else if (inv.method === "REQUEST" && inv.me) {
actions =
'<div class="cal-inv-btns">' +
@@ -171,6 +177,7 @@ function render(el, inv, h12, note) {
<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>' : ""}
${inv.update_pending ? '<div class="cal-inv-conflict">The organizer changed this event — answer to update your calendar.</div>' : ""}
</span></div>`
}
${actions}
@@ -196,8 +203,6 @@ export default function mount(el, ctx, props) {
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) {
@@ -232,6 +237,10 @@ export default function mount(el, ctx, props) {
if (!hasIcs || disposed) return;
const inv = await ctx.api.pluginFetch(`/invitations/${messageId}`);
if (!disposed && inv) await show(inv, null);
// Opening an older cancellation may just have applied it: refresh calendars.
if (inv && inv.method === "CANCEL" && inv.cancellation === "removed") {
window.dispatchEvent(new CustomEvent("webmail:calendar-changed"));
}
} catch {
/* not an invitation (404), or offline: no banner — never block the message */
}
@@ -430,6 +430,12 @@ const CSS = `
.cal-modal .opts{margin-top:14px;display:flex;flex-direction:column;gap:8px}
.cal-modal .opts button{display:block;width:100%;text-align:left;border:1px solid rgb(var(--app-border));background:rgb(var(--app-surface));color:rgb(var(--app-fg));padding:10px 12px;border-radius:8px;cursor:pointer;font:inherit}
.cal-modal .opts button:hover{background:rgb(var(--app-hover));border-color:#3b82f6}
.cal-modal .cal-modal-sub{margin-top:6px;font-size:12px;color:rgb(var(--app-faint))}
.cal-modal-stack{margin-top:16px;display:flex;flex-direction:column;gap:8px}
.cal-modal-stack button{width:100%;padding:10px 14px;border-radius:8px;font:inherit;font-weight:600;cursor:pointer;border:1px solid rgb(var(--app-border));background:rgb(var(--app-surface));color:rgb(var(--app-fg))}
.cal-modal-stack button:hover{background:rgb(var(--app-hover))}
.cal-modal-stack button.primary{background:#3b82f6;border-color:#3b82f6;color:#fff}
.cal-modal-stack button.primary:hover{background:#2563eb}
.cal-modal .foot{border-top:1px solid rgb(var(--app-border));padding:12px 16px;display:flex;justify-content:flex-end;gap:8px}
`;
@@ -469,7 +475,8 @@ export default function mount(el, ctx, props) {
leftCollapsed: localStorage.getItem("cal.leftCollapsed") === "1",
composing: null, // null | { date:"YYYY-MM-DD", time:"HH:MM", editId }
eventPopover: null, // null | { ev, rect }
confirm: null, // null | { ev } — delete confirmation modal
confirm: null, // null | { ev, scope? } — delete confirmation; scope set → the "inform guests?" step
myEmails: new Set(), // the user's own addresses (who is "me" among guests)
search: null, // null | { q, from:Date, to:Date, results:[], loading:bool }
searchOpen: false, // embed: search box revealed
panelCalOpen: false, // embed: "All calendars" dropdown open
@@ -798,11 +805,38 @@ export default function mount(el, ctx, props) {
render();
}
async function doDelete(scope) {
// Guests besides the user, on an event the user organizes: the people a
// deletion can be announced to. Someone else's event is not ours to cancel.
function guestsToInform(ev) {
const mine = state.myEmails;
const org = ev.organizer && ev.organizer.email ? String(ev.organizer.email).toLowerCase() : "";
if (org && !mine.has(org)) return [];
return (ev.attendees || []).filter((a) => a.email && !mine.has(String(a.email).toLowerCase()));
}
// Step past "which occurrences?": ask about telling the guests when there are
// any, else delete straight away.
function chooseScope(scope) {
if (!state.confirm) return;
if (guestsToInform(state.confirm.ev).length) {
state.confirm = { ...state.confirm, scope };
render();
} else {
doDelete(scope, false);
}
}
async function doDelete(scope, notify) {
if (!state.confirm) return;
const ev = state.confirm.ev;
state.confirm = null;
const qs = scope === "all" ? "" : `?scope=${scope}&occurrence=${encodeURIComponent(ev.start)}`;
const params = new URLSearchParams();
if (scope !== "all") {
params.set("scope", scope);
params.set("occurrence", ev.start);
}
if (notify) params.set("notify", "1");
const qs = params.toString() ? `?${params}` : "";
await api.pluginFetch(`/events/${ev.id}${qs}`, { method: "DELETE" });
await reload();
}
@@ -1360,6 +1394,22 @@ export default function mount(el, ctx, props) {
function confirmHtml() {
const ev = state.confirm.ev;
const title = esc(ev.summary || "(no title)");
// An event with guests: before deleting, ask whether to tell them.
if (state.confirm.scope || (!ev.recurring && guestsToInform(ev).length)) {
const n = guestsToInform(ev).length;
return `<div class="cal-modal-ov" data-act="confirm-cancel"><div class="cal-modal" data-stop>
<div class="body">
<h3>Delete “${title}”</h3>
<p>Would you like to inform the participants that this event is canceled?</p>
<p class="cal-modal-sub">${n === 1 ? "1 guest" : `${n} guests`} will get an email their calendar understands.</p>
<div class="cal-modal-stack">
<button class="primary" data-act="del-inform">Yes, inform them and delete</button>
<button data-act="del-quiet">No, just delete</button>
<button data-act="confirm-cancel">Cancel</button>
</div>
</div>
</div></div>`;
}
if (ev.recurring) {
return `<div class="cal-modal-ov" data-act="confirm-cancel"><div class="cal-modal" data-stop>
<div class="body">
@@ -1680,9 +1730,11 @@ export default function mount(el, ctx, props) {
// --- delete confirmation modal ---
on("[data-stop]", "click", (e) => e.stopPropagation());
act("confirm-cancel", () => { state.confirm = null; render(); });
act("del-all", () => doDelete("all"));
act("del-this", () => doDelete("this"));
act("del-following", () => doDelete("following"));
act("del-all", () => doDelete("all", false));
act("del-this", () => chooseScope("this"));
act("del-following", () => chooseScope("following"));
act("del-inform", () => doDelete((state.confirm && state.confirm.scope) || "all", true));
act("del-quiet", () => doDelete((state.confirm && state.confirm.scope) || "all", false));
on("[data-col]", "click", (e) => {
const rect = e.currentTarget.getBoundingClientRect();
const hour = Math.max(0, Math.min(23, Math.floor((e.clientY - rect.top) / HOUR_PX)));
@@ -1772,6 +1824,7 @@ export default function mount(el, ctx, props) {
try {
const accounts = await api.fetch("/accounts");
if (accounts && accounts[0]) state.me = accounts[0].email;
state.myEmails = new Set((accounts || []).map((a) => String(a.email).toLowerCase()));
} catch { /* ignore */ }
try {
const s = await api.fetch("/settings");