Event invitations in Mail, video previews, People tab, matching avatars

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>
This commit is contained in:
2026-10-06 21:50:29 +02:00
co-authored by Claude Opus 5.5
parent fba3c947bf
commit 2337375bb7
24 changed files with 1443 additions and 100 deletions
+31 -8
View File
@@ -11,7 +11,7 @@ from typing import Annotated
from urllib.parse import quote
from fastapi import APIRouter, Depends, Query
from fastapi.responses import HTMLResponse, Response
from fastapi.responses import FileResponse, HTMLResponse, Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -33,6 +33,26 @@ DbSession = Annotated[AsyncSession, Depends(get_session)]
# Content types safe to serve inline (SPEC §10.8); everything else is forced to
# an attachment download with a neutral type.
_INLINE_SAFE = {"image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"}
# Videos the browser can play, by extension — senders often label them
# application/octet-stream. A QuickTime .mov is usually H.264 in an MP4-style
# container: offered as video/mp4, browsers play it (an HEVC one stays unplayable,
# and the preview offers a download instead). Media cannot run script: safe inline.
_VIDEO_EXT_MIME = {
"mp4": "video/mp4",
"m4v": "video/mp4",
"mov": "video/mp4",
"webm": "video/webm",
"ogv": "video/ogg",
}
def _inline_type(mime_type: str, filename: str) -> str | None:
"""The type to serve an attachment as for in-app viewing, or None."""
if mime_type in _INLINE_SAFE:
return mime_type
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
return _VIDEO_EXT_MIME.get(ext)
# Base styles injected around the sanitized fragment so email content renders on a
# readable light canvas with images that fit the pane (the iframe is sandboxed).
@@ -309,12 +329,15 @@ async def message_attachment(
)
if att is None:
raise_api_error(404, "not_found", "attachment not found")
data = blobstore.load(msg.user_id, att.blob_sha256)
inline = att.mime_type in _INLINE_SAFE and not download
media_type = att.mime_type if inline else "application/octet-stream"
disposition = "inline" if inline else "attachment"
return Response(
content=data,
media_type=media_type,
path = blobstore.blob_path(msg.user_id, att.blob_sha256)
if not path.exists():
raise_api_error(404, "not_found", "attachment content missing")
inline_type = None if download else _inline_type(att.mime_type, att.filename)
disposition = "inline" if inline_type else "attachment"
# Streamed from disk, with HTTP Range: a video can be played and seeked
# without being sent whole first (Safari will not play one otherwise).
return FileResponse(
path,
media_type=inline_type or "application/octet-stream",
headers={"Content-Disposition": _content_disposition(disposition, att.filename)},
)
+18
View File
@@ -139,6 +139,24 @@ def _extract_attachments(msg: EmailMessage) -> list[ParsedAttachment]:
payload=payload,
)
)
# An invitation sent only as the text/calendar alternative (Outlook, often) has
# no file to show: keep it as invite.ics, so the event can be seen, answered
# and downloaded like a Gmail one. Not when the message attaches its own .ics.
if not any(a.filename.lower().endswith(".ics") for a in out):
for part in msg.walk():
if part.get_content_type() != "text/calendar" or part.get_filename():
continue
payload = part.get_payload(decode=True)
if isinstance(payload, bytes) and payload.strip():
out.append(
ParsedAttachment(
filename="invite.ics",
mime_type="text/calendar",
content_id=None,
payload=payload,
)
)
break
return out
+22 -8
View File
@@ -24,7 +24,7 @@ from sqlalchemy import select
from app.db import session_scope
from app.events import get_event_bus
from app.indexer import blobstore
from app.models import Account, Mailbox, Message, Thread
from app.models import Account, Mailbox, Message, Thread, User
class PluginMessages:
@@ -163,13 +163,10 @@ class PluginMessages:
class PluginAccounts:
"""Cross-user account queries, for scheduled jobs.
Deliberately not user-scoped: a sweep runs on behalf of the instance, not a
request, and needs to find work across every user. Nothing here returns
content — only ids and state — so a plugin still has to go through the
user-scoped accessors to read anything.
"""
"""Account queries: ``live()`` is cross-user, for scheduled jobs (a sweep runs
on behalf of the instance and returns only ids and state); ``of_user`` and
``settings_of`` read one user's own accounts and settings, for a handler
already acting as that user."""
async def live(self) -> list[tuple[uuid.UUID, uuid.UUID]]:
"""``(user_id, account_id)`` for every account finished with its initial
@@ -181,6 +178,23 @@ class PluginAccounts:
)
return [(u, a) for u, a in rows]
async def of_user(self, user_id: uuid.UUID) -> list[tuple[uuid.UUID, str, str]]:
"""``(account_id, email, display_name)`` of the user's own mail accounts —
e.g. to tell which attendee of an emailed invitation is the user."""
async with session_scope() as session:
rows = await session.execute(
select(Account.id, Account.email, Account.display_name)
.where(Account.user_id == user_id)
.order_by(Account.created_at)
)
return [(a, str(e), str(n or "")) for a, e, n in rows]
async def settings_of(self, user_id: uuid.UUID) -> dict[str, object]:
"""The user's settings (time zone, default calendar, …), read-only."""
async with session_scope() as session:
settings = await session.scalar(select(User.settings).where(User.id == user_id))
return dict(settings or {})
class PluginThreads:
"""Read-only, user-scoped thread queries (e.g. self-healing stale references)."""
+37
View File
@@ -605,3 +605,40 @@ async def test_attachment_thumbnails(api: ApiCtx) -> None:
assert (await api.client.get(url("notes.txt"))).status_code == 404
assert (await api.client.get(url("broken.jpg"))).status_code == 404
async def test_video_attachment_streams_with_range(api: ApiCtx) -> None:
# A video plays in the preview: served inline as video (whatever the sender
# called it) and in pieces on request (HTTP Range), so it can be seeked.
from app.indexer import blobstore
from app.models import Attachment
assert api.session is not None
mid = api.message_ids[-1]
data = bytes(range(256)) * 40 # 10 240 bytes standing in for a video
att = Attachment(
message_id=mid,
user_id=api.user_id,
filename="clip.MOV",
mime_type="application/octet-stream",
size_bytes=len(data),
content_id=None,
blob_sha256=blobstore.store(api.user_id, data),
)
api.session.add(att)
await api.session.commit()
url = f"/api/messages/{mid}/attachments/{att.id}"
whole = await api.client.get(url)
assert whole.status_code == 200 and whole.content == data
assert whole.headers["content-type"] == "video/mp4"
assert whole.headers["content-disposition"].startswith("inline")
assert whole.headers["accept-ranges"] == "bytes"
part = await api.client.get(url, headers={"Range": "bytes=1000-1999"})
assert part.status_code == 206 and part.content == data[1000:2000]
assert part.headers["content-range"] == f"bytes 1000-1999/{len(data)}"
saved = await api.client.get(url + "?download=1")
assert saved.headers["content-type"] == "application/octet-stream"
assert saved.headers["content-disposition"].startswith("attachment")
@@ -0,0 +1,256 @@
"""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")]