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
@@ -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"]