From 2337375bb706dc27a1099a06ac726571c239def6 Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Tue, 6 Oct 2026 21:50:29 +0200 Subject: [PATCH] Event invitations in Mail, video previews, People tab, matching avatars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/api/messages.py | 39 +- backend/app/indexer/mime.py | 18 + backend/app/plugins/actions.py | 30 +- backend/tests/api/test_api.py | 37 ++ .../plugins/test_calendar_invitations.py | 256 ++++++++++++ frontend/src/components/Toast.tsx | 44 ++ frontend/src/components/list/MessageList.tsx | 9 +- frontend/src/components/mail/RightPanel.tsx | 62 +-- .../components/thread/AttachmentPreview.tsx | 62 ++- .../src/components/thread/Attachments.tsx | 19 +- frontend/src/components/thread/ThreadView.tsx | 11 +- frontend/src/components/thread/previewKind.ts | 7 +- frontend/src/i18n/en.ts | 2 +- frontend/src/lib/avatarColor.ts | 23 ++ frontend/src/routes/MailLayout.tsx | 2 + .../webmail_plugin_calendar/pyproject.toml | 2 +- .../webmail_plugin_calendar/__init__.py | 9 +- .../webmail_plugin_calendar/imip.py | 188 +++++++++ .../webmail_plugin_calendar/invites.py | 2 +- .../webmail_plugin_calendar/router.py | 391 ++++++++++++++++-- .../webmail_plugin_calendar/schemas.py | 6 + .../webmail_plugin_calendar/ui/invite.js | 244 +++++++++++ .../webmail_plugin_calendar/ui/page.js | 46 ++- .../ui/ultimo-logo-signet.svg | 34 ++ 24 files changed, 1443 insertions(+), 100 deletions(-) create mode 100644 backend/tests/plugins/test_calendar_invitations.py create mode 100644 frontend/src/components/Toast.tsx create mode 100644 frontend/src/lib/avatarColor.ts create mode 100644 plugins/webmail_plugin_calendar/webmail_plugin_calendar/imip.py create mode 100644 plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/invite.js create mode 100644 plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/ultimo-logo-signet.svg 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; -
{t("panel.senders")}
{people.length === 0 ? (
{t("panel.noPeople")}
) : ( - people.map((p, i) => ( - - )) + people.map((p) => personRow(p)) )} ); @@ -316,7 +329,6 @@ function PersonDetail({ email, name, onBack }: { email: string; name: string; on
c?.phones?.[0] && window.open(`tel:${c.phones[0].value}`)} icon={} /> - navigate(`/mail?compose=1&to=${encodeURIComponent(primary)}`)} icon={} /> navigate(`/mail?compose=1&to=${encodeURIComponent(primary)}`)} icon={} /> window.open(`/p/calendar?guest=${encodeURIComponent(primary)}`, "_blank", "noopener")} icon={} />
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. */} +
+ ); +} + function ImageView({ url, zoom, alt }: { url: string; zoom: number; alt: string }) { return (
diff --git a/frontend/src/components/thread/Attachments.tsx b/frontend/src/components/thread/Attachments.tsx index 877dd2f..64df152 100644 --- a/frontend/src/components/thread/Attachments.tsx +++ b/frontend/src/components/thread/Attachments.tsx @@ -6,7 +6,7 @@ import spreadsheetSvgRaw from "../../../icons/spreadsheet.svg?raw"; import { useT } from "../../i18n"; import { api } from "../../lib/api"; import type { AttachmentOut } from "../../lib/types"; -import { extOf, isPreviewable } from "./previewKind"; +import { extOf, isPreviewable, VIDEO_EXT } from "./previewKind"; // These icons carry their own (baked-in) colours via inline styles, so render // them raw rather than through , which strips styles. Drop the XML prolog. @@ -75,8 +75,25 @@ const ARCHIVE_EXT = new Set(["zip", "rar", "7z", "tar", "gz", "bz2", "xz"]); const SPREADSHEET_EXT = new Set(["xlsx", "ods"]); const DOC_EXT = new Set(["txt", "docx", "odt"]); // blue document icon +// Video: a film frame with a play triangle. +function VideoGlyph() { + return ( + + + + + ); +} + function AttachmentIcon({ filename }: { filename: string }) { const e = extOf(filename); + if (VIDEO_EXT.has(e)) { + return ( + + + + ); + } if (e === "pdf") { return ( diff --git a/frontend/src/components/thread/ThreadView.tsx b/frontend/src/components/thread/ThreadView.tsx index 662b4e2..9138cce 100644 --- a/frontend/src/components/thread/ThreadView.tsx +++ b/frontend/src/components/thread/ThreadView.tsx @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import { useT } from "../../i18n"; +import { personHue } from "../../lib/avatarColor"; import { api } from "../../lib/api"; import { printMessages } from "../../lib/print"; import { useIsMobile } from "../../lib/useIsMobile"; @@ -98,10 +99,7 @@ function initials(msg: MessageEnvelope): string { // Stable per-sender hue so a sender's avatar + name share a colour (like the mockup). function hue(msg: MessageEnvelope): number { - const key = (msg.from_addr[0]?.email || msg.from_addr[0]?.name || "?").toLowerCase(); - let h = 0; - for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) % 360; - return h; + return personHue(msg.from_addr[0]?.email || msg.from_addr[0]?.name || "?"); } function fmtRelative(iso: string): string { @@ -493,7 +491,10 @@ function MessageItem({
{/* Plugin-contributed per-message banner (SPEC §11.3 message.banner). */} - +
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.

" + "Sent by Ultimo Calendar.

" "" ) diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/router.py b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/router.py index 121988e..8116be9 100644 --- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/router.py +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/router.py @@ -10,10 +10,14 @@ from __future__ import annotations import json import logging +import re import secrets import uuid from datetime import UTC, datetime, timedelta +from html import escape as html_escape from pathlib import Path +from typing import Any +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import FileResponse, HTMLResponse, Response @@ -22,7 +26,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.plugins.sdk import CurrentUserId, require_csrf, sdk -from webmail_plugin_calendar import invites +from webmail_plugin_calendar import imip, invites from webmail_plugin_calendar.queries import expand_range from webmail_plugin_calendar.schemas import ( CalendarIn, @@ -31,6 +35,7 @@ from webmail_plugin_calendar.schemas import ( EditFollowingIn, EventIn, EventPatch, + InvitationAnswer, SubscribeIn, ) @@ -473,7 +478,15 @@ async def list_events( """List events overlapping [start, end). With ``q`` set, only events whose summary/location/description match (case-insensitive) are returned — used by the calendar search panel over a wide date window.""" - range_start, range_end = _aware(start), _aware(end) + async with _SDK.session(user_id) as s: + return {"events": await _events_between(s, _aware(start), _aware(end), q)} + + +async def _events_between( + s: AsyncSession, range_start: datetime, range_end: datetime, q: str = "" +) -> list[dict[str, object]]: + """Every event occurrence overlapping [range_start, range_end), recurrence + expanded (the calendar views, search, and an invitation's conflicts).""" params: dict[str, object] = {"end": range_end} where = "WHERE e.dtstart < :end AND e.dav_deleted = false" term = q.strip() @@ -483,23 +496,22 @@ async def list_events( "OR COALESCE(e.description, '') ILIKE :q)" ) params["q"] = f"%{term}%" - async with _SDK.session(user_id) as s: - rows = ( - await s.execute( - text( - "SELECT e.id, e.calendar_id, e.ical_uid, e.summary, e.description, " - "e.location, e.dtstart, e.dtend, e.all_day, e.tz, e.rrule, e.reminders, " - "e.exdates, e.recurrence_id, e.status, " - "e.my_partstat, e.origin_message_id, e.show_as, e.conference_url, " - "e.attendees, e.organizer, " - "COALESCE(e.color, c.color) AS color " - "FROM plg_calendar_events e " - "JOIN plg_calendar_calendars c ON c.id = e.calendar_id " - f"{where}" - ), - params, - ) - ).mappings().all() + rows = ( + await s.execute( + text( + "SELECT e.id, e.calendar_id, e.ical_uid, e.summary, e.description, " + "e.location, e.dtstart, e.dtend, e.all_day, e.tz, e.rrule, e.reminders, " + "e.exdates, e.recurrence_id, e.status, " + "e.my_partstat, e.origin_message_id, e.show_as, e.conference_url, " + "e.attendees, e.organizer, " + "COALESCE(e.color, c.color) AS color " + "FROM plg_calendar_events e " + "JOIN plg_calendar_calendars c ON c.id = e.calendar_id " + f"{where}" + ), + params, + ) + ).mappings().all() base = [ r for r in rows @@ -507,7 +519,7 @@ async def list_events( and (r["rrule"] is not None or (r["dtend"] or r["dtstart"]) > range_start) ] overrides = [r for r in rows if r["recurrence_id"] is not None] - return {"events": expand_range(base, overrides, range_start, range_end)} + return expand_range(base, overrides, range_start, range_end) async def _insert_event( @@ -882,15 +894,32 @@ async def public_calendar(token: str) -> Response: # --- RSVP (public: guests respond from the invitation email, no login) -------- +def _logo_svg() -> str: + """The Ultimo signet, inline (a guest's browser loads nothing else), 56 px wide.""" + try: + svg = (_UI / "ultimo-logo-signet.svg").read_text(encoding="utf-8") + except OSError: # never let a missing picture stop the calendar from loading + return "" + svg = re.sub(r"<\?xml[^>]*\?>|", "", svg, flags=re.S).strip() + # Inkscape sizes it in mm: let the box decide (the viewBox keeps its shape). + return re.sub(r"]*?)\swidth=\"[^\"]*\"([^>]*?)\sheight=\"[^\"]*\"", + r' str: + """What a guest sees after clicking Yes / No / Maybe in our invitation.""" return ( "" "" - "Invitation response" + "Invitation response · Ultimo Calendar" "
" - "

Webmail Calendar

" + f"
{_LOGO}
" + "

Ultimo Calendar

" f"

{message}

" ) @@ -929,11 +958,327 @@ async def rsvp(t: str = Query(...), r: str = Query(...)) -> HTMLResponse: return HTMLResponse(_rsvp_page(f"Your response “{invites.RESPONSES[r]}” was recorded.")) +# --- invitations received by email (iMIP) ----------------------------------- +# +# The reading pane shows an invitation email as a card (core MessageInvitation): +# when, what, where, who organizes, what else is in the calendar then, and +# Yes / No / Maybe. Answering adds (or updates, or removes) the event in the +# user's calendar and sends the organizer an iTIP REPLY, so their calendar +# records the answer too. + +_ANSWERS = ("accepted", "declined", "tentative") + + +def _declined_key(inv: dict[str, Any]) -> str: + """Where a "No" is remembered: declining takes the event out of the calendar, + so the calendar no longer holds the answer the card should show.""" + rid = inv["recurrence_id"].isoformat() if inv["recurrence_id"] else "" + return f"declined:{inv['uid']}:{rid}" + + +async def _invitation(user_id: uuid.UUID, message_id: uuid.UUID) -> tuple[str, dict[str, Any]]: + raw = await _SDK.messages.raw(user_id, message_id) + ics = imip.calendar_part(raw) if raw else None + inv = imip.parse_invite(ics) if ics else None + if ics is None or inv is None or not inv["uid"]: + raise HTTPException(status_code=404, detail="no_invitation") + return ics, inv + + +async def _me(user_id: uuid.UUID, inv: dict[str, Any]) -> dict[str, str] | None: + """The user among the invitation's attendees (any of their accounts).""" + mine = {e.lower(): n for _a, e, n in await _SDK.accounts.of_user(user_id)} + for a in inv["attendees"]: + if a["email"] in mine: + name = a.get("name") or mine[a["email"]] + return {"email": a["email"], "name": name, "partstat": a["partstat"]} + return None + + +async def _invited_copy(s: AsyncSession, inv: dict[str, Any]) -> dict[str, Any] | None: + """The event this invitation already put in the user's calendar, if any.""" + row = ( + await s.execute( + text( + "SELECT e.id, e.calendar_id, e.sequence, e.my_partstat, c.kind, c.name " + "FROM plg_calendar_events e JOIN plg_calendar_calendars c ON c.id = e.calendar_id " + "WHERE e.ical_uid = :uid AND e.recurrence_id IS NOT DISTINCT FROM :rid " + "AND e.dav_deleted = false ORDER BY e.sequence DESC LIMIT 1" + ), + {"uid": inv["uid"], "rid": inv["recurrence_id"]}, + ) + ).mappings().first() + return dict(row) if row else None + + +async def _invitation_calendar(s: AsyncSession, user_id: uuid.UUID) -> uuid.UUID: + """Where accepted invitations go: Settings → Calendars → Invitations, else + the default calendar, else the first one that can be written to.""" + settings = await _SDK.accounts.settings_of(user_id) + await _ensure_default_calendar(s, user_id) + rows = ( + await s.execute( + text("SELECT id FROM plg_calendar_calendars WHERE read_only = false ORDER BY name") + ) + ).scalars().all() + writable = [str(r) for r in rows] + for wanted in (settings.get("invitation_calendar"), settings.get("default_calendar")): + if wanted and str(wanted) in writable: + return uuid.UUID(str(wanted)) + return uuid.UUID(writable[0]) + + +def _brief(ev: dict[str, Any]) -> dict[str, Any]: + return {k: ev.get(k) for k in ("summary", "start", "end", "all_day", "color")} + + +async def _invitation_view(user_id: uuid.UUID, message_id: uuid.UUID) -> dict[str, Any]: + _ics, inv = await _invitation(user_id, message_id) + me = await _me(user_id, inv) + start, end = inv["dtstart"], inv["dtend"] + settings = await _SDK.accounts.settings_of(user_id) + try: + zone = ZoneInfo(str(settings.get("tz") or inv["tz"])) + except (ZoneInfoNotFoundError, ValueError): + zone = ZoneInfo("UTC") + day_start = start.astimezone(zone).replace(hour=0, minute=0, second=0, microsecond=0) + async with _SDK.session(user_id) as s: + copy = await _invited_copy(s, inv) + around = await _events_between(s, day_start, max(end, day_start + timedelta(days=1))) + others = [ + e + for e in around + if e.get("ical_uid") != inv["uid"] + and e.get("status") != "cancelled" + and e.get("my_partstat") != "declined" + ] + + def times(e: dict[str, Any]) -> tuple[datetime, datetime]: + return datetime.fromisoformat(str(e["start"])), datetime.fromisoformat(str(e["end"])) + + # What the user is already busy with then (timed events, unless this one is + # all-day), and else the event just before it that day. + conflicts = [ + _brief(e) + for e in others + if e.get("show_as") != "free" + and (inv["all_day"] or not e.get("all_day")) + and times(e)[0] < end + and times(e)[1] > start + ] + earlier = sorted( + (e for e in others if not e.get("all_day") and times(e)[1] <= start), + key=lambda e: times(e)[1], + ) + declined = await _SDK.kv(user_id).get(_declined_key(inv)) + response = ( + (copy or {}).get("my_partstat") + or ("declined" if declined else None) + or (me or {}).get("partstat") + or "needs-action" + ) + return { + "method": inv["method"], + "uid": inv["uid"], + "summary": inv["summary"], + "description": inv["description"], + "location": inv["location"], + "conference_url": inv["conference_url"], + "start": start.isoformat(), + "end": end.isoformat(), + "all_day": inv["all_day"], + "tz": inv["tz"], + "recurring": inv["rrule"] is not None, + "status": inv["status"], + "organizer": inv["organizer"], + "attendees": inv["attendees"], + "me": {"email": me["email"], "name": me["name"]} if me else None, + "response": response, + "in_calendar": ( + {"event_id": str(copy["id"]), "calendar": copy["name"]} if copy else None + ), + # The calendar holds a newer version (the organizer changed it since). + "outdated": bool(copy and int(copy["sequence"]) > inv["sequence"]), + "conflicts": conflicts, + "previous": _brief(earlier[-1]) if earlier and not conflicts else None, + } + + +@router.get("/invitations/{message_id}") +async def get_invitation(message_id: uuid.UUID, user_id: CurrentUserId) -> dict[str, Any]: + """The invitation in an email, as the reading pane shows it; 404 if none.""" + return await _invitation_view(user_id, message_id) + + +def _default_reminders(settings: dict[str, Any]) -> list[int]: + out = [] + for r in settings.get("default_reminders_timed") or []: + minutes = r.get("minutes") if isinstance(r, dict) else r + if isinstance(minutes, int): + out.append(minutes) + return out + + +@router.post("/invitations/{message_id}/respond", dependencies=[Depends(require_csrf)]) +async def respond_to_invitation( + message_id: uuid.UUID, body: InvitationAnswer, user_id: CurrentUserId +) -> dict[str, Any]: + """Answer an invitation: Yes / Maybe put it in the calendar (or update it), + No takes it out; then the organizer gets the iTIP REPLY.""" + if body.response not in _ANSWERS: + raise HTTPException(status_code=400, detail="bad_response") + ics, inv = await _invitation(user_id, message_id) + if inv["method"] != "REQUEST": + raise HTTPException(status_code=400, detail="not_a_request") + me = await _me(user_id, inv) + if me is None: + raise HTTPException(status_code=400, detail="not_invited") + attendees = [ + {**a, "partstat": body.response} if a["email"] == me["email"] else a + for a in inv["attendees"] + ] + dav_calendar: str | None = None + async with _SDK.session(user_id) as s: + copy = await _invited_copy(s, inv) + if body.response == "declined": + if copy is not None: + await s.execute( + text( + "UPDATE plg_calendar_events SET dav_deleted = true WHERE id = :id" + if copy["kind"] == "caldav" + else "DELETE FROM plg_calendar_events WHERE id = :id" + ), + {"id": str(copy["id"])}, + ) + dav_calendar = str(copy["calendar_id"]) if copy["kind"] == "caldav" else None + elif copy is not None and int(copy["sequence"]) > inv["sequence"]: + # An older copy of the invitation: keep the newer event, just answer. + await s.execute( + text("UPDATE plg_calendar_events SET my_partstat = :p WHERE id = :id"), + {"p": body.response, "id": str(copy["id"])}, + ) + dav_calendar = str(copy["calendar_id"]) if copy["kind"] == "caldav" else None + else: + fields = { + "sum": inv["summary"], + "desc": inv["description"], + "loc": inv["location"], + "start": inv["dtstart"], + "end": inv["dtend"], + "allday": inv["all_day"], + "tz": inv["tz"], + "rrule": inv["rrule"], + "rid": inv["recurrence_id"], + "status": "cancelled" if inv["status"] == "cancelled" else "confirmed", + "org": json.dumps(inv["organizer"]) if inv["organizer"] else None, + "att": json.dumps(attendees), + "mine": body.response, + "seq": inv["sequence"], + "raw": ics, + "msg": str(message_id), + "conf": inv["conference_url"], + "showas": inv["show_as"], + } + if copy is not None: + await s.execute( + text( + "UPDATE plg_calendar_events SET summary = :sum, description = :desc, " + "location = :loc, dtstart = :start, dtend = :end, all_day = :allday, " + "tz = :tz, rrule = :rrule, status = :status, " + "organizer = CAST(:org AS jsonb), attendees = CAST(:att AS jsonb), " + "my_partstat = :mine, sequence = :seq, raw_ical = :raw, " + "origin_message_id = :msg, conference_url = :conf, show_as = :showas " + "WHERE id = :id" + ), + {**fields, "id": str(copy["id"])}, + ) + dav_calendar = str(copy["calendar_id"]) if copy["kind"] == "caldav" else None + else: + calendar_id = await _invitation_calendar(s, user_id) + settings = await _SDK.accounts.settings_of(user_id) + await s.execute( + text( + "INSERT INTO plg_calendar_events " + "(user_id, calendar_id, ical_uid, summary, description, location, " + " dtstart, dtend, all_day, tz, rrule, recurrence_id, status, organizer, " + " attendees, my_partstat, sequence, raw_ical, origin_message_id, " + " conference_url, show_as, reminders) " + "VALUES (:u, :cal, :uid, :sum, :desc, :loc, :start, :end, :allday, :tz, " + " :rrule, :rid, :status, CAST(:org AS jsonb), CAST(:att AS jsonb), :mine, " + " :seq, :raw, :msg, :conf, :showas, :rem)" + ), + { + **fields, + "u": str(user_id), + "cal": str(calendar_id), + "uid": inv["uid"], + "rem": _default_reminders(settings), + }, + ) + if await _is_caldav(s, calendar_id): + dav_calendar = str(calendar_id) + if dav_calendar: + await _enqueue_dav_sync(uuid.UUID(dav_calendar)) + kv = _SDK.kv(user_id) + if body.response == "declined": + await kv.set(_declined_key(inv), True) + else: + await kv.delete(_declined_key(inv)) + + # Tell the organizer — unless the user organized it themselves. + organizer = inv["organizer"] + reply_sent = False + if organizer and organizer["email"] != me["email"]: + who = me["name"] or me["email"] + word = {"accepted": "accepted", "declined": "declined", "tentative": "tentatively accepted"} + sentence = f"{who} has {word[body.response]} this invitation." + try: + await _SDK.mail.send( + user_id, + to=[organizer["email"]], + subject=imip.reply_subject(body.response, inv["summary"]), + html=f"

{html_escape(sentence)}

", + text=sentence, + from_email=me["email"], + ics=imip.build_reply(ics, me, body.response), + ics_method="REPLY", + ics_filename="reply.ics", + ) + reply_sent = True + except Exception: # noqa: BLE001 - the calendar is updated either way + _log.exception("invitation reply to %s failed", organizer["email"]) + view = await _invitation_view(user_id, message_id) + view["reply_sent"] = reply_sent + return view + + +@router.post("/invitations/{message_id}/remove", dependencies=[Depends(require_csrf)]) +async def remove_cancelled_event(message_id: uuid.UUID, user_id: CurrentUserId) -> dict[str, Any]: + """The organizer cancelled the event: take it out of the calendar.""" + _ics, inv = await _invitation(user_id, message_id) + dav_calendar: str | None = None + async with _SDK.session(user_id) as s: + copy = await _invited_copy(s, inv) + if copy is not None: + await s.execute( + text( + "UPDATE plg_calendar_events SET dav_deleted = true WHERE id = :id" + if copy["kind"] == "caldav" + else "DELETE FROM plg_calendar_events WHERE id = :id" + ), + {"id": str(copy["id"])}, + ) + dav_calendar = str(copy["calendar_id"]) if copy["kind"] == "caldav" else None + if dav_calendar: + await _enqueue_dav_sync(uuid.UUID(dav_calendar)) + return await _invitation_view(user_id, message_id) + + # --- bundle ------------------------------------------------------------------ @router.get("/ui/{filename}") async def bundle(filename: str) -> FileResponse: - if filename != "page.js": + if filename not in ("page.js", "invite.js"): raise HTTPException(status_code=404) # `no-cache` = the browser must revalidate before reusing (ETag makes a 304 cheap), # so an edited bundle is picked up on the next page reload instead of a stale copy. diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/schemas.py b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/schemas.py index 4869736..640fc03 100644 --- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/schemas.py +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/schemas.py @@ -78,3 +78,9 @@ class EventPatch(BaseModel): color: str | None = None show_as: str | None = None conference_url: str | None = None + + +class InvitationAnswer(BaseModel): + """Yes / No / Maybe to an emailed invitation (iTIP PARTSTAT).""" + + response: str # accepted | declined | tentative diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/invite.js b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/invite.js new file mode 100644 index 0000000..f4f3d54 --- /dev/null +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/invite.js @@ -0,0 +1,244 @@ +// calendar · message.banner slot: an invitation received by email (iMIP). +// +// Above the message body: when, what, where, who organizes, what else is in the +// calendar then, and Yes / No / Maybe. Answering puts the event in the calendar +// (or takes it out) and sends the organizer the reply their calendar reads. +// +// Mounts on every opened message, so it stays silent and cheap for ordinary +// mail: nothing without attachments, and only a message with an .ics attachment +// asks the server about an invitation. + +const STYLE_ID = "cal-invite-styles"; +const CSS = ` +.cal-inv { margin: 4px 16px 12px; padding: 16px 18px; border: 1px solid rgb(var(--app-border)); + border-radius: 10px; background: rgb(var(--app-surface)); color: rgb(var(--app-fg)); font-size: 14px; } +.cal-inv-when { font-weight: 600; font-size: 13px; } +.cal-inv-when .cal-inv-sep { margin: 0 6px; opacity: .6; } +.cal-inv-title { margin: 4px 0 14px; font-size: 21px; line-height: 1.25; word-break: break-word; } +.cal-inv-cancelled .cal-inv-title { text-decoration: line-through; opacity: .7; } +.cal-inv-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px 24px; } +.cal-inv-row { display: flex; align-items: flex-start; gap: 12px; min-width: 0; color: rgb(var(--app-fg)); } +.cal-inv-row svg { flex: none; margin-top: 1px; color: rgb(var(--app-muted)); } +.cal-inv-row .cal-inv-text { min-width: 0; overflow-wrap: anywhere; } +.cal-inv-row a { color: rgb(var(--app-accent)); text-decoration: none; } +.cal-inv-row a:hover { text-decoration: underline; } +.cal-inv-faint { color: rgb(var(--app-muted)); } +.cal-inv-cal { margin-top: 12px; } +.cal-inv-label { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .03em; + color: rgb(var(--app-muted)); margin-bottom: 2px; } +.cal-inv-conflict { color: rgb(217 119 6); } +.cal-inv-btns { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 16px; } +.cal-inv-btn { min-width: 72px; padding: 8px 18px; border-radius: 999px; font-weight: 600; font-size: 14px; + border: 1px solid rgb(var(--app-border)); background: rgb(var(--app-hover)); color: rgb(var(--app-fg)); + cursor: pointer; } +.cal-inv-btn:hover:not(:disabled) { border-color: rgb(var(--app-accent)); } +.cal-inv-btn.on { background: rgb(var(--app-accent)); border-color: rgb(var(--app-accent)); + color: rgb(var(--app-accent-fg)); } +.cal-inv-btn:disabled { opacity: .6; cursor: default; } +.cal-inv-note { font-size: 12px; color: rgb(var(--app-muted)); } +.cal-inv-note.err { color: rgb(220 38 38); } +.cal-inv-badge { display: inline-block; margin-left: 8px; padding: 1px 7px; border-radius: 6px; font-size: 11px; + font-weight: 700; text-transform: uppercase; vertical-align: 2px; background: rgb(220 38 38 / .15); color: rgb(220 38 38); } +`; + +const ICON = { + place: + '', + video: + '', + people: + '', + calendar: + '', +}; + +function esc(s) { + return String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); +} + +// 12/24-hour and date order follow Settings → Appearance, like the rest of Mail. +let hour12Promise = null; +function hour12(ctx) { + hour12Promise ??= ctx.api + .fetch("/settings") + .then((s) => s && s.time_format === "12") + .catch(() => false); + return hour12Promise; +} + +function clock(d, h12) { + return d.toLocaleTimeString(h12 ? "en-US" : "en-GB", { hour: h12 ? "numeric" : "2-digit", minute: "2-digit", hour12: h12 }); +} + +function dayLabel(d, h12) { + const today = new Date(); + const tomorrow = new Date(today); + tomorrow.setDate(today.getDate() + 1); + if (d.toDateString() === today.toDateString()) return "Today"; + if (d.toDateString() === tomorrow.toDateString()) return "Tomorrow"; + const sameYear = d.getFullYear() === today.getFullYear(); + return d.toLocaleDateString(h12 ? "en-US" : "en-GB", { + weekday: "short", + day: "numeric", + month: "short", + ...(sameYear ? {} : { year: "numeric" }), + }); +} + +function whenLine(inv, h12) { + const s = new Date(inv.start); + const e = new Date(inv.end); + const sep = '•'; + if (inv.all_day) { + // An all-day event ends at the start of the day after its last one. + const last = new Date(e.getTime() - 1); + const days = last.toDateString() === s.toDateString() ? dayLabel(s, h12) : `${dayLabel(s, h12)} – ${dayLabel(last, h12)}`; + return `${esc(days)}${sep}All day`; + } + const sameDay = s.toDateString() === e.toDateString(); + const range = sameDay + ? `${clock(s, h12)} – ${clock(e, h12)}` + : `${clock(s, h12)} – ${esc(dayLabel(e, h12))} ${clock(e, h12)}`; + return `${esc(dayLabel(s, h12))}${sep}${range}${inv.recurring ? `${sep}Repeats` : ""}`; +} + +function eventSpan(ev, h12) { + if (ev.all_day) return "all day"; + return `${clock(new Date(ev.start), h12)} – ${clock(new Date(ev.end), h12)}`; +} + +function calendarLine(inv, h12) { + if (inv.conflicts && inv.conflicts.length) { + const first = inv.conflicts[0]; + const more = inv.conflicts.length > 1 ? ` and ${inv.conflicts.length - 1} more` : ""; + return `Conflicts with: ${esc(first.summary || "(no title)")} at ${eventSpan(first, h12)}${more}`; + } + if (inv.previous) { + return `Previous event: ${esc(inv.previous.summary || "(no title)")} at ${eventSpan(inv.previous, h12)}`; + } + return 'No other events at that time'; +} + +const ANSWERS = [ + ["accepted", "Yes"], + ["declined", "No"], + ["tentative", "Maybe"], +]; + +function render(el, inv, h12, note) { + const cancelled = inv.method === "CANCEL" || inv.status === "cancelled"; + const org = inv.organizer; + const orgLabel = org ? `${esc(org.name || org.email)} – organizer` : ""; + const conf = inv.conference_url; + // A meeting link given as the location reads better as a link. + const place = + conf && (!inv.location || inv.location === conf) + ? `
${ICON.video}Join video call
` + : inv.location + ? `
${ICON.place}${esc(inv.location)}${ + conf ? ` · Join video call` : "" + }
` + : ""; + let actions = ""; + if (cancelled) { + actions = inv.in_calendar + ? '
' + : '
Not in your calendar.
'; + } else if (inv.method === "REQUEST" && inv.me) { + actions = + '
' + + ANSWERS.map( + ([value, label]) => + ``, + ).join("") + + (note ? `${esc(note.text)}` : "") + + "
"; + } else if (inv.method === "REQUEST") { + actions = '
You are not on the guest list of this event.
'; + } + el.innerHTML = ` +
+
${whenLine(inv, h12)}${cancelled ? 'Cancelled' : ""}
+
${esc(inv.summary || "(no title)")}
+
+ ${place} + ${org ? `
${ICON.people}${orgLabel}
` : ""} +
+ ${ + cancelled + ? "" + : `
${ICON.calendar} +
In calendar${inv.in_calendar ? ` · ${esc(inv.in_calendar.calendar)}` : ""}
+ ${calendarLine(inv, h12)} + ${inv.outdated ? '
Your calendar has a newer version of this event.
' : ""} +
` + } + ${actions} +
`; +} + +export default function mount(el, ctx, props) { + const messageId = props && props.messageId; + // Ordinary mail stays silent: invitations come with an .ics attached. + if (!messageId || props.hasAttachments === false) return; + let disposed = false; + if (!document.getElementById(STYLE_ID)) { + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = CSS; + document.head.appendChild(style); + } + + async function show(inv, note) { + const h12 = await hour12(ctx); + if (disposed) return; + render(el, inv, h12, note); + el.querySelectorAll("[data-answer]").forEach((b) => + b.addEventListener("click", () => answer(b.dataset.answer, inv)), + ); + const remove = el.querySelector("[data-remove]"); + if (remove) remove.addEventListener("click", () => send(`/invitations/${messageId}/remove`, {}, inv, "Removed from your calendar.")); + } + + async function send(path, body, inv, okText) { + el.querySelectorAll("button").forEach((b) => (b.disabled = true)); + try { + const next = await ctx.api.pluginFetch(path, { method: "POST", body: JSON.stringify(body) }); + window.dispatchEvent(new CustomEvent("webmail:calendar-changed")); + await show(next, { text: typeof okText === "function" ? okText(next) : okText }); + } catch { + await show(inv, { text: "Couldn't save your answer. Try again.", err: true }); + } + } + + function answer(value, inv) { + send(`/invitations/${messageId}/respond`, { response: value }, inv, (next) => { + // The reply going out is the toast's news (Mail's corner toast, like Undo). + if (next.reply_sent) { + window.dispatchEvent(new CustomEvent("webmail:toast", { detail: { text: "Reply sent" } })); + } + return value === "declined" + ? "Not in your calendar." + : `Saved in ${next.in_calendar ? next.in_calendar.calendar : "your calendar"}.`; + }); + } + + (async () => { + try { + const files = await ctx.api.fetch(`/messages/${messageId}/attachments`); + const hasIcs = (files || []).some( + (a) => /\.ics$/i.test(a.filename || "") || /calendar|ics/i.test(a.mime_type || ""), + ); + if (!hasIcs || disposed) return; + const inv = await ctx.api.pluginFetch(`/invitations/${messageId}`); + if (!disposed && inv) await show(inv, null); + } catch { + /* not an invitation (404), or offline: no banner — never block the message */ + } + })(); + + return () => { + disposed = true; + el.innerHTML = ""; + }; +} diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js index be01ead..c7dd210 100644 --- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js @@ -132,7 +132,10 @@ const videoLinkHtml = (link) => ``; +// The Video-call section without a link: the button that adds one. +const ADD_VIDEO_HTML = ``; const COLORS = [ ["Blue", "#3b82f6"], ["Green", "#22c55e"], ["Red", "#ef4444"], ["Orange", "#f97316"], @@ -335,6 +338,9 @@ const CSS = ` .cal-agenda .day .dt{width:44px;flex:0 0 auto;font-size:12px;color:rgb(var(--app-muted))} .cal-agenda .day .dt b{display:block;font-size:18px;color:rgb(var(--app-fg))} .cal-agenda .none{color:rgb(var(--app-faint));font-size:13px} +.cal-agenda .day.empty{cursor:pointer} +.cal-agenda .day.empty:hover{background:rgb(var(--app-hover))} +.cal-agenda .day.empty:hover .none{color:rgb(var(--app-accent))} /* new-event form (floating box) */ /* Content-sized box: grows in height as sections are added, scrolls only past the viewport cap; the Save/Cancel footer sticks to the bottom while scrolling. */ @@ -935,6 +941,19 @@ export default function mount(el, ctx, props) { box.innerHTML = videoLinkHtml(link); const copyBtn = box.querySelector("[data-act=copy-jitsi]"); if (copyBtn) copyBtn.addEventListener("click", () => copyLink(link, copyBtn)); + const removeBtn = box.querySelector("[data-act=remove-video]"); + if (removeBtn) removeBtn.addEventListener("click", clearVideo); + } + // ✕ on the link: no video call — and none re-added when another guest is invited. + function clearVideo() { + if (!state.composing) return; + state.composing.conference = ""; + state.composing.videoRemoved = true; + const box = el.querySelector("[data-video]"); + if (!box) return; + box.innerHTML = ADD_VIDEO_HTML; + const add = box.querySelector("[data-act=add-jitsi]"); + if (add) add.addEventListener("click", () => fillVideo(jitsiLink(state.jitsi))); } function copyLink(link, btn) { try { navigator.clipboard.writeText(link); } catch { /* ignore */ } @@ -968,7 +987,9 @@ export default function mount(el, ctx, props) { guests.appendChild(span); // Adding a NEW guest auto-provisions a video call if none exists yet // (skipped when restoring an existing event's guests during edit). - if (autoVideo && !state.composing.conference) fillVideo(jitsiLink(state.jitsi)); + if (autoVideo && !state.composing.conference && !state.composing.videoRemoved) { + fillVideo(jitsiLink(state.jitsi)); + } } inp.value = ""; acpop.style.display = "none"; @@ -1296,7 +1317,7 @@ export default function mount(el, ctx, props) {

Video call

${c.conference ? videoLinkHtml(c.conference) - : ``}
+ : ADD_VIDEO_HTML}

Reminders

@@ -1482,7 +1503,10 @@ export default function mount(el, ctx, props) { const d = addDays(start, i); if (!short && d.getMonth() !== start.getMonth()) break; const dayEvs = evs.filter((ev) => sameDay(ev._s, d)).sort((a, b) => a._s - b._s); - html += `
${DAYNAMES[(d.getDay() + 6) % 7].toUpperCase()}${d.getDate()}
${ + const key = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; + // An empty day is a place to add one: click → new event on that date. + const empty = dayEvs.length ? "" : ` empty" data-newday="${key}" role="button" title="New event on this day`; + html += `
${DAYNAMES[(d.getDay() + 6) % 7].toUpperCase()}${d.getDate()}
${ dayEvs.length ? dayEvs.map((ev) => `
${ev.all_day ? "" : hhmm(ev._s) + " "}${esc(ev.summary || "(no title)")}
`).join("") : `No events` }
`; } @@ -1540,6 +1564,12 @@ export default function mount(el, ctx, props) { act("add-calendar", () => ctx.navigate("/settings/calendars?from=%2Fp%2Fcalendar&create=1")); act("signout", async () => { await api.fetch("/auth/logout", { method: "POST" }); ctx.navigate("/login"); }); act("new", () => openForm(null)); + on("[data-newday]", "click", (e) => { + // That day, at the current time of day (as clicking a day in Month view). + const [y, m, dd] = e.currentTarget.dataset.newday.split("-").map(Number); + const now = new Date(); + openForm(new Date(y, m - 1, dd, now.getHours(), now.getMinutes())); + }); act("today", () => { state.anchor = startOfDay(new Date()); state.mini = startOfMonth(state.anchor); reload(); }); act("prev", () => go(-1)); act("next", () => go(1)); @@ -1710,6 +1740,7 @@ export default function mount(el, ctx, props) { act("add-jitsi", () => fillVideo(jitsiLink(state.jitsi))); // Copy handler for a link rendered on initial form paint (edit mode). act("copy-jitsi", (e) => copyLink(state.composing.conference, e.currentTarget)); + act("remove-video", clearVideo); act("add-reminder", () => { const box = el.querySelector("[data-reminders]"); const wrap = document.createElement("div"); @@ -1789,7 +1820,16 @@ export default function mount(el, ctx, props) { } catch { /* ignore */ } })(); + // An invitation answered in Mail (calendar · invite.js) changed the calendar. + // Not while a form is open: re-rendering would throw away what is being typed. + const onChanged = async () => { + await loadEvents(); + if (!state.composing && !state.confirm) render(); + }; + window.addEventListener("webmail:calendar-changed", onChanged); + return () => { + window.removeEventListener("webmail:calendar-changed", onChanged); el.innerHTML = ""; }; } diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/ultimo-logo-signet.svg b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/ultimo-logo-signet.svg new file mode 100644 index 0000000..f5ff865 --- /dev/null +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/ultimo-logo-signet.svg @@ -0,0 +1,34 @@ + + + +