Files
webmail-client/backend/tests/plugins/test_calendar_invitations.py
lukasandClaude Opus 5.5 1982e8964b 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>
2026-10-07 20:50:17 +02:00

478 lines
21 KiB
Python

"""Calendar plugin: invitations received by email (iMIP) — the reading-pane card
and Yes / No / Maybe, through the real HTTP surface.
A Gmail-style invitation (text/calendar METHOD:REQUEST + an invite.ics file) is
stored as a real message; answering must put the event in the calendar (or take
it out) and send the organizer an iTIP REPLY. Outgoing mail is captured, not sent.
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
from datetime import UTC, datetime
from email.message import EmailMessage
import httpx
import pytest_asyncio
from httpx import ASGITransport
from sqlalchemy import delete, text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import get_settings
from app.identity.passwords import hash_password
from app.identity.sessions import create_session
from app.indexer.indexer import persist_message
from app.indexer.mime import parse_message
from app.main import create_app
from app.models import Account, Mailbox, User
from app.plugins import mail as plugin_mail
from app.providers.base import RemoteMessage
CAL = "/api/plugins/calendar"
ME = "me@example.com"
ORGANIZER = "boss@example.org"
def _ics(method: str = "REQUEST", attendees: tuple[str, ...] = (ME,), seq: int = 0) -> str:
people = "".join(
f"ATTENDEE;CN=Guest;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:{a}\r\n" for a in attendees
)
return (
"BEGIN:VCALENDAR\r\nPRODID:-//Google Inc//Google Calendar 70.9054//EN\r\n"
f"VERSION:2.0\r\nMETHOD:{method}\r\n"
"BEGIN:VEVENT\r\n"
"DTSTART;TZID=Europe/Warsaw:20261007T133000\r\n"
"DTEND;TZID=Europe/Warsaw:20261007T143000\r\n"
"DTSTAMP:20261006T183413Z\r\n"
f"ORGANIZER;CN=The Boss:mailto:{ORGANIZER}\r\n"
"UID:test-invite-1@google.com\r\n"
f"{people}"
"X-GOOGLE-CONFERENCE:https://meet.google.com/abc-defg-hij\r\n"
"LOCATION:Gliwice\r\n"
f"SEQUENCE:{seq}\r\nSTATUS:{'CANCELLED' if method == 'CANCEL' else 'CONFIRMED'}\r\n"
"SUMMARY:Maintenance Renewal\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"
)
def _email(ics: str, method: str) -> bytes:
m = EmailMessage()
m["Subject"] = "Invitation: Maintenance Renewal"
m["From"] = f"The Boss <{ORGANIZER}>"
m["To"] = ME
m["Message-ID"] = f"<{uuid.uuid4()}@example.org>"
m.set_content("You have been invited.")
m.add_alternative("<p>You have been invited.</p>", subtype="html")
m.add_alternative(ics, subtype="calendar", params={"method": method})
m.make_mixed()
m.add_attachment(ics.encode(), maintype="application", subtype="ics", filename="invite.ics")
return m.as_bytes()
@dataclass
class Ctx:
user_id: uuid.UUID
client: httpx.AsyncClient
sm: async_sessionmaker # type: ignore[type-arg]
account_id: uuid.UUID
mailbox_id: uuid.UUID
sent: list[dict[str, object]] = field(default_factory=list)
uid: int = 0
async def message(self, raw: bytes) -> str:
self.uid += 1
async with self.sm() as s:
result = await persist_message(
s,
user_id=self.user_id,
account_id=self.account_id,
mailbox_id=self.mailbox_id,
mailbox_special_use="inbox",
remote=RemoteMessage(
uid=self.uid, raw=raw, flags=frozenset(), internal_date=datetime.now(UTC)
),
)
await s.commit()
assert result is not None
return str(result.message.id)
@pytest_asyncio.fixture
async def ctx(monkeypatch) -> AsyncIterator[Ctx]: # type: ignore[no-untyped-def]
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
async with sm() as s:
user = User(
handle=f"inv{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw")
)
s.add(user)
await s.flush()
account = Account(
user_id=user.id,
email=ME,
display_name="Me Myself",
imap_config={"host": "imap.example.com", "port": 993, "tls": "ssl"},
smtp_config={"host": "smtp.example.com", "port": 465, "tls": "ssl"},
secret_ct=b"",
discovery_src="manual",
state="live",
)
s.add(account)
await s.flush()
inbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
s.add(inbox)
await s.commit()
ids = (user.id, account.id, inbox.id)
token = await create_session(ids[0], "pytest")
client = httpx.AsyncClient(
transport=ASGITransport(app=create_app()),
base_url="http://testserver",
cookies={"session": token, "csrf": "csrf"},
headers={"X-CSRF": "csrf"},
)
c = Ctx(user_id=ids[0], client=client, sm=sm, account_id=ids[1], mailbox_id=ids[2])
async def fake_send(self, user_id, **kw): # type: ignore[no-untyped-def]
c.sent.append(kw)
monkeypatch.setattr(plugin_mail.PluginMail, "send", fake_send)
try:
yield c
finally:
await client.aclose()
async with sm() as s:
await s.execute(
text("DELETE FROM plg_calendar_calendars WHERE user_id = :u"), {"u": str(ids[0])}
)
await s.execute(delete(User).where(User.id == ids[0]))
await s.commit()
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"}
)
return r.json()["events"]
async def test_invitation_card_and_answers(ctx: Ctx) -> None:
mid = await ctx.message(_email(_ics(), "REQUEST"))
# Something already in the calendar at that time: a conflict.
cal_id = (await ctx.client.get(f"{CAL}/calendars")).json()[0]["id"]
await ctx.client.post(
f"{CAL}/events",
json={
"calendar_id": cal_id,
"summary": "Design review",
"dtstart": "2026-10-07T11:00:00Z", # 13:00 in Warsaw
"dtend": "2026-10-07T12:00:00Z",
},
)
card = (await ctx.client.get(f"{CAL}/invitations/{mid}")).json()
assert card["method"] == "REQUEST" and card["summary"] == "Maintenance Renewal"
assert card["start"] == "2026-10-07T13:30:00+02:00" and card["tz"] == "Europe/Warsaw"
assert card["organizer"] == {"email": ORGANIZER, "name": "The Boss"}
assert card["location"] == "Gliwice"
assert card["conference_url"] == "https://meet.google.com/abc-defg-hij"
assert card["me"]["email"] == ME and card["response"] == "needs-action"
assert card["in_calendar"] is None
assert [c["summary"] for c in card["conflicts"]] == ["Design review"]
# Yes: in the calendar, and the organizer gets an iTIP REPLY.
r = await ctx.client.post(f"{CAL}/invitations/{mid}/respond", json={"response": "accepted"})
assert r.status_code == 200
card = r.json()
assert card["response"] == "accepted" and card["in_calendar"]["calendar"] == "Personal"
assert card["reply_sent"] is True
reply = ctx.sent[-1]
assert reply["to"] == [ORGANIZER] and reply["from_email"] == ME
assert reply["ics_method"] == "REPLY" and reply["subject"] == "Accepted: Maintenance Renewal"
ics = str(reply["ics"])
assert "METHOD:REPLY" in ics and "UID:test-invite-1@google.com" in ics
assert "PARTSTAT=ACCEPTED" in ics and f"mailto:{ME}" in ics
events = [e for e in await _calendar_events(ctx) if e["summary"] == "Maintenance Renewal"]
assert len(events) == 1 and events[0]["my_partstat"] == "accepted"
# Maybe: the same event, answer updated.
r = await ctx.client.post(f"{CAL}/invitations/{mid}/respond", json={"response": "tentative"})
assert r.json()["response"] == "tentative" and "PARTSTAT=TENTATIVE" in str(ctx.sent[-1]["ics"])
events = [e for e in await _calendar_events(ctx) if e["summary"] == "Maintenance Renewal"]
assert len(events) == 1 and events[0]["my_partstat"] == "tentative"
# No: out of the calendar — and still shown as the answer given.
r = await ctx.client.post(f"{CAL}/invitations/{mid}/respond", json={"response": "declined"})
assert r.json()["in_calendar"] is None and "PARTSTAT=DECLINED" in str(ctx.sent[-1]["ics"])
assert r.json()["response"] == "declined"
assert (await ctx.client.get(f"{CAL}/invitations/{mid}")).json()["response"] == "declined"
assert not [e for e in await _calendar_events(ctx) if e["summary"] == "Maintenance Renewal"]
# Changing one's mind back to Yes: in the calendar again, "No" forgotten.
r = await ctx.client.post(f"{CAL}/invitations/{mid}/respond", json={"response": "accepted"})
assert r.json()["response"] == "accepted" and r.json()["in_calendar"] is not 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"})
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["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
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()
assert card["me"] is None # forwarded to the user, say: nothing to answer
r = await ctx.client.post(f"{CAL}/invitations/{other}/respond", json={"response": "accepted"})
assert r.status_code == 400
plain = EmailMessage()
plain["Subject"], plain["From"], plain["To"] = "Hello", ORGANIZER, ME
plain.set_content("Just a note.")
mid = await ctx.message(plain.as_bytes())
assert (await ctx.client.get(f"{CAL}/invitations/{mid}")).status_code == 404
def test_outlook_style_invitation_is_kept_as_a_file() -> None:
# Outlook often sends the calendar only as the text/calendar alternative:
# the indexer keeps it as invite.ics, so the card has something to find.
m = EmailMessage()
m["Subject"], m["From"], m["To"] = "Meeting", ORGANIZER, ME
m.set_content("plain")
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"]