Invitations received by email (iMIP): above the message body, a card with when, what, where (or "Join video call"), the organizer, what else is in the calendar then (a conflict, else the previous event that day), and Yes / No / Maybe. Yes and Maybe put the event in the calendar (or update it), No takes it out — and is still shown as the answer given; each sends the organizer the iTIP REPLY, then a "Reply sent" toast, and an open calendar refreshes. A cancellation offers "Remove from calendar". It is the calendar plugin's message.banner slot (ui/invite.js, imip.py, /invitations endpoints); the SDK gains the user's own accounts and settings. An Outlook-style invitation sent only as text/calendar is now kept as invite.ics too. Calendar: clicking an empty day in the side panel's agenda opens a new event on that date; a video-call link can be removed (✕ beside Copy); the page guests see after answering, and our invitations' footer, say Ultimo Calendar (with the logo). Attachments: videos (mp4, mov, webm, …) play in the full-screen preview, paused until played; attachments are now served from disk with HTTP Range, so a video starts at once and can be seeked. People tab: everyone in the thread, the user included, A–Z, under the name any message gives them. Initials circles share one colour rule in the list, the reading pane and the People tab. The contact view drops its duplicate "Message" button. A general corner toast (webmail:toast) joins Undo's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
257 lines
11 KiB
Python
257 lines
11 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 _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")]
|