Files
webmail-client/backend/tests/plugins/test_calendar_invitations.py
T

257 lines
11 KiB
Python
Raw Normal View History

"""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 _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_cancelled_event_is_removed(ctx: Ctx) -> None:
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"))
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.
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
assert not [e for e in await _calendar_events(ctx) if e["summary"] == "Maintenance Renewal"]
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")]