2026-10-06 21:50:29 +02:00
|
|
|
"""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()
|
|
|
|
|
|
|
|
|
|
|
2026-10-07 20:50:17 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-10-06 21:50:29 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-10-07 20:50:17 +02:00
|
|
|
async def test_organizers_cancellation_is_applied_on_arrival(ctx: Ctx) -> None:
|
|
|
|
|
from webmail_plugin_calendar.hook import on_message_new
|
|
|
|
|
|
2026-10-06 21:50:29 +02:00
|
|
|
invite = await ctx.message(_email(_ics(), "REQUEST"))
|
|
|
|
|
await ctx.client.post(f"{CAL}/invitations/{invite}/respond", json={"response": "accepted"})
|
2026-10-07 20:50:17 +02:00
|
|
|
assert [e for e in await _calendar_events(ctx) if e["summary"] == "Maintenance Renewal"]
|
2026-10-06 21:50:29 +02:00
|
|
|
|
2026-10-07 20:50:17 +02:00
|
|
|
# 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"]
|
2026-10-06 21:50:29 +02:00
|
|
|
card = (await ctx.client.get(f"{CAL}/invitations/{cancel}")).json()
|
2026-10-07 20:50:17 +02:00
|
|
|
assert card["method"] == "CANCEL" and card["cancellation"] == "removed"
|
|
|
|
|
# A cancellation cannot be answered.
|
2026-10-06 21:50:29 +02:00
|
|
|
no = await ctx.client.post(f"{CAL}/invitations/{cancel}/respond", json={"response": "accepted"})
|
|
|
|
|
assert no.status_code == 400
|
2026-10-07 20:50:17 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
2026-10-06 21:50:29 +02:00
|
|
|
assert not [e for e in await _calendar_events(ctx) if e["summary"] == "Maintenance Renewal"]
|
|
|
|
|
|
|
|
|
|
|
2026-10-07 20:50:17 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-10-06 21:50:29 +02:00
|
|
|
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")]
|
2026-10-07 20:50:17 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- 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"]
|