Event invitations in Mail, video previews, People tab, matching avatars
Invitations received by email (iMIP): above the message body, a card with when, what, where (or "Join video call"), the organizer, what else is in the calendar then (a conflict, else the previous event that day), and Yes / No / Maybe. Yes and Maybe put the event in the calendar (or update it), No takes it out — and is still shown as the answer given; each sends the organizer the iTIP REPLY, then a "Reply sent" toast, and an open calendar refreshes. A cancellation offers "Remove from calendar". It is the calendar plugin's message.banner slot (ui/invite.js, imip.py, /invitations endpoints); the SDK gains the user's own accounts and settings. An Outlook-style invitation sent only as text/calendar is now kept as invite.ics too. Calendar: clicking an empty day in the side panel's agenda opens a new event on that date; a video-call link can be removed (✕ beside Copy); the page guests see after answering, and our invitations' footer, say Ultimo Calendar (with the logo). Attachments: videos (mp4, mov, webm, …) play in the full-screen preview, paused until played; attachments are now served from disk with HTTP Range, so a video starts at once and can be seeked. People tab: everyone in the thread, the user included, A–Z, under the name any message gives them. Initials circles share one colour rule in the list, the reading pane and the People tab. The contact view drops its duplicate "Message" button. A general corner toast (webmail:toast) joins Undo's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@ from typing import Annotated
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -33,6 +33,26 @@ DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
# Content types safe to serve inline (SPEC §10.8); everything else is forced to
|
||||
# an attachment download with a neutral type.
|
||||
_INLINE_SAFE = {"image/png", "image/jpeg", "image/gif", "image/webp", "application/pdf"}
|
||||
# Videos the browser can play, by extension — senders often label them
|
||||
# application/octet-stream. A QuickTime .mov is usually H.264 in an MP4-style
|
||||
# container: offered as video/mp4, browsers play it (an HEVC one stays unplayable,
|
||||
# and the preview offers a download instead). Media cannot run script: safe inline.
|
||||
_VIDEO_EXT_MIME = {
|
||||
"mp4": "video/mp4",
|
||||
"m4v": "video/mp4",
|
||||
"mov": "video/mp4",
|
||||
"webm": "video/webm",
|
||||
"ogv": "video/ogg",
|
||||
}
|
||||
|
||||
|
||||
def _inline_type(mime_type: str, filename: str) -> str | None:
|
||||
"""The type to serve an attachment as for in-app viewing, or None."""
|
||||
if mime_type in _INLINE_SAFE:
|
||||
return mime_type
|
||||
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
||||
return _VIDEO_EXT_MIME.get(ext)
|
||||
|
||||
|
||||
# Base styles injected around the sanitized fragment so email content renders on a
|
||||
# readable light canvas with images that fit the pane (the iframe is sandboxed).
|
||||
@@ -309,12 +329,15 @@ async def message_attachment(
|
||||
)
|
||||
if att is None:
|
||||
raise_api_error(404, "not_found", "attachment not found")
|
||||
data = blobstore.load(msg.user_id, att.blob_sha256)
|
||||
inline = att.mime_type in _INLINE_SAFE and not download
|
||||
media_type = att.mime_type if inline else "application/octet-stream"
|
||||
disposition = "inline" if inline else "attachment"
|
||||
return Response(
|
||||
content=data,
|
||||
media_type=media_type,
|
||||
path = blobstore.blob_path(msg.user_id, att.blob_sha256)
|
||||
if not path.exists():
|
||||
raise_api_error(404, "not_found", "attachment content missing")
|
||||
inline_type = None if download else _inline_type(att.mime_type, att.filename)
|
||||
disposition = "inline" if inline_type else "attachment"
|
||||
# Streamed from disk, with HTTP Range: a video can be played and seeked
|
||||
# without being sent whole first (Safari will not play one otherwise).
|
||||
return FileResponse(
|
||||
path,
|
||||
media_type=inline_type or "application/octet-stream",
|
||||
headers={"Content-Disposition": _content_disposition(disposition, att.filename)},
|
||||
)
|
||||
|
||||
@@ -139,6 +139,24 @@ def _extract_attachments(msg: EmailMessage) -> list[ParsedAttachment]:
|
||||
payload=payload,
|
||||
)
|
||||
)
|
||||
# An invitation sent only as the text/calendar alternative (Outlook, often) has
|
||||
# no file to show: keep it as invite.ics, so the event can be seen, answered
|
||||
# and downloaded like a Gmail one. Not when the message attaches its own .ics.
|
||||
if not any(a.filename.lower().endswith(".ics") for a in out):
|
||||
for part in msg.walk():
|
||||
if part.get_content_type() != "text/calendar" or part.get_filename():
|
||||
continue
|
||||
payload = part.get_payload(decode=True)
|
||||
if isinstance(payload, bytes) and payload.strip():
|
||||
out.append(
|
||||
ParsedAttachment(
|
||||
filename="invite.ics",
|
||||
mime_type="text/calendar",
|
||||
content_id=None,
|
||||
payload=payload,
|
||||
)
|
||||
)
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ from sqlalchemy import select
|
||||
from app.db import session_scope
|
||||
from app.events import get_event_bus
|
||||
from app.indexer import blobstore
|
||||
from app.models import Account, Mailbox, Message, Thread
|
||||
from app.models import Account, Mailbox, Message, Thread, User
|
||||
|
||||
|
||||
class PluginMessages:
|
||||
@@ -163,13 +163,10 @@ class PluginMessages:
|
||||
|
||||
|
||||
class PluginAccounts:
|
||||
"""Cross-user account queries, for scheduled jobs.
|
||||
|
||||
Deliberately not user-scoped: a sweep runs on behalf of the instance, not a
|
||||
request, and needs to find work across every user. Nothing here returns
|
||||
content — only ids and state — so a plugin still has to go through the
|
||||
user-scoped accessors to read anything.
|
||||
"""
|
||||
"""Account queries: ``live()`` is cross-user, for scheduled jobs (a sweep runs
|
||||
on behalf of the instance and returns only ids and state); ``of_user`` and
|
||||
``settings_of`` read one user's own accounts and settings, for a handler
|
||||
already acting as that user."""
|
||||
|
||||
async def live(self) -> list[tuple[uuid.UUID, uuid.UUID]]:
|
||||
"""``(user_id, account_id)`` for every account finished with its initial
|
||||
@@ -181,6 +178,23 @@ class PluginAccounts:
|
||||
)
|
||||
return [(u, a) for u, a in rows]
|
||||
|
||||
async def of_user(self, user_id: uuid.UUID) -> list[tuple[uuid.UUID, str, str]]:
|
||||
"""``(account_id, email, display_name)`` of the user's own mail accounts —
|
||||
e.g. to tell which attendee of an emailed invitation is the user."""
|
||||
async with session_scope() as session:
|
||||
rows = await session.execute(
|
||||
select(Account.id, Account.email, Account.display_name)
|
||||
.where(Account.user_id == user_id)
|
||||
.order_by(Account.created_at)
|
||||
)
|
||||
return [(a, str(e), str(n or "")) for a, e, n in rows]
|
||||
|
||||
async def settings_of(self, user_id: uuid.UUID) -> dict[str, object]:
|
||||
"""The user's settings (time zone, default calendar, …), read-only."""
|
||||
async with session_scope() as session:
|
||||
settings = await session.scalar(select(User.settings).where(User.id == user_id))
|
||||
return dict(settings or {})
|
||||
|
||||
|
||||
class PluginThreads:
|
||||
"""Read-only, user-scoped thread queries (e.g. self-healing stale references)."""
|
||||
|
||||
@@ -605,3 +605,40 @@ async def test_attachment_thumbnails(api: ApiCtx) -> None:
|
||||
|
||||
assert (await api.client.get(url("notes.txt"))).status_code == 404
|
||||
assert (await api.client.get(url("broken.jpg"))).status_code == 404
|
||||
|
||||
|
||||
async def test_video_attachment_streams_with_range(api: ApiCtx) -> None:
|
||||
# A video plays in the preview: served inline as video (whatever the sender
|
||||
# called it) and in pieces on request (HTTP Range), so it can be seeked.
|
||||
from app.indexer import blobstore
|
||||
from app.models import Attachment
|
||||
|
||||
assert api.session is not None
|
||||
mid = api.message_ids[-1]
|
||||
data = bytes(range(256)) * 40 # 10 240 bytes standing in for a video
|
||||
att = Attachment(
|
||||
message_id=mid,
|
||||
user_id=api.user_id,
|
||||
filename="clip.MOV",
|
||||
mime_type="application/octet-stream",
|
||||
size_bytes=len(data),
|
||||
content_id=None,
|
||||
blob_sha256=blobstore.store(api.user_id, data),
|
||||
)
|
||||
api.session.add(att)
|
||||
await api.session.commit()
|
||||
url = f"/api/messages/{mid}/attachments/{att.id}"
|
||||
|
||||
whole = await api.client.get(url)
|
||||
assert whole.status_code == 200 and whole.content == data
|
||||
assert whole.headers["content-type"] == "video/mp4"
|
||||
assert whole.headers["content-disposition"].startswith("inline")
|
||||
assert whole.headers["accept-ranges"] == "bytes"
|
||||
|
||||
part = await api.client.get(url, headers={"Range": "bytes=1000-1999"})
|
||||
assert part.status_code == 206 and part.content == data[1000:2000]
|
||||
assert part.headers["content-range"] == f"bytes 1000-1999/{len(data)}"
|
||||
|
||||
saved = await api.client.get(url + "?download=1")
|
||||
assert saved.headers["content-type"] == "application/octet-stream"
|
||||
assert saved.headers["content-disposition"].startswith("attachment")
|
||||
|
||||
@@ -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")]
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useRef } from "react";
|
||||
import { useT } from "../../i18n";
|
||||
import { api } from "../../lib/api";
|
||||
import { useListDividers } from "../../lib/listDividers";
|
||||
import { avatarColor } from "../../lib/avatarColor";
|
||||
import { useSavedThreads } from "../../lib/offline";
|
||||
import { formatClock, getDateLocale } from "../../lib/timeFormat";
|
||||
import type { Address, ThreadSummary } from "../../lib/types";
|
||||
@@ -22,12 +23,6 @@ function timeLabel(iso: string): string {
|
||||
}
|
||||
|
||||
// Stable per-sender hue so a sender's avatar keeps a consistent colour.
|
||||
function hue(key: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) % 360;
|
||||
return h;
|
||||
}
|
||||
|
||||
function initials(p: Address | undefined): string {
|
||||
const name = (p?.name || p?.email || "?").trim();
|
||||
const words = name.split(/\s+/).filter(Boolean);
|
||||
@@ -187,7 +182,7 @@ export function MessageList({
|
||||
// The badge still counts everyone involved — the name is narrowed, the
|
||||
// "how many people are in this" signal is not.
|
||||
const extra = Math.max(0, thread.participants.length - 1);
|
||||
const avatarBg = `hsl(${hue(primary?.email || primary?.name || "?")} 45% 45%)`;
|
||||
const avatarBg = avatarColor(primary?.email, primary?.name, accounts.data);
|
||||
const stop = (fn: () => void) => (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
fn();
|
||||
|
||||
@@ -9,8 +9,9 @@ import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useT } from "../../i18n";
|
||||
import { avatarColor } from "../../lib/avatarColor";
|
||||
import { api, apiFetch } from "../../lib/api";
|
||||
import type { Address, AttachmentOut, MessageEnvelope, ThreadDetail } from "../../lib/types";
|
||||
import type { AttachmentOut, MessageEnvelope, ThreadDetail } from "../../lib/types";
|
||||
import { AvatarImage } from "../SenderAvatar";
|
||||
import { useIsMobile } from "../../lib/useIsMobile";
|
||||
import { loadBundle } from "../../plugins/bundleLoader";
|
||||
@@ -27,22 +28,17 @@ const AttachmentPreview = lazy(() =>
|
||||
export type PanelTab = "calendar" | "people" | "attachments";
|
||||
|
||||
// ---- small helpers ---------------------------------------------------------
|
||||
const AV_COLORS = ["#ef4444", "#f97316", "#eab308", "#22c55e", "#14b8a6", "#3b82f6", "#8b5cf6", "#ec4899", "#6b7280"];
|
||||
function avatarColor(key: string) {
|
||||
let h = 0;
|
||||
for (const c of key || "?") h = (h * 31 + c.charCodeAt(0)) >>> 0;
|
||||
return AV_COLORS[h % AV_COLORS.length];
|
||||
}
|
||||
function initials(name: string) {
|
||||
const p = (name || "").trim().split(/\s+/).filter(Boolean);
|
||||
if (!p.length) return "?";
|
||||
return ((p[0][0] || "") + (p.length > 1 ? p[p.length - 1][0] : "")).toUpperCase();
|
||||
}
|
||||
function Avatar({ name, email, size = 36 }: { name: string; email: string; size?: number }) {
|
||||
const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
|
||||
return (
|
||||
<span
|
||||
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)}
|
||||
<AvatarImage email={email} />
|
||||
@@ -184,14 +180,23 @@ function PeopleTab({ thread, onInviteAll }: { thread: ThreadDetail | undefined;
|
||||
const [openEmail, setOpenEmail] = useState<string | null>(null);
|
||||
const [openName, setOpenName] = useState("");
|
||||
|
||||
// "In conversation" = unique senders from the open thread.
|
||||
// "In conversation" = everyone in the open thread — who wrote it, and who it
|
||||
// went to (To, Cc, Bcc: e.g. whom a message was forwarded to), the user
|
||||
// included. Each person once, under the name any message gives them, A–Z.
|
||||
const convPeople = useMemo(() => {
|
||||
const seen = new Map<string, Address>();
|
||||
const names = new Map<string, string>();
|
||||
for (const m of thread?.messages ?? []) {
|
||||
const a = m.from_addr[0];
|
||||
if (a?.email && !seen.has(a.email.toLowerCase())) seen.set(a.email.toLowerCase(), a);
|
||||
for (const a of [...m.from_addr, ...m.to_addr, ...m.cc_addr, ...m.bcc_addr]) {
|
||||
const key = a?.email?.toLowerCase();
|
||||
if (!key) continue;
|
||||
// Some messages carry the address itself as the name: that is no name.
|
||||
const name = a.name && a.name.toLowerCase() !== key ? a.name : "";
|
||||
if (!names.get(key)) names.set(key, name);
|
||||
}
|
||||
}
|
||||
return [...seen.values()].map((a) => ({ id: "", display_name: a.name || a.email, primary_email: a.email }));
|
||||
return [...names.entries()]
|
||||
.map(([email, name]) => ({ id: "", display_name: name || email, primary_email: email }))
|
||||
.sort((a, b) => a.display_name.localeCompare(b.display_name, undefined, { sensitivity: "base" }));
|
||||
}, [thread]);
|
||||
|
||||
const groups = useQuery({ queryKey: ["contact-groups"], queryFn: () => apiFetch<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)} />;
|
||||
}
|
||||
|
||||
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][] = [
|
||||
["conversation", t("panel.inConversation")],
|
||||
["all", t("panel.allContacts")],
|
||||
@@ -260,20 +283,10 @@ function PeopleTab({ thread, onInviteAll }: { thread: ThreadDetail | undefined;
|
||||
</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 ? (
|
||||
<div className="px-4 py-6 text-sm text-app-faint">{t("panel.noPeople")}</div>
|
||||
) : (
|
||||
people.map((p, i) => (
|
||||
<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>
|
||||
))
|
||||
people.map((p) => personRow(p))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -316,7 +329,6 @@ function PersonDetail({ email, name, onBack }: { email: string; name: string; on
|
||||
</div>
|
||||
<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.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.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>
|
||||
|
||||
@@ -147,15 +147,17 @@ export function AttachmentPreview({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={printAtt}
|
||||
title={t("mail.print")}
|
||||
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>
|
||||
{kind !== "video" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={printAtt}
|
||||
title={t("mail.print")}
|
||||
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>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={download}
|
||||
@@ -174,11 +176,12 @@ export function AttachmentPreview({
|
||||
<div
|
||||
className="relative min-h-0 flex-1"
|
||||
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();
|
||||
}}
|
||||
>
|
||||
{kind === "image" && <ImageView url={url} zoom={zoom} alt={att.filename} />}
|
||||
{kind === "video" && <VideoView key={att.id} url={url} onDownload={download} />}
|
||||
{kind === "text" && (
|
||||
<TextView url={url} zoom={zoom} onLoaded={(tx) => (textRef.current = tx)} />
|
||||
)}
|
||||
@@ -208,8 +211,8 @@ export function AttachmentPreview({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom controls */}
|
||||
<div className="flex shrink-0 items-center justify-center py-3">
|
||||
{/* Bottom controls (a video has its own, in the player) */}
|
||||
<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">
|
||||
{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 }) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center overflow-auto p-4">
|
||||
|
||||
@@ -6,7 +6,7 @@ import spreadsheetSvgRaw from "../../../icons/spreadsheet.svg?raw";
|
||||
import { useT } from "../../i18n";
|
||||
import { api } from "../../lib/api";
|
||||
import type { AttachmentOut } from "../../lib/types";
|
||||
import { extOf, isPreviewable } from "./previewKind";
|
||||
import { extOf, isPreviewable, VIDEO_EXT } from "./previewKind";
|
||||
|
||||
// These icons carry their own (baked-in) colours via inline styles, so render
|
||||
// them raw rather than through <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 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 }) {
|
||||
const e = extOf(filename);
|
||||
if (VIDEO_EXT.has(e)) {
|
||||
return (
|
||||
<span className="shrink-0 text-violet-500">
|
||||
<VideoGlyph />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (e === "pdf") {
|
||||
return (
|
||||
<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 { useT } from "../../i18n";
|
||||
import { personHue } from "../../lib/avatarColor";
|
||||
import { api } from "../../lib/api";
|
||||
import { printMessages } from "../../lib/print";
|
||||
import { useIsMobile } from "../../lib/useIsMobile";
|
||||
@@ -98,10 +99,7 @@ function initials(msg: MessageEnvelope): string {
|
||||
|
||||
// Stable per-sender hue so a sender's avatar + name share a colour (like the mockup).
|
||||
function hue(msg: MessageEnvelope): number {
|
||||
const key = (msg.from_addr[0]?.email || msg.from_addr[0]?.name || "?").toLowerCase();
|
||||
let h = 0;
|
||||
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) % 360;
|
||||
return h;
|
||||
return personHue(msg.from_addr[0]?.email || msg.from_addr[0]?.name || "?");
|
||||
}
|
||||
|
||||
function fmtRelative(iso: string): string {
|
||||
@@ -493,7 +491,10 @@ function MessageItem({
|
||||
</div>
|
||||
</div>
|
||||
{/* 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">
|
||||
<MessageBody message={message} onMailto={onMailto} />
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// clickability without pulling the heavy preview bundle (lazy-loaded on open).
|
||||
|
||||
const IMAGE_EXT = new Set(["jpg", "jpeg", "png", "gif", "webp"]);
|
||||
// Played in the preview's <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).
|
||||
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 {
|
||||
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 {
|
||||
const e = extOf(name);
|
||||
if (e === "pdf") return "pdf";
|
||||
if (e === "txt") return "text";
|
||||
if (OFFICE_EXT.has(e)) return "office";
|
||||
if (VIDEO_EXT.has(e)) return "video";
|
||||
return "image";
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ export const en = {
|
||||
"mail.expandAll": "Expand all",
|
||||
"mail.collapseAll": "Collapse all",
|
||||
"mail.print": "Print",
|
||||
"mail.previewVideoUnsupported": "This video can't be played in the browser.",
|
||||
"mail.previewPage": "Page {current} / {total}",
|
||||
"mail.previewResetZoom": "Reset zoom",
|
||||
"mail.previewPrev": "Previous",
|
||||
@@ -169,7 +170,6 @@ export const en = {
|
||||
"panel.composeAll": "Compose to all",
|
||||
"panel.inviteAll": "Invite all to new event",
|
||||
"panel.call": "Call",
|
||||
"panel.message": "Message",
|
||||
"panel.compose": "Compose",
|
||||
"panel.invite": "Invite",
|
||||
"panel.contactDetails": "Contact details",
|
||||
|
||||
@@ -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%)`;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { AppSwitcher } from "../components/AppSwitcher";
|
||||
import { ComposeDock } from "../components/compose/ComposeDock";
|
||||
import { Composer, type ComposerInit, draftToInit, localToInit } from "../components/compose/Composer";
|
||||
import { UndoSendToast } from "../components/compose/UndoSendToast";
|
||||
import { Toast } from "../components/Toast";
|
||||
import { usePendingSend } from "../components/compose/undoSend";
|
||||
import {
|
||||
collapseExpanded,
|
||||
@@ -1530,6 +1531,7 @@ export function MailLayout() {
|
||||
fullScreen={mobile}
|
||||
/>
|
||||
<UndoSendToast mobile={mobile} onUndone={reopenUnsent} />
|
||||
<Toast mobile={mobile} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,4 +21,4 @@ calendar = "webmail_plugin_calendar:plugin"
|
||||
packages = ["webmail_plugin_calendar"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
webmail_plugin_calendar = ["ui/*.js", "migrations/versions/*.py"]
|
||||
webmail_plugin_calendar = ["ui/*.js", "ui/*.svg", "migrations/versions/*.py"]
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
A **core, non-removable** plugin (``builtin=True``) that ships a `/calendar` page
|
||||
beside Mail. Its own ``plg_calendar_*`` tables (RLS-isolated) hold calendars and
|
||||
events; recurrence is expanded at query time. Uses the SDK only — zero core diffs.
|
||||
Phase A1 (local events) is the current scope; iMIP (A2) and CalDAV/ICS (A3) build
|
||||
on the same data model and the SDK's vault + multi-method HTTP.
|
||||
Local events (A1), iMIP invitations sent and received (A2) and CalDAV / ICS (A3)
|
||||
share one data model, the SDK's vault and its multi-method HTTP.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.plugins.sdk import JobSpec, Page, PageNav, Plugin, UiManifest
|
||||
from app.plugins.sdk import JobSpec, Page, PageNav, Plugin, Slot, UiManifest
|
||||
from webmail_plugin_calendar.router import router
|
||||
from webmail_plugin_calendar.sync import caldav_sync
|
||||
|
||||
@@ -25,6 +25,9 @@ plugin = Plugin(
|
||||
jobs=[JobSpec(func=caldav_sync, schedule="*/15 * * * *")],
|
||||
router=router,
|
||||
ui_manifest=UiManifest(
|
||||
# An invitation received by email, above the message body (iMIP): renders
|
||||
# only for a message carrying an .ics, so ordinary mail shows nothing.
|
||||
slots=[Slot(name="message.banner", bundle="/api/plugins/calendar/ui/invite.js")],
|
||||
pages=[
|
||||
Page(
|
||||
route="/calendar",
|
||||
|
||||
@@ -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>"
|
||||
'<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. "
|
||||
"Sent by Webmail Calendar.</p>"
|
||||
"Sent by Ultimo Calendar.</p>"
|
||||
"</body></html>"
|
||||
)
|
||||
|
||||
|
||||
@@ -10,10 +10,14 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from html import escape as html_escape
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||
@@ -22,7 +26,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.plugins.sdk import CurrentUserId, require_csrf, sdk
|
||||
from webmail_plugin_calendar import invites
|
||||
from webmail_plugin_calendar import imip, invites
|
||||
from webmail_plugin_calendar.queries import expand_range
|
||||
from webmail_plugin_calendar.schemas import (
|
||||
CalendarIn,
|
||||
@@ -31,6 +35,7 @@ from webmail_plugin_calendar.schemas import (
|
||||
EditFollowingIn,
|
||||
EventIn,
|
||||
EventPatch,
|
||||
InvitationAnswer,
|
||||
SubscribeIn,
|
||||
)
|
||||
|
||||
@@ -473,7 +478,15 @@ async def list_events(
|
||||
"""List events overlapping [start, end). With ``q`` set, only events whose
|
||||
summary/location/description match (case-insensitive) are returned — used by
|
||||
the calendar search panel over a wide date window."""
|
||||
range_start, range_end = _aware(start), _aware(end)
|
||||
async with _SDK.session(user_id) as s:
|
||||
return {"events": await _events_between(s, _aware(start), _aware(end), q)}
|
||||
|
||||
|
||||
async def _events_between(
|
||||
s: AsyncSession, range_start: datetime, range_end: datetime, q: str = ""
|
||||
) -> list[dict[str, object]]:
|
||||
"""Every event occurrence overlapping [range_start, range_end), recurrence
|
||||
expanded (the calendar views, search, and an invitation's conflicts)."""
|
||||
params: dict[str, object] = {"end": range_end}
|
||||
where = "WHERE e.dtstart < :end AND e.dav_deleted = false"
|
||||
term = q.strip()
|
||||
@@ -483,23 +496,22 @@ async def list_events(
|
||||
"OR COALESCE(e.description, '') ILIKE :q)"
|
||||
)
|
||||
params["q"] = f"%{term}%"
|
||||
async with _SDK.session(user_id) as s:
|
||||
rows = (
|
||||
await s.execute(
|
||||
text(
|
||||
"SELECT e.id, e.calendar_id, e.ical_uid, e.summary, e.description, "
|
||||
"e.location, e.dtstart, e.dtend, e.all_day, e.tz, e.rrule, e.reminders, "
|
||||
"e.exdates, e.recurrence_id, e.status, "
|
||||
"e.my_partstat, e.origin_message_id, e.show_as, e.conference_url, "
|
||||
"e.attendees, e.organizer, "
|
||||
"COALESCE(e.color, c.color) AS color "
|
||||
"FROM plg_calendar_events e "
|
||||
"JOIN plg_calendar_calendars c ON c.id = e.calendar_id "
|
||||
f"{where}"
|
||||
),
|
||||
params,
|
||||
)
|
||||
).mappings().all()
|
||||
rows = (
|
||||
await s.execute(
|
||||
text(
|
||||
"SELECT e.id, e.calendar_id, e.ical_uid, e.summary, e.description, "
|
||||
"e.location, e.dtstart, e.dtend, e.all_day, e.tz, e.rrule, e.reminders, "
|
||||
"e.exdates, e.recurrence_id, e.status, "
|
||||
"e.my_partstat, e.origin_message_id, e.show_as, e.conference_url, "
|
||||
"e.attendees, e.organizer, "
|
||||
"COALESCE(e.color, c.color) AS color "
|
||||
"FROM plg_calendar_events e "
|
||||
"JOIN plg_calendar_calendars c ON c.id = e.calendar_id "
|
||||
f"{where}"
|
||||
),
|
||||
params,
|
||||
)
|
||||
).mappings().all()
|
||||
base = [
|
||||
r
|
||||
for r in rows
|
||||
@@ -507,7 +519,7 @@ async def list_events(
|
||||
and (r["rrule"] is not None or (r["dtend"] or r["dtstart"]) > range_start)
|
||||
]
|
||||
overrides = [r for r in rows if r["recurrence_id"] is not None]
|
||||
return {"events": expand_range(base, overrides, range_start, range_end)}
|
||||
return expand_range(base, overrides, range_start, range_end)
|
||||
|
||||
|
||||
async def _insert_event(
|
||||
@@ -882,15 +894,32 @@ async def public_calendar(token: str) -> Response:
|
||||
|
||||
# --- RSVP (public: guests respond from the invitation email, no login) --------
|
||||
|
||||
def _logo_svg() -> str:
|
||||
"""The Ultimo signet, inline (a guest's browser loads nothing else), 56 px wide."""
|
||||
try:
|
||||
svg = (_UI / "ultimo-logo-signet.svg").read_text(encoding="utf-8")
|
||||
except OSError: # never let a missing picture stop the calendar from loading
|
||||
return ""
|
||||
svg = re.sub(r"<\?xml[^>]*\?>|<!--.*?-->", "", svg, flags=re.S).strip()
|
||||
# Inkscape sizes it in mm: let the box decide (the viewBox keeps its shape).
|
||||
return re.sub(r"<svg\b([^>]*?)\swidth=\"[^\"]*\"([^>]*?)\sheight=\"[^\"]*\"",
|
||||
r'<svg\1 width="56"\2 height="54"', svg, count=1)
|
||||
|
||||
|
||||
_LOGO = _logo_svg()
|
||||
|
||||
|
||||
def _rsvp_page(message: str) -> str:
|
||||
"""What a guest sees after clicking Yes / No / Maybe in our invitation."""
|
||||
return (
|
||||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||
"<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;"
|
||||
"color:#3c4043'><div style='max-width:460px;margin:64px auto;background:#fff;"
|
||||
"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>"
|
||||
)
|
||||
|
||||
@@ -929,11 +958,327 @@ async def rsvp(t: str = Query(...), r: str = Query(...)) -> HTMLResponse:
|
||||
return HTMLResponse(_rsvp_page(f"Your response “{invites.RESPONSES[r]}” was recorded."))
|
||||
|
||||
|
||||
# --- invitations received by email (iMIP) -----------------------------------
|
||||
#
|
||||
# The reading pane shows an invitation email as a card (core MessageInvitation):
|
||||
# when, what, where, who organizes, what else is in the calendar then, and
|
||||
# Yes / No / Maybe. Answering adds (or updates, or removes) the event in the
|
||||
# user's calendar and sends the organizer an iTIP REPLY, so their calendar
|
||||
# records the answer too.
|
||||
|
||||
_ANSWERS = ("accepted", "declined", "tentative")
|
||||
|
||||
|
||||
def _declined_key(inv: dict[str, Any]) -> str:
|
||||
"""Where a "No" is remembered: declining takes the event out of the calendar,
|
||||
so the calendar no longer holds the answer the card should show."""
|
||||
rid = inv["recurrence_id"].isoformat() if inv["recurrence_id"] else ""
|
||||
return f"declined:{inv['uid']}:{rid}"
|
||||
|
||||
|
||||
async def _invitation(user_id: uuid.UUID, message_id: uuid.UUID) -> tuple[str, dict[str, Any]]:
|
||||
raw = await _SDK.messages.raw(user_id, message_id)
|
||||
ics = imip.calendar_part(raw) if raw else None
|
||||
inv = imip.parse_invite(ics) if ics else None
|
||||
if ics is None or inv is None or not inv["uid"]:
|
||||
raise HTTPException(status_code=404, detail="no_invitation")
|
||||
return ics, inv
|
||||
|
||||
|
||||
async def _me(user_id: uuid.UUID, inv: dict[str, Any]) -> dict[str, str] | None:
|
||||
"""The user among the invitation's attendees (any of their accounts)."""
|
||||
mine = {e.lower(): n for _a, e, n in await _SDK.accounts.of_user(user_id)}
|
||||
for a in inv["attendees"]:
|
||||
if a["email"] in mine:
|
||||
name = a.get("name") or mine[a["email"]]
|
||||
return {"email": a["email"], "name": name, "partstat": a["partstat"]}
|
||||
return None
|
||||
|
||||
|
||||
async def _invited_copy(s: AsyncSession, inv: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""The event this invitation already put in the user's calendar, if any."""
|
||||
row = (
|
||||
await s.execute(
|
||||
text(
|
||||
"SELECT e.id, e.calendar_id, e.sequence, e.my_partstat, c.kind, c.name "
|
||||
"FROM plg_calendar_events e JOIN plg_calendar_calendars c ON c.id = e.calendar_id "
|
||||
"WHERE e.ical_uid = :uid AND e.recurrence_id IS NOT DISTINCT FROM :rid "
|
||||
"AND e.dav_deleted = false ORDER BY e.sequence DESC LIMIT 1"
|
||||
),
|
||||
{"uid": inv["uid"], "rid": inv["recurrence_id"]},
|
||||
)
|
||||
).mappings().first()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def _invitation_calendar(s: AsyncSession, user_id: uuid.UUID) -> uuid.UUID:
|
||||
"""Where accepted invitations go: Settings → Calendars → Invitations, else
|
||||
the default calendar, else the first one that can be written to."""
|
||||
settings = await _SDK.accounts.settings_of(user_id)
|
||||
await _ensure_default_calendar(s, user_id)
|
||||
rows = (
|
||||
await s.execute(
|
||||
text("SELECT id FROM plg_calendar_calendars WHERE read_only = false ORDER BY name")
|
||||
)
|
||||
).scalars().all()
|
||||
writable = [str(r) for r in rows]
|
||||
for wanted in (settings.get("invitation_calendar"), settings.get("default_calendar")):
|
||||
if wanted and str(wanted) in writable:
|
||||
return uuid.UUID(str(wanted))
|
||||
return uuid.UUID(writable[0])
|
||||
|
||||
|
||||
def _brief(ev: dict[str, Any]) -> dict[str, Any]:
|
||||
return {k: ev.get(k) for k in ("summary", "start", "end", "all_day", "color")}
|
||||
|
||||
|
||||
async def _invitation_view(user_id: uuid.UUID, message_id: uuid.UUID) -> dict[str, Any]:
|
||||
_ics, inv = await _invitation(user_id, message_id)
|
||||
me = await _me(user_id, inv)
|
||||
start, end = inv["dtstart"], inv["dtend"]
|
||||
settings = await _SDK.accounts.settings_of(user_id)
|
||||
try:
|
||||
zone = ZoneInfo(str(settings.get("tz") or inv["tz"]))
|
||||
except (ZoneInfoNotFoundError, ValueError):
|
||||
zone = ZoneInfo("UTC")
|
||||
day_start = start.astimezone(zone).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
async with _SDK.session(user_id) as s:
|
||||
copy = await _invited_copy(s, inv)
|
||||
around = await _events_between(s, day_start, max(end, day_start + timedelta(days=1)))
|
||||
others = [
|
||||
e
|
||||
for e in around
|
||||
if e.get("ical_uid") != inv["uid"]
|
||||
and e.get("status") != "cancelled"
|
||||
and e.get("my_partstat") != "declined"
|
||||
]
|
||||
|
||||
def times(e: dict[str, Any]) -> tuple[datetime, datetime]:
|
||||
return datetime.fromisoformat(str(e["start"])), datetime.fromisoformat(str(e["end"]))
|
||||
|
||||
# What the user is already busy with then (timed events, unless this one is
|
||||
# all-day), and else the event just before it that day.
|
||||
conflicts = [
|
||||
_brief(e)
|
||||
for e in others
|
||||
if e.get("show_as") != "free"
|
||||
and (inv["all_day"] or not e.get("all_day"))
|
||||
and times(e)[0] < end
|
||||
and times(e)[1] > start
|
||||
]
|
||||
earlier = sorted(
|
||||
(e for e in others if not e.get("all_day") and times(e)[1] <= start),
|
||||
key=lambda e: times(e)[1],
|
||||
)
|
||||
declined = await _SDK.kv(user_id).get(_declined_key(inv))
|
||||
response = (
|
||||
(copy or {}).get("my_partstat")
|
||||
or ("declined" if declined else None)
|
||||
or (me or {}).get("partstat")
|
||||
or "needs-action"
|
||||
)
|
||||
return {
|
||||
"method": inv["method"],
|
||||
"uid": inv["uid"],
|
||||
"summary": inv["summary"],
|
||||
"description": inv["description"],
|
||||
"location": inv["location"],
|
||||
"conference_url": inv["conference_url"],
|
||||
"start": start.isoformat(),
|
||||
"end": end.isoformat(),
|
||||
"all_day": inv["all_day"],
|
||||
"tz": inv["tz"],
|
||||
"recurring": inv["rrule"] is not None,
|
||||
"status": inv["status"],
|
||||
"organizer": inv["organizer"],
|
||||
"attendees": inv["attendees"],
|
||||
"me": {"email": me["email"], "name": me["name"]} if me else None,
|
||||
"response": response,
|
||||
"in_calendar": (
|
||||
{"event_id": str(copy["id"]), "calendar": copy["name"]} if copy else None
|
||||
),
|
||||
# The calendar holds a newer version (the organizer changed it since).
|
||||
"outdated": bool(copy and int(copy["sequence"]) > inv["sequence"]),
|
||||
"conflicts": conflicts,
|
||||
"previous": _brief(earlier[-1]) if earlier and not conflicts else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/invitations/{message_id}")
|
||||
async def get_invitation(message_id: uuid.UUID, user_id: CurrentUserId) -> dict[str, Any]:
|
||||
"""The invitation in an email, as the reading pane shows it; 404 if none."""
|
||||
return await _invitation_view(user_id, message_id)
|
||||
|
||||
|
||||
def _default_reminders(settings: dict[str, Any]) -> list[int]:
|
||||
out = []
|
||||
for r in settings.get("default_reminders_timed") or []:
|
||||
minutes = r.get("minutes") if isinstance(r, dict) else r
|
||||
if isinstance(minutes, int):
|
||||
out.append(minutes)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/invitations/{message_id}/respond", dependencies=[Depends(require_csrf)])
|
||||
async def respond_to_invitation(
|
||||
message_id: uuid.UUID, body: InvitationAnswer, user_id: CurrentUserId
|
||||
) -> dict[str, Any]:
|
||||
"""Answer an invitation: Yes / Maybe put it in the calendar (or update it),
|
||||
No takes it out; then the organizer gets the iTIP REPLY."""
|
||||
if body.response not in _ANSWERS:
|
||||
raise HTTPException(status_code=400, detail="bad_response")
|
||||
ics, inv = await _invitation(user_id, message_id)
|
||||
if inv["method"] != "REQUEST":
|
||||
raise HTTPException(status_code=400, detail="not_a_request")
|
||||
me = await _me(user_id, inv)
|
||||
if me is None:
|
||||
raise HTTPException(status_code=400, detail="not_invited")
|
||||
attendees = [
|
||||
{**a, "partstat": body.response} if a["email"] == me["email"] else a
|
||||
for a in inv["attendees"]
|
||||
]
|
||||
dav_calendar: str | None = None
|
||||
async with _SDK.session(user_id) as s:
|
||||
copy = await _invited_copy(s, inv)
|
||||
if body.response == "declined":
|
||||
if copy is not None:
|
||||
await s.execute(
|
||||
text(
|
||||
"UPDATE plg_calendar_events SET dav_deleted = true WHERE id = :id"
|
||||
if copy["kind"] == "caldav"
|
||||
else "DELETE FROM plg_calendar_events WHERE id = :id"
|
||||
),
|
||||
{"id": str(copy["id"])},
|
||||
)
|
||||
dav_calendar = str(copy["calendar_id"]) if copy["kind"] == "caldav" else None
|
||||
elif copy is not None and int(copy["sequence"]) > inv["sequence"]:
|
||||
# An older copy of the invitation: keep the newer event, just answer.
|
||||
await s.execute(
|
||||
text("UPDATE plg_calendar_events SET my_partstat = :p WHERE id = :id"),
|
||||
{"p": body.response, "id": str(copy["id"])},
|
||||
)
|
||||
dav_calendar = str(copy["calendar_id"]) if copy["kind"] == "caldav" else None
|
||||
else:
|
||||
fields = {
|
||||
"sum": inv["summary"],
|
||||
"desc": inv["description"],
|
||||
"loc": inv["location"],
|
||||
"start": inv["dtstart"],
|
||||
"end": inv["dtend"],
|
||||
"allday": inv["all_day"],
|
||||
"tz": inv["tz"],
|
||||
"rrule": inv["rrule"],
|
||||
"rid": inv["recurrence_id"],
|
||||
"status": "cancelled" if inv["status"] == "cancelled" else "confirmed",
|
||||
"org": json.dumps(inv["organizer"]) if inv["organizer"] else None,
|
||||
"att": json.dumps(attendees),
|
||||
"mine": body.response,
|
||||
"seq": inv["sequence"],
|
||||
"raw": ics,
|
||||
"msg": str(message_id),
|
||||
"conf": inv["conference_url"],
|
||||
"showas": inv["show_as"],
|
||||
}
|
||||
if copy is not None:
|
||||
await s.execute(
|
||||
text(
|
||||
"UPDATE plg_calendar_events SET summary = :sum, description = :desc, "
|
||||
"location = :loc, dtstart = :start, dtend = :end, all_day = :allday, "
|
||||
"tz = :tz, rrule = :rrule, status = :status, "
|
||||
"organizer = CAST(:org AS jsonb), attendees = CAST(:att AS jsonb), "
|
||||
"my_partstat = :mine, sequence = :seq, raw_ical = :raw, "
|
||||
"origin_message_id = :msg, conference_url = :conf, show_as = :showas "
|
||||
"WHERE id = :id"
|
||||
),
|
||||
{**fields, "id": str(copy["id"])},
|
||||
)
|
||||
dav_calendar = str(copy["calendar_id"]) if copy["kind"] == "caldav" else None
|
||||
else:
|
||||
calendar_id = await _invitation_calendar(s, user_id)
|
||||
settings = await _SDK.accounts.settings_of(user_id)
|
||||
await s.execute(
|
||||
text(
|
||||
"INSERT INTO plg_calendar_events "
|
||||
"(user_id, calendar_id, ical_uid, summary, description, location, "
|
||||
" dtstart, dtend, all_day, tz, rrule, recurrence_id, status, organizer, "
|
||||
" attendees, my_partstat, sequence, raw_ical, origin_message_id, "
|
||||
" conference_url, show_as, reminders) "
|
||||
"VALUES (:u, :cal, :uid, :sum, :desc, :loc, :start, :end, :allday, :tz, "
|
||||
" :rrule, :rid, :status, CAST(:org AS jsonb), CAST(:att AS jsonb), :mine, "
|
||||
" :seq, :raw, :msg, :conf, :showas, :rem)"
|
||||
),
|
||||
{
|
||||
**fields,
|
||||
"u": str(user_id),
|
||||
"cal": str(calendar_id),
|
||||
"uid": inv["uid"],
|
||||
"rem": _default_reminders(settings),
|
||||
},
|
||||
)
|
||||
if await _is_caldav(s, calendar_id):
|
||||
dav_calendar = str(calendar_id)
|
||||
if dav_calendar:
|
||||
await _enqueue_dav_sync(uuid.UUID(dav_calendar))
|
||||
kv = _SDK.kv(user_id)
|
||||
if body.response == "declined":
|
||||
await kv.set(_declined_key(inv), True)
|
||||
else:
|
||||
await kv.delete(_declined_key(inv))
|
||||
|
||||
# Tell the organizer — unless the user organized it themselves.
|
||||
organizer = inv["organizer"]
|
||||
reply_sent = False
|
||||
if organizer and organizer["email"] != me["email"]:
|
||||
who = me["name"] or me["email"]
|
||||
word = {"accepted": "accepted", "declined": "declined", "tentative": "tentatively accepted"}
|
||||
sentence = f"{who} has {word[body.response]} this invitation."
|
||||
try:
|
||||
await _SDK.mail.send(
|
||||
user_id,
|
||||
to=[organizer["email"]],
|
||||
subject=imip.reply_subject(body.response, inv["summary"]),
|
||||
html=f"<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 ------------------------------------------------------------------
|
||||
|
||||
@router.get("/ui/{filename}")
|
||||
async def bundle(filename: str) -> FileResponse:
|
||||
if filename != "page.js":
|
||||
if filename not in ("page.js", "invite.js"):
|
||||
raise HTTPException(status_code=404)
|
||||
# `no-cache` = the browser must revalidate before reusing (ETag makes a 304 cheap),
|
||||
# so an edited bundle is picked up on the next page reload instead of a stale copy.
|
||||
|
||||
@@ -78,3 +78,9 @@ class EventPatch(BaseModel):
|
||||
color: str | None = None
|
||||
show_as: str | None = None
|
||||
conference_url: str | None = None
|
||||
|
||||
|
||||
class InvitationAnswer(BaseModel):
|
||||
"""Yes / No / Maybe to an emailed invitation (iTIP PARTSTAT)."""
|
||||
|
||||
response: str # accepted | declined | tentative
|
||||
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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>
|
||||
<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="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>`;
|
||||
// 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 = [
|
||||
["Blue", "#3b82f6"], ["Green", "#22c55e"], ["Red", "#ef4444"], ["Orange", "#f97316"],
|
||||
@@ -335,6 +338,9 @@ const CSS = `
|
||||
.cal-agenda .day .dt{width:44px;flex:0 0 auto;font-size:12px;color:rgb(var(--app-muted))}
|
||||
.cal-agenda .day .dt b{display:block;font-size:18px;color:rgb(var(--app-fg))}
|
||||
.cal-agenda .none{color:rgb(var(--app-faint));font-size:13px}
|
||||
.cal-agenda .day.empty{cursor:pointer}
|
||||
.cal-agenda .day.empty:hover{background:rgb(var(--app-hover))}
|
||||
.cal-agenda .day.empty:hover .none{color:rgb(var(--app-accent))}
|
||||
/* new-event form (floating box) */
|
||||
/* Content-sized box: grows in height as sections are added, scrolls only past the
|
||||
viewport cap; the Save/Cancel footer sticks to the bottom while scrolling. */
|
||||
@@ -935,6 +941,19 @@ export default function mount(el, ctx, props) {
|
||||
box.innerHTML = videoLinkHtml(link);
|
||||
const copyBtn = box.querySelector("[data-act=copy-jitsi]");
|
||||
if (copyBtn) copyBtn.addEventListener("click", () => copyLink(link, copyBtn));
|
||||
const removeBtn = box.querySelector("[data-act=remove-video]");
|
||||
if (removeBtn) removeBtn.addEventListener("click", clearVideo);
|
||||
}
|
||||
// ✕ on the link: no video call — and none re-added when another guest is invited.
|
||||
function clearVideo() {
|
||||
if (!state.composing) return;
|
||||
state.composing.conference = "";
|
||||
state.composing.videoRemoved = true;
|
||||
const box = el.querySelector("[data-video]");
|
||||
if (!box) return;
|
||||
box.innerHTML = ADD_VIDEO_HTML;
|
||||
const add = box.querySelector("[data-act=add-jitsi]");
|
||||
if (add) add.addEventListener("click", () => fillVideo(jitsiLink(state.jitsi)));
|
||||
}
|
||||
function copyLink(link, btn) {
|
||||
try { navigator.clipboard.writeText(link); } catch { /* ignore */ }
|
||||
@@ -968,7 +987,9 @@ export default function mount(el, ctx, props) {
|
||||
guests.appendChild(span);
|
||||
// Adding a NEW guest auto-provisions a video call if none exists yet
|
||||
// (skipped when restoring an existing event's guests during edit).
|
||||
if (autoVideo && !state.composing.conference) fillVideo(jitsiLink(state.jitsi));
|
||||
if (autoVideo && !state.composing.conference && !state.composing.videoRemoved) {
|
||||
fillVideo(jitsiLink(state.jitsi));
|
||||
}
|
||||
}
|
||||
inp.value = "";
|
||||
acpop.style.display = "none";
|
||||
@@ -1296,7 +1317,7 @@ export default function mount(el, ctx, props) {
|
||||
<h4>Video call</h4>
|
||||
<div data-video>${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>
|
||||
<div data-reminders></div>
|
||||
<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);
|
||||
if (!short && d.getMonth() !== start.getMonth()) break;
|
||||
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>`
|
||||
}</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("signout", async () => { await api.fetch("/auth/logout", { method: "POST" }); ctx.navigate("/login"); });
|
||||
act("new", () => openForm(null));
|
||||
on("[data-newday]", "click", (e) => {
|
||||
// That day, at the current time of day (as clicking a day in Month view).
|
||||
const [y, m, dd] = e.currentTarget.dataset.newday.split("-").map(Number);
|
||||
const now = new Date();
|
||||
openForm(new Date(y, m - 1, dd, now.getHours(), now.getMinutes()));
|
||||
});
|
||||
act("today", () => { state.anchor = startOfDay(new Date()); state.mini = startOfMonth(state.anchor); reload(); });
|
||||
act("prev", () => go(-1));
|
||||
act("next", () => go(1));
|
||||
@@ -1710,6 +1740,7 @@ export default function mount(el, ctx, props) {
|
||||
act("add-jitsi", () => fillVideo(jitsiLink(state.jitsi)));
|
||||
// Copy handler for a link rendered on initial form paint (edit mode).
|
||||
act("copy-jitsi", (e) => copyLink(state.composing.conference, e.currentTarget));
|
||||
act("remove-video", clearVideo);
|
||||
act("add-reminder", () => {
|
||||
const box = el.querySelector("[data-reminders]");
|
||||
const wrap = document.createElement("div");
|
||||
@@ -1789,7 +1820,16 @@ export default function mount(el, ctx, props) {
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
|
||||
// An invitation answered in Mail (calendar · invite.js) changed the calendar.
|
||||
// Not while a form is open: re-rendering would throw away what is being typed.
|
||||
const onChanged = async () => {
|
||||
await loadEvents();
|
||||
if (!state.composing && !state.confirm) render();
|
||||
};
|
||||
window.addEventListener("webmail:calendar-changed", onChanged);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("webmail:calendar-changed", onChanged);
|
||||
el.innerHTML = "";
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user