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
+3 -2
View File
@@ -42,7 +42,8 @@ class SettingsOut(BaseModel):
default_calendar: str = "" # calendar id new events default to (blank => first)
default_reminders_timed: list[ReminderPref] = [] # defaults for timed events
default_reminders_allday: list[ReminderPref] = [] # defaults for all-day events
invitation_policy: str = "add_update" # how emailed events update the calendar
# How emailed events update the calendar: none | update_only | add_update.
invitation_policy: str = "none"
invitation_from: str = "contacts" # whose emailed events auto-add (contacts|anyone)
invitation_calendar: str = "" # calendar id emailed events land in (blank => default)
invitation_file: bool = False # also mark the invite message read + filed
@@ -119,7 +120,7 @@ def _settings_out(user: User) -> SettingsOut:
default_calendar=str(s.get("default_calendar", "")),
default_reminders_timed=_reminder_list(s.get("default_reminders_timed")),
default_reminders_allday=_reminder_list(s.get("default_reminders_allday")),
invitation_policy=str(s.get("invitation_policy", "add_update")),
invitation_policy=str(s.get("invitation_policy", "none")),
invitation_from=str(s.get("invitation_from", "contacts")),
invitation_calendar=str(s.get("invitation_calendar", "")),
invitation_file=bool(s.get("invitation_file", False)),
+2
View File
@@ -159,6 +159,8 @@ class PluginMessages:
for a in msg.to_addr
],
"flags": list(msg.flags),
# Whether files came with it — e.g. an invitation's .ics.
"has_attachments": bool(msg.has_attachments),
}
@@ -151,6 +151,21 @@ async def ctx(monkeypatch) -> AsyncIterator[Ctx]: # type: ignore[no-untyped-def
await engine.dispose()
async def _policy(c: Ctx, policy: str, **more: object) -> None:
"""Settings → Calendars → Invitations."""
r = await c.client.patch("/api/settings", json={"invitation_policy": policy, **more})
assert r.status_code == 200 and r.json()["invitation_policy"] == policy
async def _arrives(c: Ctx, raw: bytes) -> str:
"""A message stored and announced, as the sync engine does (message.new)."""
from webmail_plugin_calendar.hook import on_message_new
mid = await c.message(raw)
await on_message_new({"user_id": str(c.user_id), "message_id": mid})
return mid
async def _calendar_events(c: Ctx) -> list[dict[str, object]]:
r = await c.client.get(
f"{CAL}/events", params={"start": "2026-10-07T00:00:00Z", "end": "2026-10-08T00:00:00Z"}
@@ -216,21 +231,61 @@ async def test_invitation_card_and_answers(ctx: Ctx) -> None:
assert r.json()["response"] == "accepted" and r.json()["in_calendar"] is not None
async def test_cancelled_event_is_removed(ctx: Ctx) -> None:
async def test_organizers_cancellation_is_applied_on_arrival(ctx: Ctx) -> None:
from webmail_plugin_calendar.hook import on_message_new
invite = await ctx.message(_email(_ics(), "REQUEST"))
await ctx.client.post(f"{CAL}/invitations/{invite}/respond", json={"response": "accepted"})
cancel = await ctx.message(_email(_ics("CANCEL", seq=1), "CANCEL"))
assert [e for e in await _calendar_events(ctx) if e["summary"] == "Maintenance Renewal"]
# The organizer calls it off: gone as the email arrives, nothing to click.
cancel = await ctx.message(_email(_ics("CANCEL", seq=1), "CANCEL"))
await on_message_new({"user_id": str(ctx.user_id), "message_id": cancel})
assert not [e for e in await _calendar_events(ctx) if e["summary"] == "Maintenance Renewal"]
card = (await ctx.client.get(f"{CAL}/invitations/{cancel}")).json()
assert card["method"] == "CANCEL" and card["in_calendar"] is not None
# A cancellation cannot be answered, only taken out of the calendar.
assert card["method"] == "CANCEL" and card["cancellation"] == "removed"
# A cancellation cannot be answered.
no = await ctx.client.post(f"{CAL}/invitations/{cancel}/respond", json={"response": "accepted"})
assert no.status_code == 400
card = (await ctx.client.post(f"{CAL}/invitations/{cancel}/remove")).json()
assert card["in_calendar"] is None
async def test_older_cancellation_is_applied_when_opened(ctx: Ctx) -> None:
invite = await ctx.message(_email(_ics(), "REQUEST"))
await ctx.client.post(f"{CAL}/invitations/{invite}/respond", json={"response": "tentative"})
cancel = await ctx.message(_email(_ics("CANCEL", seq=1), "CANCEL")) # no hook ran
card = (await ctx.client.get(f"{CAL}/invitations/{cancel}")).json()
assert card["cancellation"] == "removed"
assert not [e for e in await _calendar_events(ctx) if e["summary"] == "Maintenance Renewal"]
async def test_cancellation_not_from_the_organizer_is_ignored(ctx: Ctx) -> None:
invite = await ctx.message(_email(_ics(), "REQUEST"))
await ctx.client.post(f"{CAL}/invitations/{invite}/respond", json={"response": "accepted"})
forged = _ics("CANCEL", seq=1).replace(f"mailto:{ORGANIZER}", "mailto:prankster@example.com")
cancel = await ctx.message(_email(forged, "CANCEL"))
card = (await ctx.client.get(f"{CAL}/invitations/{cancel}")).json()
assert card["cancellation"] == "not_organizer"
assert [e for e in await _calendar_events(ctx) if e["summary"] == "Maintenance Renewal"]
async def test_cancelled_occurrence_of_a_series(ctx: Ctx) -> None:
weekly = _ics().replace("SEQUENCE:0", "RRULE:FREQ=WEEKLY\r\nSEQUENCE:0")
invite = await ctx.message(_email(weekly, "REQUEST"))
await ctx.client.post(f"{CAL}/invitations/{invite}/respond", json={"response": "accepted"})
one = _ics("CANCEL", seq=1).replace(
"SEQUENCE:1", "RECURRENCE-ID;TZID=Europe/Warsaw:20261014T133000\r\nSEQUENCE:1"
)
cancel = await ctx.message(_email(one, "CANCEL"))
assert (await ctx.client.get(f"{CAL}/invitations/{cancel}")).json()["cancellation"] == "removed"
r = await ctx.client.get(
f"{CAL}/events", params={"start": "2026-10-06T00:00:00Z", "end": "2026-10-22T00:00:00Z"}
)
days = sorted(
e["start"][:10] for e in r.json()["events"] if e["summary"] == "Maintenance Renewal"
)
assert days == ["2026-10-07", "2026-10-21"] # 14 Oct is gone, the rest stays
async def test_not_invited_or_not_an_invitation(ctx: Ctx) -> None:
other = await ctx.message(_email(_ics(attendees=("someone@else.com",)), "REQUEST"))
card = (await ctx.client.get(f"{CAL}/invitations/{other}")).json()
@@ -254,3 +309,169 @@ def test_outlook_style_invitation_is_kept_as_a_file() -> None:
m.add_alternative(_ics(), subtype="calendar", params={"method": "REQUEST"})
files = parse_message(m.as_bytes()).attachments
assert [(a.filename, a.mime_type) for a in files] == [("invite.ics", "text/calendar")]
# --- deleting an event with guests: tell them (iTIP CANCEL) -----------------------
async def _my_event(ctx: Ctx, **extra: object) -> str:
cal_id = (await ctx.client.get(f"{CAL}/calendars")).json()[0]["id"]
body = {
"calendar_id": cal_id,
"summary": "Planning",
"dtstart": "2026-11-02T09:00:00Z",
"dtend": "2026-11-02T10:00:00Z",
"tz": "Europe/Warsaw",
"organizer": {"email": ME, "name": "Me Myself"},
"attendees": [{"email": "guest@example.net", "name": "Guest"}, {"email": ME, "name": ""}],
**extra,
}
r = await ctx.client.post(f"{CAL}/events", json=body)
assert r.status_code == 200
ctx.sent.clear() # forget the invitations
return str(r.json()["id"])
async def test_delete_and_tell_the_guests(ctx: Ctx) -> None:
event_id = await _my_event(ctx)
r = await ctx.client.delete(f"{CAL}/events/{event_id}", params={"notify": "1"})
assert r.json() == {"status": "ok", "notified": 1}
mail = ctx.sent[-1]
assert mail["to"] == ["guest@example.net"] and mail["from_email"] == ME # not to me
assert mail["ics_method"] == "CANCEL" and str(mail["subject"]).startswith("Canceled: Planning")
ics = str(mail["ics"])
assert "METHOD:CANCEL" in ics and "STATUS:CANCELLED" in ics and "SEQUENCE:1" in ics
assert "RECURRENCE-ID" not in ics
async def test_delete_quietly(ctx: Ctx) -> None:
event_id = await _my_event(ctx)
r = await ctx.client.delete(f"{CAL}/events/{event_id}")
assert r.json()["notified"] == 0 and ctx.sent == []
async def test_cancel_one_occurrence_or_shorten_the_series(ctx: Ctx) -> None:
event_id = await _my_event(ctx, rrule="FREQ=WEEKLY")
# Just 16 Nov: a CANCEL of that occurrence.
r = await ctx.client.delete(
f"{CAL}/events/{event_id}",
params={"scope": "this", "occurrence": "2026-11-16T09:00:00Z", "notify": "1"},
)
assert r.json()["notified"] == 1
ics = str(ctx.sent[-1]["ics"])
assert "METHOD:CANCEL" in ics and "RECURRENCE-ID" in ics and "20261116T" in ics
assert "RRULE" not in ics
# From 30 Nov on: not a cancellation — the series now ends earlier.
r = await ctx.client.delete(
f"{CAL}/events/{event_id}",
params={"scope": "following", "occurrence": "2026-11-30T09:00:00Z", "notify": "1"},
)
assert r.json()["notified"] == 1
mail = ctx.sent[-1]
ics = str(mail["ics"])
assert mail.get("ics_method", "REQUEST") == "REQUEST" and "METHOD:REQUEST" in ics
assert "RRULE:FREQ=WEEKLY;UNTIL=20261130T085959Z" in ics and "SEQUENCE:2" in ics
async def test_someone_elses_event_is_not_cancelled_for_its_guests(ctx: Ctx) -> None:
event_id = await _my_event(ctx, organizer={"email": "boss@example.org", "name": "Boss"})
r = await ctx.client.delete(f"{CAL}/events/{event_id}", params={"notify": "1"})
assert r.json()["notified"] == 0 and ctx.sent == []
# --- Settings → Calendars → Invitations: "When I receive events by email" --------
def _moved(ics: str, start: str, end: str) -> str:
return ics.replace("20261007T133000", start).replace("20261007T143000", end)
async def test_default_changes_nothing_on_its_own(ctx: Ctx) -> None:
# Default: "Do not automatically update my calendar".
settings = (await ctx.client.get("/api/settings")).json()
assert settings["invitation_policy"] == "none"
await _arrives(ctx, _email(_ics(), "REQUEST"))
assert not await _calendar_events(ctx) # not added
invite = await ctx.message(_email(_ics(), "REQUEST"))
await ctx.client.post(f"{CAL}/invitations/{invite}/respond", json={"response": "accepted"})
moved = await _arrives(
ctx, _email(_moved(_ics(seq=1), "20261007T150000", "20261007T160000"), "REQUEST")
)
events = [e for e in await _calendar_events(ctx) if e["summary"] == "Maintenance Renewal"]
assert [e["start"] for e in events] == ["2026-10-07T11:30:00+00:00"] # not moved
assert (await ctx.client.get(f"{CAL}/invitations/{moved}")).json()["update_pending"] is True
# The one exception: an organizer's cancellation is applied all the same.
cancel = await _arrives(ctx, _email(_ics("CANCEL", seq=2), "CANCEL"))
assert not [e for e in await _calendar_events(ctx) if e["summary"] == "Maintenance Renewal"]
card = (await ctx.client.get(f"{CAL}/invitations/{cancel}")).json()
assert card["cancellation"] == "removed"
async def test_only_update_events_already_added(ctx: Ctx) -> None:
await _policy(ctx, "update_only")
await _arrives(ctx, _email(_ics(), "REQUEST"))
assert not await _calendar_events(ctx) # a new one: not added
invite = await ctx.message(_email(_ics(), "REQUEST"))
await ctx.client.post(f"{CAL}/invitations/{invite}/respond", json={"response": "tentative"})
await _arrives(
ctx, _email(_moved(_ics(seq=1), "20261007T150000", "20261007T160000"), "REQUEST")
)
events = [e for e in await _calendar_events(ctx) if e["summary"] == "Maintenance Renewal"]
assert [e["start"] for e in events] == ["2026-10-07T13:00:00+00:00"] # moved to 15:00
assert events[0]["my_partstat"] == "tentative" # the user's own answer kept
await _arrives(ctx, _email(_ics("CANCEL", seq=2), "CANCEL"))
assert not await _calendar_events(ctx)
async def test_add_new_events_from_anyone_or_contacts(ctx: Ctx) -> None:
await _policy(ctx, "add_update", invitation_from="contacts")
await _arrives(ctx, _email(_ics(), "REQUEST"))
assert not await _calendar_events(ctx) # the organizer is not a contact
await _policy(ctx, "add_update", invitation_from="anyone")
mid = await _arrives(ctx, _email(_ics(), "REQUEST"))
events = await _calendar_events(ctx)
assert [(e["summary"], e["my_partstat"]) for e in events] == [
("Maintenance Renewal", "needs-action") # added, not answered for the user
]
card = (await ctx.client.get(f"{CAL}/invitations/{mid}")).json()
assert card["in_calendar"] is not None and card["response"] == "needs-action"
assert ctx.sent == [] # nobody was told anything
async def test_mark_read_and_file(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def]
from app import queue as app_queue
from app.models import Mailbox, Message
jobs: list[tuple[object, ...]] = []
class FakePool:
async def enqueue_job(self, *args: object, **kwargs: object) -> None:
jobs.append(args)
async def fake_pool() -> FakePool:
return FakePool()
monkeypatch.setattr(app_queue, "get_arq_pool", fake_pool)
async with ctx.sm() as s:
s.add(
Mailbox(
account_id=ctx.account_id,
user_id=ctx.user_id,
path="Archive",
special_use="archive",
)
)
await s.commit()
await _policy(ctx, "add_update", invitation_from="anyone", invitation_file=True)
mid = await _arrives(ctx, _email(_ics(), "REQUEST"))
async with ctx.sm() as s:
msg = await s.get(Message, uuid.UUID(mid))
assert msg is not None and "\\Seen" in msg.flags
box = await s.get(Mailbox, msg.mailbox_id)
assert box is not None and box.special_use == "archive"
assert [j[0] for j in jobs] == ["push_flags_job", "move_job"]
+1 -1
View File
@@ -1845,7 +1845,7 @@ function CalendarOptions({ calendars }: { calendars: CalendarOut[] }) {
type="radio"
name="invitation_policy"
className="accent-app-accent"
checked={(s?.invitation_policy ?? "add_update") === val}
checked={(s?.invitation_policy ?? "none") === val}
onChange={() => patch.mutate({ invitation_policy: val })}
/>
{txt}
@@ -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 = await _store_invited_event(
s, user_id, inv, ics, message_id, attendees, body.response, copy
)
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)
@@ -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");