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:
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user