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 @@ + + + +