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
+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)."""