Event invitations in Mail, video previews, People tab, matching avatars

Invitations received by email (iMIP): above the message body, a card with
when, what, where (or "Join video call"), the organizer, what else is in the
calendar then (a conflict, else the previous event that day), and Yes / No /
Maybe. Yes and Maybe put the event in the calendar (or update it), No takes
it out — and is still shown as the answer given; each sends the organizer
the iTIP REPLY, then a "Reply sent" toast, and an open calendar refreshes.
A cancellation offers "Remove from calendar". It is the calendar plugin's
message.banner slot (ui/invite.js, imip.py, /invitations endpoints); the SDK
gains the user's own accounts and settings. An Outlook-style invitation sent
only as text/calendar is now kept as invite.ics too.

Calendar: clicking an empty day in the side panel's agenda opens a new event
on that date; a video-call link can be removed (✕ beside Copy); the page
guests see after answering, and our invitations' footer, say Ultimo Calendar
(with the logo).

Attachments: videos (mp4, mov, webm, …) play in the full-screen preview,
paused until played; attachments are now served from disk with HTTP Range,
so a video starts at once and can be seeked.

People tab: everyone in the thread, the user included, A–Z, under the name
any message gives them. Initials circles share one colour rule in the list,
the reading pane and the People tab. The contact view drops its duplicate
"Message" button. A general corner toast (webmail:toast) joins Undo's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 21:50:29 +02:00
co-authored by Claude Opus 5.5
parent fba3c947bf
commit 2337375bb7
24 changed files with 1443 additions and 100 deletions
+31 -8
View File
@@ -11,7 +11,7 @@ from typing import Annotated
from urllib.parse import quote from urllib.parse import quote
from fastapi import APIRouter, Depends, Query 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 import select
from sqlalchemy.ext.asyncio import AsyncSession 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 # Content types safe to serve inline (SPEC §10.8); everything else is forced to
# an attachment download with a neutral type. # an attachment download with a neutral type.
_INLINE_SAFE = {"image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"} _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 # 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). # 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: if att is None:
raise_api_error(404, "not_found", "attachment not found") raise_api_error(404, "not_found", "attachment not found")
data = blobstore.load(msg.user_id, att.blob_sha256) path = blobstore.blob_path(msg.user_id, att.blob_sha256)
inline = att.mime_type in _INLINE_SAFE and not download if not path.exists():
media_type = att.mime_type if inline else "application/octet-stream" raise_api_error(404, "not_found", "attachment content missing")
disposition = "inline" if inline else "attachment" inline_type = None if download else _inline_type(att.mime_type, att.filename)
return Response( disposition = "inline" if inline_type else "attachment"
content=data, # Streamed from disk, with HTTP Range: a video can be played and seeked
media_type=media_type, # 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)}, headers={"Content-Disposition": _content_disposition(disposition, att.filename)},
) )
+18
View File
@@ -139,6 +139,24 @@ def _extract_attachments(msg: EmailMessage) -> list[ParsedAttachment]:
payload=payload, 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 return out
+22 -8
View File
@@ -24,7 +24,7 @@ from sqlalchemy import select
from app.db import session_scope from app.db import session_scope
from app.events import get_event_bus from app.events import get_event_bus
from app.indexer import blobstore from app.indexer import blobstore
from app.models import Account, Mailbox, Message, Thread from app.models import Account, Mailbox, Message, Thread, User
class PluginMessages: class PluginMessages:
@@ -163,13 +163,10 @@ class PluginMessages:
class PluginAccounts: class PluginAccounts:
"""Cross-user account queries, for scheduled jobs. """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
Deliberately not user-scoped: a sweep runs on behalf of the instance, not a ``settings_of`` read one user's own accounts and settings, for a handler
request, and needs to find work across every user. Nothing here returns already acting as that user."""
content — only ids and state — so a plugin still has to go through the
user-scoped accessors to read anything.
"""
async def live(self) -> list[tuple[uuid.UUID, uuid.UUID]]: async def live(self) -> list[tuple[uuid.UUID, uuid.UUID]]:
"""``(user_id, account_id)`` for every account finished with its initial """``(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] 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: class PluginThreads:
"""Read-only, user-scoped thread queries (e.g. self-healing stale references).""" """Read-only, user-scoped thread queries (e.g. self-healing stale references)."""
+37
View File
@@ -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("notes.txt"))).status_code == 404
assert (await api.client.get(url("broken.jpg"))).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")
@@ -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("<p>You have been invited.</p>", 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")]
+44
View File
@@ -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 (
<div
key={toast.seq}
role="status"
className={
mobile
? // Level with the Compose button on the other side of the screen.
"fixed bottom-[calc(4.375rem+env(safe-area-inset-bottom))] left-4 z-40 flex h-11 items-center rounded-xl bg-app-fg px-4 text-[15px] font-medium text-app-surface shadow-lg"
: "fixed bottom-4 right-4 z-50 flex w-80 items-center rounded-lg bg-app-fg px-4 py-2.5 text-sm text-app-surface shadow-lg"
}
>
{toast.text}
</div>
);
}
+2 -7
View File
@@ -5,6 +5,7 @@ import { useEffect, useRef } from "react";
import { useT } from "../../i18n"; import { useT } from "../../i18n";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { useListDividers } from "../../lib/listDividers"; import { useListDividers } from "../../lib/listDividers";
import { avatarColor } from "../../lib/avatarColor";
import { useSavedThreads } from "../../lib/offline"; import { useSavedThreads } from "../../lib/offline";
import { formatClock, getDateLocale } from "../../lib/timeFormat"; import { formatClock, getDateLocale } from "../../lib/timeFormat";
import type { Address, ThreadSummary } from "../../lib/types"; 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. // 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 { function initials(p: Address | undefined): string {
const name = (p?.name || p?.email || "?").trim(); const name = (p?.name || p?.email || "?").trim();
const words = name.split(/\s+/).filter(Boolean); 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 // The badge still counts everyone involved — the name is narrowed, the
// "how many people are in this" signal is not. // "how many people are in this" signal is not.
const extra = Math.max(0, thread.participants.length - 1); 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) => { const stop = (fn: () => void) => (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
fn(); fn();
+37 -25
View File
@@ -9,8 +9,9 @@ import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useT } from "../../i18n"; import { useT } from "../../i18n";
import { avatarColor } from "../../lib/avatarColor";
import { api, apiFetch } from "../../lib/api"; 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 { AvatarImage } from "../SenderAvatar";
import { useIsMobile } from "../../lib/useIsMobile"; import { useIsMobile } from "../../lib/useIsMobile";
import { loadBundle } from "../../plugins/bundleLoader"; import { loadBundle } from "../../plugins/bundleLoader";
@@ -27,22 +28,17 @@ const AttachmentPreview = lazy(() =>
export type PanelTab = "calendar" | "people" | "attachments"; export type PanelTab = "calendar" | "people" | "attachments";
// ---- small helpers --------------------------------------------------------- // ---- 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) { function initials(name: string) {
const p = (name || "").trim().split(/\s+/).filter(Boolean); const p = (name || "").trim().split(/\s+/).filter(Boolean);
if (!p.length) return "?"; if (!p.length) return "?";
return ((p[0][0] || "") + (p.length > 1 ? p[p.length - 1][0] : "")).toUpperCase(); 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 }) { function Avatar({ name, email, size = 36 }: { name: string; email: string; size?: number }) {
const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
return ( return (
<span <span
className="relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full font-semibold text-white" className="relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full font-semibold text-white"
style={{ width: size, height: size, background: avatarColor(email || name), fontSize: Math.round(size * 0.38) }} style={{ width: size, height: size, background: avatarColor(email, name, accounts.data), fontSize: Math.round(size * 0.38) }}
> >
{initials(name || email)} {initials(name || email)}
<AvatarImage email={email} /> <AvatarImage email={email} />
@@ -184,14 +180,23 @@ function PeopleTab({ thread, onInviteAll }: { thread: ThreadDetail | undefined;
const [openEmail, setOpenEmail] = useState<string | null>(null); const [openEmail, setOpenEmail] = useState<string | null>(null);
const [openName, setOpenName] = useState(""); 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 convPeople = useMemo(() => {
const seen = new Map<string, Address>(); const names = new Map<string, string>();
for (const m of thread?.messages ?? []) { for (const m of thread?.messages ?? []) {
const a = m.from_addr[0]; for (const a of [...m.from_addr, ...m.to_addr, ...m.cc_addr, ...m.bcc_addr]) {
if (a?.email && !seen.has(a.email.toLowerCase())) seen.set(a.email.toLowerCase(), a); 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]); }, [thread]);
const groups = useQuery({ queryKey: ["contact-groups"], queryFn: () => apiFetch<Group[]>("/plugins/contacts/groups"), enabled: source === "autosaved" }); const groups = useQuery({ queryKey: ["contact-groups"], queryFn: () => apiFetch<Group[]>("/plugins/contacts/groups"), enabled: source === "autosaved" });
@@ -213,6 +218,24 @@ function PeopleTab({ thread, onInviteAll }: { thread: ThreadDetail | undefined;
return <PersonDetail email={openEmail} name={openName} onBack={() => setOpenEmail(null)} />; return <PersonDetail email={openEmail} name={openName} onBack={() => setOpenEmail(null)} />;
} }
const personRow = (p: { display_name: string; primary_email: string }) => (
<button
key={p.primary_email}
className="flex w-full items-center gap-3 px-4 py-2 text-left hover:bg-app-hover"
onClick={() => {
setOpenName(p.display_name);
setOpenEmail(p.primary_email);
}}
>
<Avatar name={p.display_name} email={p.primary_email} />
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{p.display_name}</div>
<div className="truncate text-[13px] text-app-muted">{p.primary_email}</div>
</div>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" className="text-app-faint"><path d="M6 4l4 4-4 4" /></svg>
</button>
);
const SOURCES: [PeopleSource, string][] = [ const SOURCES: [PeopleSource, string][] = [
["conversation", t("panel.inConversation")], ["conversation", t("panel.inConversation")],
["all", t("panel.allContacts")], ["all", t("panel.allContacts")],
@@ -260,20 +283,10 @@ function PeopleTab({ thread, onInviteAll }: { thread: ThreadDetail | undefined;
</div> </div>
</div> </div>
<div className="px-3 py-2 text-[12px] font-semibold uppercase tracking-wide text-app-faint">{t("panel.senders")}</div>
{people.length === 0 ? ( {people.length === 0 ? (
<div className="px-4 py-6 text-sm text-app-faint">{t("panel.noPeople")}</div> <div className="px-4 py-6 text-sm text-app-faint">{t("panel.noPeople")}</div>
) : ( ) : (
people.map((p, i) => ( people.map((p) => personRow(p))
<button key={p.primary_email + i} className="flex w-full items-center gap-3 px-4 py-2 text-left hover:bg-app-hover" onClick={() => { setOpenName(p.display_name); setOpenEmail(p.primary_email); }}>
<Avatar name={p.display_name} email={p.primary_email} />
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{p.display_name}</div>
<div className="truncate text-[13px] text-app-muted">{p.primary_email}</div>
</div>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" className="text-app-faint"><path d="M6 4l4 4-4 4" /></svg>
</button>
))
)} )}
</div> </div>
); );
@@ -316,7 +329,6 @@ function PersonDetail({ email, name, onBack }: { email: string; name: string; on
</div> </div>
<div className="mt-4 flex justify-around"> <div className="mt-4 flex justify-around">
<ActionBtn label={t("panel.call")} disabled={!c?.phones?.length} onClick={() => c?.phones?.[0] && window.open(`tel:${c.phones[0].value}`)} icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M4 4h4l2 5-3 2a12 12 0 0 0 6 6l2-3 5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 2 6a2 2 0 0 1 2-2" /></svg>} /> <ActionBtn label={t("panel.call")} disabled={!c?.phones?.length} onClick={() => c?.phones?.[0] && window.open(`tel:${c.phones[0].value}`)} icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M4 4h4l2 5-3 2a12 12 0 0 0 6 6l2-3 5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 2 6a2 2 0 0 1 2-2" /></svg>} />
<ActionBtn label={t("panel.message")} onClick={() => navigate(`/mail?compose=1&to=${encodeURIComponent(primary)}`)} icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H8l-4 4V5a2 2 0 0 1 2-2h13a2 2 0 0 1 2 2z" /></svg>} />
<ActionBtn label={t("panel.compose")} onClick={() => navigate(`/mail?compose=1&to=${encodeURIComponent(primary)}`)} icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z" /></svg>} /> <ActionBtn label={t("panel.compose")} onClick={() => navigate(`/mail?compose=1&to=${encodeURIComponent(primary)}`)} icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z" /></svg>} />
<ActionBtn label={t("panel.invite")} onClick={() => window.open(`/p/calendar?guest=${encodeURIComponent(primary)}`, "_blank", "noopener")} icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" /><path d="M16 2v4M8 2v4M3 10h18" /></svg>} /> <ActionBtn label={t("panel.invite")} onClick={() => window.open(`/p/calendar?guest=${encodeURIComponent(primary)}`, "_blank", "noopener")} icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" /><path d="M16 2v4M8 2v4M3 10h18" /></svg>} />
</div> </div>
@@ -147,15 +147,17 @@ export function AttachmentPreview({
</span> </span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button {kind !== "video" && (
type="button" <button
onClick={printAtt} type="button"
title={t("mail.print")} onClick={printAtt}
aria-label={t("mail.print")} title={t("mail.print")}
className="rounded p-1.5 text-white/80 hover:bg-white/10 hover:text-white" aria-label={t("mail.print")}
> className="rounded p-1.5 text-white/80 hover:bg-white/10 hover:text-white"
<Icon name="print" size={19} /> >
</button> <Icon name="print" size={19} />
</button>
)}
<button <button
type="button" type="button"
onClick={download} onClick={download}
@@ -174,11 +176,12 @@ export function AttachmentPreview({
<div <div
className="relative min-h-0 flex-1" className="relative min-h-0 flex-1"
onClick={(e) => { onClick={(e) => {
if (!(e.target as Element).closest("img, iframe, canvas, pre, button, a, form, input")) if (!(e.target as Element).closest("img, video, iframe, canvas, pre, button, a, form, input"))
onClose(); onClose();
}} }}
> >
{kind === "image" && <ImageView url={url} zoom={zoom} alt={att.filename} />} {kind === "image" && <ImageView url={url} zoom={zoom} alt={att.filename} />}
{kind === "video" && <VideoView key={att.id} url={url} onDownload={download} />}
{kind === "text" && ( {kind === "text" && (
<TextView url={url} zoom={zoom} onLoaded={(tx) => (textRef.current = tx)} /> <TextView url={url} zoom={zoom} onLoaded={(tx) => (textRef.current = tx)} />
)} )}
@@ -208,8 +211,8 @@ export function AttachmentPreview({
)} )}
</div> </div>
{/* Bottom controls */} {/* Bottom controls (a video has its own, in the player) */}
<div className="flex shrink-0 items-center justify-center py-3"> <div className={`flex shrink-0 items-center justify-center py-3 ${kind === "video" ? "hidden" : ""}`}>
<div className="flex items-center gap-2 rounded-full bg-black/60 px-3 py-1.5 text-sm text-white ring-1 ring-white/15"> <div className="flex items-center gap-2 rounded-full bg-black/60 px-3 py-1.5 text-sm text-white ring-1 ring-white/15">
{kind === "pdf" && page.total > 0 && ( {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 (
<div className="flex h-full w-full flex-col items-center justify-center gap-3 text-white/80">
<span>{t("mail.previewVideoUnsupported")}</span>
<button
type="button"
onClick={onDownload}
className="rounded-md bg-white/15 px-3 py-1.5 text-sm text-white hover:bg-white/25"
>
{t("mail.download")}
</button>
</div>
);
}
return (
<div className="flex h-full w-full items-center justify-center p-4">
{/* Paused until the user plays it. The #t fragment makes browsers that
would otherwise show black (Safari) fetch and show the first frame. */}
<video
src={`${url}#t=0.001`}
controls
playsInline
preload="metadata"
onError={() => setFailed(true)}
className="max-h-full max-w-full rounded bg-black shadow-2xl"
/>
</div>
);
}
function ImageView({ url, zoom, alt }: { url: string; zoom: number; alt: string }) { function ImageView({ url, zoom, alt }: { url: string; zoom: number; alt: string }) {
return ( return (
<div className="flex h-full w-full items-center justify-center overflow-auto p-4"> <div className="flex h-full w-full items-center justify-center overflow-auto p-4">
+18 -1
View File
@@ -6,7 +6,7 @@ import spreadsheetSvgRaw from "../../../icons/spreadsheet.svg?raw";
import { useT } from "../../i18n"; import { useT } from "../../i18n";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import type { AttachmentOut } from "../../lib/types"; 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 // These icons carry their own (baked-in) colours via inline styles, so render
// them raw rather than through <Icon>, which strips styles. Drop the XML prolog. // them raw rather than through <Icon>, 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 SPREADSHEET_EXT = new Set(["xlsx", "ods"]);
const DOC_EXT = new Set(["txt", "docx", "odt"]); // blue document icon const DOC_EXT = new Set(["txt", "docx", "odt"]); // blue document icon
// Video: a film frame with a play triangle.
function VideoGlyph() {
return (
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" aria-hidden>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="M10 9.2v5.6l4.8-2.8z" fill="currentColor" />
</svg>
);
}
function AttachmentIcon({ filename }: { filename: string }) { function AttachmentIcon({ filename }: { filename: string }) {
const e = extOf(filename); const e = extOf(filename);
if (VIDEO_EXT.has(e)) {
return (
<span className="shrink-0 text-violet-500">
<VideoGlyph />
</span>
);
}
if (e === "pdf") { if (e === "pdf") {
return ( return (
<span className="shrink-0 text-red-600 dark:text-red-500"> <span className="shrink-0 text-red-600 dark:text-red-500">
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { useT } from "../../i18n"; import { useT } from "../../i18n";
import { personHue } from "../../lib/avatarColor";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { printMessages } from "../../lib/print"; import { printMessages } from "../../lib/print";
import { useIsMobile } from "../../lib/useIsMobile"; 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). // Stable per-sender hue so a sender's avatar + name share a colour (like the mockup).
function hue(msg: MessageEnvelope): number { function hue(msg: MessageEnvelope): number {
const key = (msg.from_addr[0]?.email || msg.from_addr[0]?.name || "?").toLowerCase(); return personHue(msg.from_addr[0]?.email || msg.from_addr[0]?.name || "?");
let h = 0;
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) % 360;
return h;
} }
function fmtRelative(iso: string): string { function fmtRelative(iso: string): string {
@@ -493,7 +491,10 @@ function MessageItem({
</div> </div>
</div> </div>
{/* Plugin-contributed per-message banner (SPEC §11.3 message.banner). */} {/* Plugin-contributed per-message banner (SPEC §11.3 message.banner). */}
<PluginSlot name="message.banner" props={{ messageId: message.id }} /> <PluginSlot
name="message.banner"
props={{ messageId: message.id, hasAttachments: message.has_attachments }}
/>
<div className="px-4 pb-3"> <div className="px-4 pb-3">
<MessageBody message={message} onMailto={onMailto} /> <MessageBody message={message} onMailto={onMailto} />
</div> </div>
@@ -2,6 +2,8 @@
// clickability without pulling the heavy preview bundle (lazy-loaded on open). // clickability without pulling the heavy preview bundle (lazy-loaded on open).
const IMAGE_EXT = new Set(["jpg", "jpeg", "png", "gif", "webp"]); const IMAGE_EXT = new Set(["jpg", "jpeg", "png", "gif", "webp"]);
// Played in the preview's <video> (the server serves these inline, with Range).
export const VIDEO_EXT = new Set(["mp4", "m4v", "mov", "webm", "ogv"]);
// Office docs converted to HTML client-side (docx via mammoth, ODF via JSZip). // Office docs converted to HTML client-side (docx via mammoth, ODF via JSZip).
const OFFICE_EXT = new Set(["docx", "xlsx", "odt", "ods", "odp", "odg"]); const OFFICE_EXT = new Set(["docx", "xlsx", "odt", "ods", "odp", "odg"]);
@@ -11,15 +13,16 @@ export function extOf(name: string): string {
export function isPreviewable(name: string): boolean { export function isPreviewable(name: string): boolean {
const e = extOf(name); const e = extOf(name);
return e === "pdf" || e === "txt" || IMAGE_EXT.has(e) || OFFICE_EXT.has(e); return e === "pdf" || e === "txt" || IMAGE_EXT.has(e) || OFFICE_EXT.has(e) || VIDEO_EXT.has(e);
} }
export type PreviewKind = "image" | "pdf" | "text" | "office"; export type PreviewKind = "image" | "pdf" | "text" | "office" | "video";
export function kindOf(name: string): PreviewKind { export function kindOf(name: string): PreviewKind {
const e = extOf(name); const e = extOf(name);
if (e === "pdf") return "pdf"; if (e === "pdf") return "pdf";
if (e === "txt") return "text"; if (e === "txt") return "text";
if (OFFICE_EXT.has(e)) return "office"; if (OFFICE_EXT.has(e)) return "office";
if (VIDEO_EXT.has(e)) return "video";
return "image"; return "image";
} }
+1 -1
View File
@@ -95,6 +95,7 @@ export const en = {
"mail.expandAll": "Expand all", "mail.expandAll": "Expand all",
"mail.collapseAll": "Collapse all", "mail.collapseAll": "Collapse all",
"mail.print": "Print", "mail.print": "Print",
"mail.previewVideoUnsupported": "This video can't be played in the browser.",
"mail.previewPage": "Page {current} / {total}", "mail.previewPage": "Page {current} / {total}",
"mail.previewResetZoom": "Reset zoom", "mail.previewResetZoom": "Reset zoom",
"mail.previewPrev": "Previous", "mail.previewPrev": "Previous",
@@ -169,7 +170,6 @@ export const en = {
"panel.composeAll": "Compose to all", "panel.composeAll": "Compose to all",
"panel.inviteAll": "Invite all to new event", "panel.inviteAll": "Invite all to new event",
"panel.call": "Call", "panel.call": "Call",
"panel.message": "Message",
"panel.compose": "Compose", "panel.compose": "Compose",
"panel.invite": "Invite", "panel.invite": "Invite",
"panel.contactDetails": "Contact details", "panel.contactDetails": "Contact details",
+23
View File
@@ -0,0 +1,23 @@
// One colour per person, the same wherever their initials circle appears —
// the mail list, the reading pane, the People tab. The user's own accounts use
// the colour chosen for the account; anyone else a stable hue from their address.
import type { AccountOut } from "./types";
export function personHue(key: string): number {
const k = (key || "?").toLowerCase();
let h = 0;
for (let i = 0; i < k.length; i++) h = (h * 31 + k.charCodeAt(i)) % 360;
return h;
}
/** Background of a person's initials circle. */
export function avatarColor(
email: string | null | undefined,
name: string | null | undefined,
accounts: AccountOut[] | undefined,
): string {
const key = (email || "").toLowerCase();
const own = key ? accounts?.find((a) => a.email.toLowerCase() === key) : undefined;
return own?.color ?? `hsl(${personHue(email || name || "?")} 45% 45%)`;
}
+2
View File
@@ -7,6 +7,7 @@ import { AppSwitcher } from "../components/AppSwitcher";
import { ComposeDock } from "../components/compose/ComposeDock"; import { ComposeDock } from "../components/compose/ComposeDock";
import { Composer, type ComposerInit, draftToInit, localToInit } from "../components/compose/Composer"; import { Composer, type ComposerInit, draftToInit, localToInit } from "../components/compose/Composer";
import { UndoSendToast } from "../components/compose/UndoSendToast"; import { UndoSendToast } from "../components/compose/UndoSendToast";
import { Toast } from "../components/Toast";
import { usePendingSend } from "../components/compose/undoSend"; import { usePendingSend } from "../components/compose/undoSend";
import { import {
collapseExpanded, collapseExpanded,
@@ -1530,6 +1531,7 @@ export function MailLayout() {
fullScreen={mobile} fullScreen={mobile}
/> />
<UndoSendToast mobile={mobile} onUndone={reopenUnsent} /> <UndoSendToast mobile={mobile} onUndone={reopenUnsent} />
<Toast mobile={mobile} />
</div> </div>
); );
} }
@@ -21,4 +21,4 @@ calendar = "webmail_plugin_calendar:plugin"
packages = ["webmail_plugin_calendar"] packages = ["webmail_plugin_calendar"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
webmail_plugin_calendar = ["ui/*.js", "migrations/versions/*.py"] webmail_plugin_calendar = ["ui/*.js", "ui/*.svg", "migrations/versions/*.py"]
@@ -3,15 +3,15 @@
A **core, non-removable** plugin (``builtin=True``) that ships a `/calendar` page A **core, non-removable** plugin (``builtin=True``) that ships a `/calendar` page
beside Mail. Its own ``plg_calendar_*`` tables (RLS-isolated) hold calendars and 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. 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 Local events (A1), iMIP invitations sent and received (A2) and CalDAV / ICS (A3)
on the same data model and the SDK's vault + multi-method HTTP. share one data model, the SDK's vault and its multi-method HTTP.
""" """
from __future__ import annotations from __future__ import annotations
from pathlib import Path 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.router import router
from webmail_plugin_calendar.sync import caldav_sync from webmail_plugin_calendar.sync import caldav_sync
@@ -25,6 +25,9 @@ plugin = Plugin(
jobs=[JobSpec(func=caldav_sync, schedule="*/15 * * * *")], jobs=[JobSpec(func=caldav_sync, schedule="*/15 * * * *")],
router=router, router=router,
ui_manifest=UiManifest( 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=[ pages=[
Page( Page(
route="/calendar", route="/calendar",
@@ -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()
@@ -193,7 +193,7 @@ def build_html(
"</div>" "</div>"
'<p style="max-width:600px;margin:16px auto;color:#9aa0a6;font-size:12px;padding:0 4px">' '<p style="max-width:600px;margin:16px auto;color:#9aa0a6;font-size:12px;padding:0 4px">'
"You are receiving this message because you are a guest of this event. " "You are receiving this message because you are a guest of this event. "
"Sent by Webmail Calendar.</p>" "Sent by Ultimo Calendar.</p>"
"</body></html>" "</body></html>"
) )
@@ -10,10 +10,14 @@ from __future__ import annotations
import json import json
import logging import logging
import re
import secrets import secrets
import uuid import uuid
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from html import escape as html_escape
from pathlib import Path from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse, HTMLResponse, Response from fastapi.responses import FileResponse, HTMLResponse, Response
@@ -22,7 +26,7 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.sdk import CurrentUserId, require_csrf, sdk 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.queries import expand_range
from webmail_plugin_calendar.schemas import ( from webmail_plugin_calendar.schemas import (
CalendarIn, CalendarIn,
@@ -31,6 +35,7 @@ from webmail_plugin_calendar.schemas import (
EditFollowingIn, EditFollowingIn,
EventIn, EventIn,
EventPatch, EventPatch,
InvitationAnswer,
SubscribeIn, SubscribeIn,
) )
@@ -473,7 +478,15 @@ async def list_events(
"""List events overlapping [start, end). With ``q`` set, only events whose """List events overlapping [start, end). With ``q`` set, only events whose
summary/location/description match (case-insensitive) are returned — used by summary/location/description match (case-insensitive) are returned — used by
the calendar search panel over a wide date window.""" 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} params: dict[str, object] = {"end": range_end}
where = "WHERE e.dtstart < :end AND e.dav_deleted = false" where = "WHERE e.dtstart < :end AND e.dav_deleted = false"
term = q.strip() term = q.strip()
@@ -483,23 +496,22 @@ async def list_events(
"OR COALESCE(e.description, '') ILIKE :q)" "OR COALESCE(e.description, '') ILIKE :q)"
) )
params["q"] = f"%{term}%" params["q"] = f"%{term}%"
async with _SDK.session(user_id) as s: rows = (
rows = ( await s.execute(
await s.execute( text(
text( "SELECT e.id, e.calendar_id, e.ical_uid, e.summary, e.description, "
"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.location, e.dtstart, e.dtend, e.all_day, e.tz, e.rrule, e.reminders, " "e.exdates, e.recurrence_id, e.status, "
"e.exdates, e.recurrence_id, e.status, " "e.my_partstat, e.origin_message_id, e.show_as, e.conference_url, "
"e.my_partstat, e.origin_message_id, e.show_as, e.conference_url, " "e.attendees, e.organizer, "
"e.attendees, e.organizer, " "COALESCE(e.color, c.color) AS color "
"COALESCE(e.color, c.color) AS color " "FROM plg_calendar_events e "
"FROM plg_calendar_events e " "JOIN plg_calendar_calendars c ON c.id = e.calendar_id "
"JOIN plg_calendar_calendars c ON c.id = e.calendar_id " f"{where}"
f"{where}" ),
), params,
params, )
) ).mappings().all()
).mappings().all()
base = [ base = [
r r
for r in rows 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) 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] 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( 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) -------- # --- 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"<svg\b([^>]*?)\swidth=\"[^\"]*\"([^>]*?)\sheight=\"[^\"]*\"",
r'<svg\1 width="56"\2 height="54"', svg, count=1)
_LOGO = _logo_svg()
def _rsvp_page(message: str) -> str: def _rsvp_page(message: str) -> str:
"""What a guest sees after clicking Yes / No / Maybe in our invitation."""
return ( return (
"<!doctype html><html><head><meta charset='utf-8'>" "<!doctype html><html><head><meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width, initial-scale=1'>" "<meta name='viewport' content='width=device-width, initial-scale=1'>"
"<title>Invitation response</title></head>" "<title>Invitation response · Ultimo Calendar</title></head>"
"<body style='margin:0;font-family:Arial,Helvetica,sans-serif;background:#f1f3f4;" "<body style='margin:0;font-family:Arial,Helvetica,sans-serif;background:#f1f3f4;"
"color:#3c4043'><div style='max-width:460px;margin:64px auto;background:#fff;" "color:#3c4043'><div style='max-width:460px;margin:64px auto;background:#fff;"
"border:1px solid #e0e0e0;border-radius:8px;padding:32px;text-align:center'>" "border:1px solid #e0e0e0;border-radius:8px;padding:32px;text-align:center'>"
"<h2 style='margin:0 0 12px;font-size:18px'>Webmail Calendar</h2>" f"<div style='display:flex;justify-content:center;margin:0 0 14px'>{_LOGO}</div>"
"<h2 style='margin:0 0 12px;font-size:18px'>Ultimo Calendar</h2>"
f"<p style='margin:0;color:#5f6368'>{message}</p></div></body></html>" f"<p style='margin:0;color:#5f6368'>{message}</p></div></body></html>"
) )
@@ -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.")) 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"<p>{html_escape(sentence)}</p>",
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 ------------------------------------------------------------------ # --- bundle ------------------------------------------------------------------
@router.get("/ui/{filename}") @router.get("/ui/{filename}")
async def bundle(filename: str) -> FileResponse: async def bundle(filename: str) -> FileResponse:
if filename != "page.js": if filename not in ("page.js", "invite.js"):
raise HTTPException(status_code=404) raise HTTPException(status_code=404)
# `no-cache` = the browser must revalidate before reusing (ETag makes a 304 cheap), # `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. # so an edited bundle is picked up on the next page reload instead of a stale copy.
@@ -78,3 +78,9 @@ class EventPatch(BaseModel):
color: str | None = None color: str | None = None
show_as: str | None = None show_as: str | None = None
conference_url: 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
@@ -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:
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21s-7-6.2-7-11.5a7 7 0 0 1 14 0C19 14.8 12 21 12 21z"/><circle cx="12" cy="9.5" r="2.5"/></svg>',
video:
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="6" width="13" height="12" rx="2"/><path d="M16 10.5l5-3v9l-5-3z"/></svg>',
people:
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M3 19c.6-3.3 3-5 6-5s5.4 1.7 6 5"/><circle cx="17" cy="9" r="2.4"/><path d="M15.5 14.2c2.6-.3 4.7 1.2 5.5 4.3"/></svg>',
calendar:
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="5" width="17" height="15.5" rx="2"/><path d="M3.5 10h17M8 3v4M16 3v4"/></svg>',
};
function esc(s) {
return String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[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 = '<span class="cal-inv-sep">•</span>';
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 `<span class="cal-inv-conflict">Conflicts with: ${esc(first.summary || "(no title)")} at ${eventSpan(first, h12)}${more}</span>`;
}
if (inv.previous) {
return `Previous event: ${esc(inv.previous.summary || "(no title)")} at ${eventSpan(inv.previous, h12)}`;
}
return '<span class="cal-inv-faint">No other events at that time</span>';
}
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)} <span class="cal-inv-faint">– organizer</span>` : "";
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)
? `<div class="cal-inv-row">${ICON.video}<span class="cal-inv-text"><a href="${esc(conf)}" target="_blank" rel="noopener noreferrer">Join video call</a></span></div>`
: inv.location
? `<div class="cal-inv-row">${ICON.place}<span class="cal-inv-text">${esc(inv.location)}${
conf ? ` · <a href="${esc(conf)}" target="_blank" rel="noopener noreferrer">Join video call</a>` : ""
}</span></div>`
: "";
let actions = "";
if (cancelled) {
actions = inv.in_calendar
? '<div class="cal-inv-btns"><button class="cal-inv-btn" data-remove>Remove from calendar</button></div>'
: '<div class="cal-inv-btns"><span class="cal-inv-note">Not in your calendar.</span></div>';
} else if (inv.method === "REQUEST" && inv.me) {
actions =
'<div class="cal-inv-btns">' +
ANSWERS.map(
([value, label]) =>
`<button class="cal-inv-btn${inv.response === value ? " on" : ""}" data-answer="${value}" aria-pressed="${inv.response === value}">${label}</button>`,
).join("") +
(note ? `<span class="cal-inv-note${note.err ? " err" : ""}">${esc(note.text)}</span>` : "") +
"</div>";
} else if (inv.method === "REQUEST") {
actions = '<div class="cal-inv-btns"><span class="cal-inv-note">You are not on the guest list of this event.</span></div>';
}
el.innerHTML = `
<div class="cal-inv${cancelled ? " cal-inv-cancelled" : ""}" role="group" aria-label="Event invitation">
<div class="cal-inv-when">${whenLine(inv, h12)}${cancelled ? '<span class="cal-inv-badge">Cancelled</span>' : ""}</div>
<div class="cal-inv-title">${esc(inv.summary || "(no title)")}</div>
<div class="cal-inv-grid">
${place}
${org ? `<div class="cal-inv-row">${ICON.people}<span class="cal-inv-text">${orgLabel}</span></div>` : ""}
</div>
${
cancelled
? ""
: `<div class="cal-inv-row cal-inv-cal">${ICON.calendar}<span class="cal-inv-text">
<div class="cal-inv-label">In calendar${inv.in_calendar ? ` · ${esc(inv.in_calendar.calendar)}` : ""}</div>
${calendarLine(inv, h12)}
${inv.outdated ? '<div class="cal-inv-faint">Your calendar has a newer version of this event.</div>' : ""}
</span></div>`
}
${actions}
</div>`;
}
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 = "";
};
}
@@ -132,7 +132,10 @@ const videoLinkHtml = (link) => `<div class="cal-videolink">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>
<a href="${esc(link)}" target="_blank" rel="noopener noreferrer">${esc(link)}</a> <a href="${esc(link)}" target="_blank" rel="noopener noreferrer">${esc(link)}</a>
<button class="cal-iconbtn" type="button" data-act="copy-jitsi" title="Copy link"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button> <button class="cal-iconbtn" type="button" data-act="copy-jitsi" title="Copy link"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>
<button class="cal-iconbtn" type="button" data-act="remove-video" title="Remove video call" aria-label="Remove video call"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
</div>`; </div>`;
// The Video-call section without a link: the button that adds one.
const ADD_VIDEO_HTML = `<button class="cal-btn" data-act="add-jitsi" style="display:inline-flex;align-items:center;gap:6px"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="flex:0 0 auto"><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>Add a video call in Jitsi Meet</button>`;
const COLORS = [ const COLORS = [
["Blue", "#3b82f6"], ["Green", "#22c55e"], ["Red", "#ef4444"], ["Orange", "#f97316"], ["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{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 .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 .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) */ /* new-event form (floating box) */
/* Content-sized box: grows in height as sections are added, scrolls only past the /* 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. */ 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); box.innerHTML = videoLinkHtml(link);
const copyBtn = box.querySelector("[data-act=copy-jitsi]"); const copyBtn = box.querySelector("[data-act=copy-jitsi]");
if (copyBtn) copyBtn.addEventListener("click", () => copyLink(link, copyBtn)); 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) { function copyLink(link, btn) {
try { navigator.clipboard.writeText(link); } catch { /* ignore */ } try { navigator.clipboard.writeText(link); } catch { /* ignore */ }
@@ -968,7 +987,9 @@ export default function mount(el, ctx, props) {
guests.appendChild(span); guests.appendChild(span);
// Adding a NEW guest auto-provisions a video call if none exists yet // Adding a NEW guest auto-provisions a video call if none exists yet
// (skipped when restoring an existing event's guests during edit). // (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 = ""; inp.value = "";
acpop.style.display = "none"; acpop.style.display = "none";
@@ -1296,7 +1317,7 @@ export default function mount(el, ctx, props) {
<h4>Video call</h4> <h4>Video call</h4>
<div data-video>${c.conference <div data-video>${c.conference
? videoLinkHtml(c.conference) ? videoLinkHtml(c.conference)
: `<button class="cal-btn" data-act="add-jitsi" style="display:inline-flex;align-items:center;gap:6px"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="flex:0 0 auto"><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg>Add a video call in Jitsi Meet</button>`}</div> : ADD_VIDEO_HTML}</div>
<h4>Reminders</h4> <h4>Reminders</h4>
<div data-reminders></div> <div data-reminders></div>
<button class="cal-btn" data-act="add-reminder" style="margin-top:6px">+ Add reminder</button> <button class="cal-btn" data-act="add-reminder" style="margin-top:6px">+ Add reminder</button>
@@ -1482,7 +1503,10 @@ export default function mount(el, ctx, props) {
const d = addDays(start, i); const d = addDays(start, i);
if (!short && d.getMonth() !== start.getMonth()) break; if (!short && d.getMonth() !== start.getMonth()) break;
const dayEvs = evs.filter((ev) => sameDay(ev._s, d)).sort((a, b) => a._s - b._s); const dayEvs = evs.filter((ev) => sameDay(ev._s, d)).sort((a, b) => a._s - b._s);
html += `<div class="day" data-agday="${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}"><div class="dt">${DAYNAMES[(d.getDay() + 6) % 7].toUpperCase()}<b>${d.getDate()}</b></div><div style="flex:1">${ 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 += `<div class="day${empty}" data-agday="${key}"><div class="dt">${DAYNAMES[(d.getDay() + 6) % 7].toUpperCase()}<b>${d.getDate()}</b></div><div style="flex:1">${
dayEvs.length ? dayEvs.map((ev) => `<div class="cal-ev" data-ev="${ev.id}" data-start="${esc(ev.start)}" style="border-color:${esc(ev.color)}">${ev.all_day ? "" : hhmm(ev._s) + " "}${esc(ev.summary || "(no title)")}</div>`).join("") : `<span class="none">No events</span>` dayEvs.length ? dayEvs.map((ev) => `<div class="cal-ev" data-ev="${ev.id}" data-start="${esc(ev.start)}" style="border-color:${esc(ev.color)}">${ev.all_day ? "" : hhmm(ev._s) + " "}${esc(ev.summary || "(no title)")}</div>`).join("") : `<span class="none">No events</span>`
}</div></div>`; }</div></div>`;
} }
@@ -1540,6 +1564,12 @@ export default function mount(el, ctx, props) {
act("add-calendar", () => ctx.navigate("/settings/calendars?from=%2Fp%2Fcalendar&create=1")); 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("signout", async () => { await api.fetch("/auth/logout", { method: "POST" }); ctx.navigate("/login"); });
act("new", () => openForm(null)); 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("today", () => { state.anchor = startOfDay(new Date()); state.mini = startOfMonth(state.anchor); reload(); });
act("prev", () => go(-1)); act("prev", () => go(-1));
act("next", () => go(1)); act("next", () => go(1));
@@ -1710,6 +1740,7 @@ export default function mount(el, ctx, props) {
act("add-jitsi", () => fillVideo(jitsiLink(state.jitsi))); act("add-jitsi", () => fillVideo(jitsiLink(state.jitsi)));
// Copy handler for a link rendered on initial form paint (edit mode). // Copy handler for a link rendered on initial form paint (edit mode).
act("copy-jitsi", (e) => copyLink(state.composing.conference, e.currentTarget)); act("copy-jitsi", (e) => copyLink(state.composing.conference, e.currentTarget));
act("remove-video", clearVideo);
act("add-reminder", () => { act("add-reminder", () => {
const box = el.querySelector("[data-reminders]"); const box = el.querySelector("[data-reminders]");
const wrap = document.createElement("div"); const wrap = document.createElement("div");
@@ -1789,7 +1820,16 @@ export default function mount(el, ctx, props) {
} catch { /* ignore */ } } 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 () => { return () => {
window.removeEventListener("webmail:calendar-changed", onChanged);
el.innerHTML = ""; el.innerHTML = "";
}; };
} }
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="44.943924mm"
height="42.975948mm"
viewBox="0 0 44.943923 42.975948"
version="1.1"
id="svg1"
xml:space="preserve"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1"><linearGradient
id="linearGradient11"><stop
style="stop-color:#f89629;stop-opacity:1;"
offset="0.20198774"
id="stop13" /><stop
style="stop-color:#f74176;stop-opacity:1;"
offset="1"
id="stop12" /></linearGradient><linearGradient
xlink:href="#linearGradient11"
id="linearGradient12"
x1="54.340946"
y1="122.84999"
x2="54.340946"
y2="162.66415"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.75684298,0,0,0.80151105,3.0076311,94.135157)" /></defs><g
id="layer1"
transform="translate(-21.70051,-187.41349)"><path
id="path1-2"
style="baseline-shift:baseline;display:inline;overflow:visible;opacity:1;vector-effect:none;fill:url(#linearGradient12);fill-opacity:1;stroke-width:0.778856;stroke-linecap:round;enable-background:accumulate;stop-color:#000000;stop-opacity:1"
d="m 58.875622,191.58757 c -2.152336,-7.4e-4 -4.067804,1.84724 -4.067104,4.1266 v 2.85212 c -0.073,-0.005 -0.146556,-0.007 -0.220195,-0.007 H 44.088203 33.589648 c -0.04176,-7e-5 -0.0835,5.9e-4 -0.125155,0.002 v -2.78918 c -1.66e-4,-2.27807 -1.959264,-4.12477 -4.110377,-4.12494 -2.151113,1.8e-4 -4.208743,1.84687 -4.208908,4.12494 v 15.55127 c -3.9e-5,0.005 -0.02954,3.84339 2.173004,7.60498 2.20396,3.76402 7.038201,7.38257 14.452668,7.38257 h 4.580673 c 7.417703,0 12.26918,-3.66617 14.505859,-7.44221 2.23668,-3.77604 2.27039,-7.63481 2.27039,-7.63481 l 0.0016,-0.0344 V 195.7141 c 7e-4,-2.27872 -2.102019,-4.12642 -4.253744,-4.1266 z m -2.007917,7.94794 v 11.57584 0.0286 c 0,0 -0.06644,2.0824 -1.311391,4.18416 -0.572022,0.96572 -1.445415,2.15762 -2.917676,2.99793 -1.472263,0.8403 -3.460652,1.33079 -6.272222,1.33079 H 41.78535 c -2.82381,0 -4.798635,-0.47938 -6.256969,-1.30429 -1.458334,-0.8249 -2.313565,-2.00343 -2.85901,-2.93496 -1.196636,-2.04365 -1.230039,-4.07193 -1.230039,-4.07193 l -0.0011,-0.0141 v -0.0157 -11.69637 l 0.02191,0.0178 7.82e-4,10e-4 c 0.03515,0.0487 0.07994,0.0958 0.133761,0.14041 l 5.513859,4.57144 5.515036,4.57144 c 0.651946,0.53945 2.279028,0.53945 2.930974,0 l 5.515034,-4.57144 5.514252,-4.57144 c 0.01986,-0.0164 0.03852,-0.0331 0.05593,-0.0501 l 7.82e-4,-8.2e-4 z" /></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB