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:
@@ -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)},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
Reference in New Issue
Block a user