diff --git a/backend/app/api/messages.py b/backend/app/api/messages.py
index 357340b..1cce5e0 100644
--- a/backend/app/api/messages.py
+++ b/backend/app/api/messages.py
@@ -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)},
)
diff --git a/backend/app/indexer/mime.py b/backend/app/indexer/mime.py
index 7bab481..67fbb3e 100644
--- a/backend/app/indexer/mime.py
+++ b/backend/app/indexer/mime.py
@@ -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
diff --git a/backend/app/plugins/actions.py b/backend/app/plugins/actions.py
index e937f5e..7468fd0 100644
--- a/backend/app/plugins/actions.py
+++ b/backend/app/plugins/actions.py
@@ -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)."""
diff --git a/backend/tests/api/test_api.py b/backend/tests/api/test_api.py
index f84588c..3bdfc6d 100644
--- a/backend/tests/api/test_api.py
+++ b/backend/tests/api/test_api.py
@@ -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")
diff --git a/backend/tests/plugins/test_calendar_invitations.py b/backend/tests/plugins/test_calendar_invitations.py
new file mode 100644
index 0000000..b98acb4
--- /dev/null
+++ b/backend/tests/plugins/test_calendar_invitations.py
@@ -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("
You have been invited.
", 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")]
diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx
new file mode 100644
index 0000000..4fe973c
--- /dev/null
+++ b/frontend/src/components/Toast.tsx
@@ -0,0 +1,44 @@
+import { useEffect, useState } from "react";
+
+// A short confirmation in the corner — the Undo-send toast's look, without the
+// button. Raised by anything (core or a plugin bundle) with
+// window.dispatchEvent(new CustomEvent("webmail:toast", { detail: { text } }))
+// e.g. "Reply sent" after answering an invitation. Gone after a few seconds.
+const SHOW_MS = 3500;
+
+export function Toast({ mobile }: { mobile: boolean }) {
+ const [toast, setToast] = useState<{ text: string; seq: number } | null>(null);
+
+ useEffect(() => {
+ let timer: number | undefined;
+ let seq = 0;
+ const onToast = (e: Event) => {
+ const text = (e as CustomEvent<{ text?: string }>).detail?.text;
+ if (!text) return;
+ setToast({ text, seq: ++seq });
+ window.clearTimeout(timer);
+ timer = window.setTimeout(() => setToast(null), SHOW_MS);
+ };
+ window.addEventListener("webmail:toast", onToast);
+ return () => {
+ window.removeEventListener("webmail:toast", onToast);
+ window.clearTimeout(timer);
+ };
+ }, []);
+
+ if (!toast) return null;
+ return (
+
+ {toast.text}
+
+ );
+}
diff --git a/frontend/src/components/list/MessageList.tsx b/frontend/src/components/list/MessageList.tsx
index a576586..ced0be6 100644
--- a/frontend/src/components/list/MessageList.tsx
+++ b/frontend/src/components/list/MessageList.tsx
@@ -5,6 +5,7 @@ import { useEffect, useRef } from "react";
import { useT } from "../../i18n";
import { api } from "../../lib/api";
import { useListDividers } from "../../lib/listDividers";
+import { avatarColor } from "../../lib/avatarColor";
import { useSavedThreads } from "../../lib/offline";
import { formatClock, getDateLocale } from "../../lib/timeFormat";
import type { Address, ThreadSummary } from "../../lib/types";
@@ -22,12 +23,6 @@ function timeLabel(iso: string): string {
}
// Stable per-sender hue so a sender's avatar keeps a consistent colour.
-function hue(key: string): number {
- let h = 0;
- for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) % 360;
- return h;
-}
-
function initials(p: Address | undefined): string {
const name = (p?.name || p?.email || "?").trim();
const words = name.split(/\s+/).filter(Boolean);
@@ -187,7 +182,7 @@ export function MessageList({
// The badge still counts everyone involved — the name is narrowed, the
// "how many people are in this" signal is not.
const extra = Math.max(0, thread.participants.length - 1);
- const avatarBg = `hsl(${hue(primary?.email || primary?.name || "?")} 45% 45%)`;
+ const avatarBg = avatarColor(primary?.email, primary?.name, accounts.data);
const stop = (fn: () => void) => (e: React.MouseEvent) => {
e.stopPropagation();
fn();
diff --git a/frontend/src/components/mail/RightPanel.tsx b/frontend/src/components/mail/RightPanel.tsx
index 82467b9..8388208 100644
--- a/frontend/src/components/mail/RightPanel.tsx
+++ b/frontend/src/components/mail/RightPanel.tsx
@@ -9,8 +9,9 @@ import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useT } from "../../i18n";
+import { avatarColor } from "../../lib/avatarColor";
import { api, apiFetch } from "../../lib/api";
-import type { Address, AttachmentOut, MessageEnvelope, ThreadDetail } from "../../lib/types";
+import type { AttachmentOut, MessageEnvelope, ThreadDetail } from "../../lib/types";
import { AvatarImage } from "../SenderAvatar";
import { useIsMobile } from "../../lib/useIsMobile";
import { loadBundle } from "../../plugins/bundleLoader";
@@ -27,22 +28,17 @@ const AttachmentPreview = lazy(() =>
export type PanelTab = "calendar" | "people" | "attachments";
// ---- small helpers ---------------------------------------------------------
-const AV_COLORS = ["#ef4444", "#f97316", "#eab308", "#22c55e", "#14b8a6", "#3b82f6", "#8b5cf6", "#ec4899", "#6b7280"];
-function avatarColor(key: string) {
- let h = 0;
- for (const c of key || "?") h = (h * 31 + c.charCodeAt(0)) >>> 0;
- return AV_COLORS[h % AV_COLORS.length];
-}
function initials(name: string) {
const p = (name || "").trim().split(/\s+/).filter(Boolean);
if (!p.length) return "?";
return ((p[0][0] || "") + (p.length > 1 ? p[p.length - 1][0] : "")).toUpperCase();
}
function Avatar({ name, email, size = 36 }: { name: string; email: string; size?: number }) {
+ const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
return (
{initials(name || email)}
@@ -184,14 +180,23 @@ function PeopleTab({ thread, onInviteAll }: { thread: ThreadDetail | undefined;
const [openEmail, setOpenEmail] = useState(null);
const [openName, setOpenName] = useState("");
- // "In conversation" = unique senders from the open thread.
+ // "In conversation" = everyone in the open thread — who wrote it, and who it
+ // went to (To, Cc, Bcc: e.g. whom a message was forwarded to), the user
+ // included. Each person once, under the name any message gives them, A–Z.
const convPeople = useMemo(() => {
- const seen = new Map();
+ const names = new Map();
for (const m of thread?.messages ?? []) {
- const a = m.from_addr[0];
- if (a?.email && !seen.has(a.email.toLowerCase())) seen.set(a.email.toLowerCase(), a);
+ for (const a of [...m.from_addr, ...m.to_addr, ...m.cc_addr, ...m.bcc_addr]) {
+ const key = a?.email?.toLowerCase();
+ if (!key) continue;
+ // Some messages carry the address itself as the name: that is no name.
+ const name = a.name && a.name.toLowerCase() !== key ? a.name : "";
+ if (!names.get(key)) names.set(key, name);
+ }
}
- return [...seen.values()].map((a) => ({ id: "", display_name: a.name || a.email, primary_email: a.email }));
+ return [...names.entries()]
+ .map(([email, name]) => ({ id: "", display_name: name || email, primary_email: email }))
+ .sort((a, b) => a.display_name.localeCompare(b.display_name, undefined, { sensitivity: "base" }));
}, [thread]);
const groups = useQuery({ queryKey: ["contact-groups"], queryFn: () => apiFetch("/plugins/contacts/groups"), enabled: source === "autosaved" });
@@ -213,6 +218,24 @@ function PeopleTab({ thread, onInviteAll }: { thread: ThreadDetail | undefined;
return setOpenEmail(null)} />;
}
+ const personRow = (p: { display_name: string; primary_email: string }) => (
+
+ );
+
const SOURCES: [PeopleSource, string][] = [
["conversation", t("panel.inConversation")],
["all", t("panel.allContacts")],
@@ -260,20 +283,10 @@ function PeopleTab({ thread, onInviteAll }: { thread: ThreadDetail | undefined;
-
diff --git a/frontend/src/components/thread/AttachmentPreview.tsx b/frontend/src/components/thread/AttachmentPreview.tsx
index 23d2743..775df1b 100644
--- a/frontend/src/components/thread/AttachmentPreview.tsx
+++ b/frontend/src/components/thread/AttachmentPreview.tsx
@@ -147,15 +147,17 @@ export function AttachmentPreview({
-
+ {kind !== "video" && (
+
+ )}
- {/* Bottom controls */}
-
+ {/* Bottom controls (a video has its own, in the player) */}
+
{kind === "pdf" && page.total > 0 && (
<>
@@ -270,6 +273,41 @@ function ZoomButton({ onClick, label }: { onClick: () => void; label: string })
);
}
+// A video, in the browser's own player. A format it cannot play (an HEVC .mov,
+// say) says so and offers the download instead of a dead black box.
+function VideoView({ url, onDownload }: { url: string; onDownload: () => void }) {
+ const t = useT();
+ const [failed, setFailed] = useState(false);
+ if (failed) {
+ return (
+
+ {t("mail.previewVideoUnsupported")}
+
+
+ );
+ }
+ return (
+
+ {/* Paused until the user plays it. The #t fragment makes browsers that
+ would otherwise show black (Safari) fetch and show the first frame. */}
+
diff --git a/frontend/src/components/thread/previewKind.ts b/frontend/src/components/thread/previewKind.ts
index 86b862d..0c2ea14 100644
--- a/frontend/src/components/thread/previewKind.ts
+++ b/frontend/src/components/thread/previewKind.ts
@@ -2,6 +2,8 @@
// clickability without pulling the heavy preview bundle (lazy-loaded on open).
const IMAGE_EXT = new Set(["jpg", "jpeg", "png", "gif", "webp"]);
+// Played in the preview's
);
}
diff --git a/plugins/webmail_plugin_calendar/pyproject.toml b/plugins/webmail_plugin_calendar/pyproject.toml
index 4b9f9d6..2a8018c 100644
--- a/plugins/webmail_plugin_calendar/pyproject.toml
+++ b/plugins/webmail_plugin_calendar/pyproject.toml
@@ -21,4 +21,4 @@ calendar = "webmail_plugin_calendar:plugin"
packages = ["webmail_plugin_calendar"]
[tool.setuptools.package-data]
-webmail_plugin_calendar = ["ui/*.js", "migrations/versions/*.py"]
+webmail_plugin_calendar = ["ui/*.js", "ui/*.svg", "migrations/versions/*.py"]
diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/__init__.py b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/__init__.py
index f051b8e..369b29f 100644
--- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/__init__.py
+++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/__init__.py
@@ -3,15 +3,15 @@
A **core, non-removable** plugin (``builtin=True``) that ships a `/calendar` page
beside Mail. Its own ``plg_calendar_*`` tables (RLS-isolated) hold calendars and
events; recurrence is expanded at query time. Uses the SDK only — zero core diffs.
-Phase A1 (local events) is the current scope; iMIP (A2) and CalDAV/ICS (A3) build
-on the same data model and the SDK's vault + multi-method HTTP.
+Local events (A1), iMIP invitations sent and received (A2) and CalDAV / ICS (A3)
+share one data model, the SDK's vault and its multi-method HTTP.
"""
from __future__ import annotations
from pathlib import Path
-from app.plugins.sdk import JobSpec, Page, PageNav, Plugin, UiManifest
+from app.plugins.sdk import JobSpec, Page, PageNav, Plugin, Slot, UiManifest
from webmail_plugin_calendar.router import router
from webmail_plugin_calendar.sync import caldav_sync
@@ -25,6 +25,9 @@ plugin = Plugin(
jobs=[JobSpec(func=caldav_sync, schedule="*/15 * * * *")],
router=router,
ui_manifest=UiManifest(
+ # An invitation received by email, above the message body (iMIP): renders
+ # only for a message carrying an .ics, so ordinary mail shows nothing.
+ slots=[Slot(name="message.banner", bundle="/api/plugins/calendar/ui/invite.js")],
pages=[
Page(
route="/calendar",
diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/imip.py b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/imip.py
new file mode 100644
index 0000000..2b3a020
--- /dev/null
+++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/imip.py
@@ -0,0 +1,188 @@
+"""Invitations received by email (iMIP, RFC 6047 / iTIP, RFC 5546).
+
+An invitation is an email carrying a ``text/calendar; method=REQUEST`` part (or
+``CANCEL`` when the organizer calls the event off). ``calendar_part`` finds it,
+``parse_invite`` turns its event into what the reading pane shows and the
+calendar stores, and ``build_reply`` makes the ``METHOD:REPLY`` the organizer's
+calendar (Google, Outlook, …) reads to record the answer.
+"""
+
+from __future__ import annotations
+
+import email
+import re
+from datetime import UTC, date, datetime, timedelta
+from email import policy
+from typing import Any
+from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
+
+# Google wraps its Meet details in this block; it means nothing elsewhere.
+_GOOGLE_BLOCK = re.compile(r"-::~[:~]*::-.*?-::~[:~]*::-", re.S)
+_PARTSTATS = {"accepted", "declined", "tentative", "needs-action"}
+
+
+def calendar_part(raw: bytes) -> str | None:
+ """The calendar data of an email: its inline ``text/calendar`` part (the one
+ carrying METHOD), else an attached ``.ics``. None for any other email."""
+ msg = email.message_from_bytes(raw, policy=policy.default)
+ attached: str | None = None
+ for part in msg.walk():
+ ctype = part.get_content_type()
+ name = (part.get_filename() or "").lower()
+ if ctype not in ("text/calendar", "application/ics") and not name.endswith(".ics"):
+ continue
+ payload = part.get_payload(decode=True)
+ if not isinstance(payload, bytes):
+ continue
+ text = payload.decode(part.get_content_charset() or "utf-8", "replace")
+ if ctype == "text/calendar" and not part.get_filename():
+ return text
+ attached = attached or text
+ return attached
+
+
+def _address(prop: Any) -> dict[str, str] | None:
+ if prop is None:
+ return None
+ addr = str(prop)
+ email_addr = addr[7:] if addr.lower().startswith("mailto:") else addr
+ name = str(prop.params.get("CN", "")).strip() if hasattr(prop, "params") else ""
+ email_addr = email_addr.strip().lower()
+ # Some senders put the address itself in CN: that is no name.
+ return {"email": email_addr, "name": "" if name.lower() == email_addr else name}
+
+
+def _zone(start_prop: Any, start: date | datetime) -> str:
+ """The event's IANA time zone (for recurrence and display), else UTC."""
+ candidates = [str(start_prop.params.get("TZID", ""))] if hasattr(start_prop, "params") else []
+ if isinstance(start, datetime) and start.tzinfo is not None:
+ zone = getattr(start.tzinfo, "key", "") or getattr(start.tzinfo, "zone", "")
+ candidates.append(str(zone))
+ for name in candidates:
+ if not name:
+ continue
+ try:
+ ZoneInfo(name)
+ return name
+ except (ZoneInfoNotFoundError, ValueError):
+ continue
+ return "UTC"
+
+
+def _moment(value: date | datetime, tz: str, all_day: bool) -> datetime:
+ if all_day or not isinstance(value, datetime):
+ d = value if not isinstance(value, datetime) else value.date()
+ return datetime(d.year, d.month, d.day, tzinfo=UTC)
+ if value.tzinfo is None: # floating time: read it in the event's zone
+ return value.replace(tzinfo=ZoneInfo(tz))
+ return value
+
+
+def parse_invite(ics: str) -> dict[str, Any] | None:
+ """The event an invitation is about, or None if there is none."""
+ from icalendar import Calendar
+
+ try:
+ cal = Calendar.from_ical(ics)
+ except Exception: # noqa: BLE001 - malformed calendar data: no invitation
+ return None
+ events = list(cal.walk("VEVENT"))
+ if not events:
+ return None
+ # A series with changed occurrences: the series itself (no RECURRENCE-ID).
+ comp = next((e for e in events if e.get("recurrence-id") is None), events[0])
+ start_prop = comp.get("dtstart")
+ if start_prop is None:
+ return None
+ start = start_prop.dt
+ all_day = not isinstance(start, datetime)
+ tz = _zone(start_prop, start)
+ dtstart = _moment(start, tz, all_day)
+ end_prop = comp.get("dtend")
+ if end_prop is not None:
+ dtend = _moment(end_prop.dt, tz, all_day)
+ elif comp.get("duration") is not None:
+ dtend = dtstart + comp.get("duration").dt
+ else:
+ dtend = dtstart + (timedelta(days=1) if all_day else timedelta(hours=1))
+ recurrence = comp.get("recurrence-id")
+ rrule = comp.get("rrule")
+ attendees = []
+ raw_attendees = comp.get("attendee") or []
+ for prop in raw_attendees if isinstance(raw_attendees, list) else [raw_attendees]:
+ who = _address(prop)
+ if who is None or not who["email"]:
+ continue
+ stat = str(prop.params.get("PARTSTAT", "NEEDS-ACTION")).lower()
+ who["partstat"] = stat if stat in _PARTSTATS else "needs-action"
+ attendees.append(who)
+ description = _GOOGLE_BLOCK.sub("", str(comp.get("description") or "")).strip()
+ location = str(comp.get("location") or "").strip()
+ conference = str(comp.get("x-google-conference") or "").strip()
+ if not conference and location.startswith(("https://meet.", "https://teams.", "https://zoom.")):
+ conference = location
+ return {
+ "method": str(cal.get("method") or "REQUEST").upper(),
+ "uid": str(comp.get("uid") or ""),
+ "sequence": int(comp.get("sequence") or 0),
+ "summary": str(comp.get("summary") or ""),
+ "description": description,
+ "location": location,
+ "conference_url": conference,
+ "dtstart": dtstart,
+ "dtend": dtend,
+ "all_day": all_day,
+ "tz": tz,
+ "rrule": rrule.to_ical().decode() if rrule is not None else None,
+ "recurrence_id": _moment(recurrence.dt, tz, all_day) if recurrence is not None else None,
+ "status": str(comp.get("status") or "confirmed").lower(),
+ "organizer": _address(comp.get("organizer")),
+ "attendees": attendees,
+ "show_as": "free" if str(comp.get("transp") or "").upper() == "TRANSPARENT" else "busy",
+ }
+
+
+_REPLY_WORD = {"accepted": "Accepted", "declined": "Declined", "tentative": "Tentatively accepted"}
+
+
+def reply_subject(partstat: str, summary: str) -> str:
+ return f"{_REPLY_WORD.get(partstat, 'Reply')}: {summary}"
+
+
+def build_reply(ics: str, me: dict[str, str], partstat: str) -> str:
+ """The ``METHOD:REPLY`` telling the organizer ``me`` answered ``partstat``:
+ the invitation's own event identity (UID, SEQUENCE, times, RECURRENCE-ID)
+ with just the replying attendee, as RFC 5546 asks."""
+ from icalendar import Calendar, Event, vCalAddress
+
+ source = Calendar.from_ical(ics)
+ events = list(source.walk("VEVENT"))
+ original = next((e for e in events if e.get("recurrence-id") is None), events[0])
+ cal = Calendar()
+ cal.add("prodid", "-//Ultimo//Calendar//EN")
+ cal.add("version", "2.0")
+ cal.add("method", "REPLY")
+ for tz in source.walk("VTIMEZONE"):
+ cal.add_component(tz)
+ ev = Event()
+ keep = (
+ "uid",
+ "sequence",
+ "dtstart",
+ "dtend",
+ "duration",
+ "recurrence-id",
+ "summary",
+ "organizer",
+ )
+ for key in keep:
+ if original.get(key) is not None:
+ ev[key] = original[key]
+ ev.add("dtstamp", datetime.now(UTC))
+ attendee = vCalAddress(f"mailto:{me['email']}")
+ attendee.params["PARTSTAT"] = partstat.upper()
+ if me.get("name"):
+ attendee.params["CN"] = me["name"]
+ ev.add("attendee", attendee)
+ cal.add_component(ev)
+ return cal.to_ical().decode()
diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/invites.py b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/invites.py
index 281471e..a5f8a77 100644
--- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/invites.py
+++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/invites.py
@@ -193,7 +193,7 @@ def build_html(
"
"
'
'
"You are receiving this message because you are a guest of this event. "
- "Sent by Webmail Calendar.