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>
478 lines
21 KiB
Python
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"]
|