diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index f07f929..3af7fc8 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -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)), diff --git a/backend/app/plugins/actions.py b/backend/app/plugins/actions.py index 7468fd0..cf3e2ff 100644 --- a/backend/app/plugins/actions.py +++ b/backend/app/plugins/actions.py @@ -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), } diff --git a/backend/tests/plugins/test_calendar_invitations.py b/backend/tests/plugins/test_calendar_invitations.py index b98acb4..f57e35e 100644 --- a/backend/tests/plugins/test_calendar_invitations.py +++ b/backend/tests/plugins/test_calendar_invitations.py @@ -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"] diff --git a/frontend/src/routes/settings/Settings.tsx b/frontend/src/routes/settings/Settings.tsx index 581b5b9..ad7f6f1 100644 --- a/frontend/src/routes/settings/Settings.tsx +++ b/frontend/src/routes/settings/Settings.tsx @@ -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} diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/__init__.py b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/__init__.py index 369b29f..3d099ab 100644 --- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/__init__.py +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/__init__.py @@ -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, diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/hook.py b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/hook.py new file mode 100644 index 0000000..1a84960 --- /dev/null +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/hook.py @@ -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) diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/invites.py b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/invites.py index a5f8a77..3f0367f 100644 --- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/invites.py +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/invites.py @@ -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 ( + '' + '
' + '

Event canceled

' + '

' + f"{_esc(summary)}

" + '

When

' + f'

{_esc(when)}

' + f'

{_esc(who)} has canceled this event.

' + "
" + '

' + "You are receiving this message because you were a guest of this event. " + "Sent by Ultimo Calendar.

" + "" + ) + + +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 diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/router.py b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/router.py index 8116be9..0c1f2d3 100644 --- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/router.py +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/router.py @@ -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}") diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/invite.js b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/invite.js index f4f3d54..98e6934 100644 --- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/invite.js +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/invite.js @@ -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 - ? '
' - : '
Not in your calendar.
'; + // 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 = `
${esc(outcome)}
`; } else if (inv.method === "REQUEST" && inv.me) { actions = '
' + @@ -171,6 +177,7 @@ function render(el, inv, h12, note) {
In calendar${inv.in_calendar ? ` · ${esc(inv.in_calendar.calendar)}` : ""}
${calendarLine(inv, h12)} ${inv.outdated ? '
Your calendar has a newer version of this event.
' : ""} + ${inv.update_pending ? '
The organizer changed this event — answer to update your calendar.
' : ""}
` } ${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 */ } diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js index c7dd210..a47f67a 100644 --- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js @@ -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 `
+
+

Delete “${title}”

+

Would you like to inform the participants that this event is canceled?

+

${n === 1 ? "1 guest" : `${n} guests`} will get an email their calendar understands.

+
+ + + +
+
+
`; + } if (ev.recurring) { return `
@@ -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");