M4: Calendar plugin (builtin) + mail HTML/attachment fixes

Calendar plugin (webmail_plugin_calendar, non-removable core plugin):
- Local calendars/events CRUD with RLS isolation; recurrence expanded at
  query time (DST-correct), EXDATE + detached-override merge.
- Calendar app bundle: month/week/day/3-day views, mini-month, event form
  (color, show-as, timezone combobox, repeat, reminders, guests w/ recipient
  autocomplete, Jitsi video call), event popover with RSVP badges, resizable/
  collapsible left panel + theme toggle, overlap layout.
- iMIP invites: sdk.mail sends REQUEST from the organizer's mailbox (HTML +
  text/calendar + invite.ics); public /rsvp endpoint (vault-sealed token)
  records accepted/declined/tentative per attendee.
- "This and following" edit + scoped delete (this / this-and-following), with
  a delete confirmation dialog for recurring events.

Core:
- Settings: time_format (12/24h, default 24h) + jitsi_server; new Integrations
  section; date order follows time format (en-GB vs en-US) across Mail.
- Plugin SDK: mail (send from a user's account), vault (envelope encrypt),
  multi-method SSRF-guarded http; AppSwitcher product switcher; full-bleed
  PluginPage.

Mail HTML rendering:
- Sanitizer allows <style> blocks + class (CSS sanitized: drop script-like,
  strip @import/url() when remote images blocked); SANITIZER_VERSION 3 so
  already-indexed mail re-sanitizes on view.
- Attachments: a Content-Disposition: attachment part is a real attachment
  even when it also carries a Content-ID (fixes hidden invite.ics).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 16:33:34 +02:00
co-authored by Claude Opus 4.8
parent d46ad13b7b
commit 8c634f7967
56 changed files with 3308 additions and 37 deletions
+6
View File
@@ -31,3 +31,9 @@ frontend/dist/
.DS_Store
.idea/
.vscode/
# Editor backups
*~
# Local session helper (Claude Code resume command)
/claude-session.txt
+49
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import asyncio
import ipaddress
import socket
from dataclasses import dataclass
from urllib.parse import urlsplit
import httpx
@@ -87,3 +88,51 @@ async def guarded_get(url: str) -> str | None:
raise SsrfError("response exceeds 256 KB cap")
chunks.append(chunk)
return b"".join(chunks).decode("utf-8", "replace")
@dataclass(frozen=True)
class GuardedResponse:
status: int
headers: dict[str, str]
text: str
async def guarded_request(
method: str,
url: str,
*,
headers: dict[str, str] | None = None,
content: bytes | str | None = None,
auth: tuple[str, str] | None = None,
timeout: float = 15.0, # noqa: ASYNC109 - passed straight to httpx's own timeout
max_bytes: int = 25 * 1024 * 1024,
) -> GuardedResponse:
"""Arbitrary-method HTTPS request under the same SSRF policy as :func:`guarded_get`.
Used by plugins that speak WebDAV/CalDAV/CardDAV (PROPFIND/REPORT/PUT/DELETE) or
fetch ICS feeds. HTTPS-only, public host required, no off-domain redirects, body
capped at ``max_bytes``. ``auth`` is an (user, password) basic-auth pair. Returns
status + headers + body (all statuses; the caller decides what's an error)."""
parts = urlsplit(url)
if parts.scheme != "https":
raise SsrfError("only https is permitted")
if not parts.hostname:
raise SsrfError("missing host")
await assert_host_is_public(parts.hostname)
async with (
httpx.AsyncClient(follow_redirects=False, timeout=timeout, auth=auth) as client,
client.stream(method, url, headers=headers, content=content) as resp,
):
chunks: list[bytes] = []
total = 0
async for chunk in resp.aiter_bytes():
total += len(chunk)
if total > max_bytes:
raise SsrfError("response exceeds size cap")
chunks.append(chunk)
return GuardedResponse(
status=resp.status_code,
headers=dict(resp.headers),
text=b"".join(chunks).decode("utf-8", "replace"),
)
+11
View File
@@ -18,17 +18,22 @@ router = APIRouter(prefix="/settings", tags=["settings"])
DbSession = Annotated[AsyncSession, Depends(get_session)]
_ALLOWED_ORDER = {"asc", "desc"}
_ALLOWED_TIME_FORMAT = {"12", "24"}
class SettingsOut(BaseModel):
thread_order: str = "asc"
tz: str = "UTC"
time_format: str = "24" # 12 = AM/PM, 24 = 24-hour (default); applies app-wide
jitsi_server: str = "" # blank => https://meet.jit.si/
allowed_image_senders: list[str] = []
class SettingsPatch(BaseModel):
thread_order: str | None = None
tz: str | None = None
time_format: str | None = None
jitsi_server: str | None = None
class QuotaOut(BaseModel):
@@ -56,6 +61,8 @@ def _settings_out(user: User) -> SettingsOut:
return SettingsOut(
thread_order=str(s.get("thread_order", "asc")),
tz=str(s.get("tz", "UTC")),
time_format=str(s.get("time_format", "24")),
jitsi_server=str(s.get("jitsi_server", "")),
allowed_image_senders=sorted(allowed_image_senders(user)),
)
@@ -81,6 +88,10 @@ async def patch_settings(body: SettingsPatch, user: CurrentUser, db: DbSession)
updates["thread_order"] = body.thread_order
if body.tz is not None:
updates["tz"] = body.tz
if body.time_format is not None and body.time_format in _ALLOWED_TIME_FORMAT:
updates["time_format"] = body.time_format
if body.jitsi_server is not None:
updates["jitsi_server"] = body.jitsi_server.strip()
user.settings = updates
await db.commit()
return _settings_out(user)
+9 -2
View File
@@ -98,16 +98,23 @@ def _extract_attachments(msg: EmailMessage) -> list[ParsedAttachment]:
continue
cid = part.get("Content-ID")
filename = part.get_filename()
if not (filename or cid or part.get_content_disposition() == "attachment"):
disposition = part.get_content_disposition()
if not (filename or cid or disposition == "attachment"):
continue
payload = part.get_payload(decode=True)
if not isinstance(payload, bytes):
continue
# A part is an INLINE resource (cid: image rendered in-body, hidden from the
# attachment list) only when it carries a Content-ID AND is not explicitly an
# attachment. A part marked `Content-Disposition: attachment` is a real
# attachment even when it also has a Content-ID (e.g. Fastmail's invite.ics),
# so we drop the cid for it and it shows as a downloadable chip.
inline = cid is not None and disposition != "attachment"
out.append(
ParsedAttachment(
filename=filename or "attachment",
mime_type=part.get_content_type(),
content_id=cid.strip("<>") if cid else None,
content_id=cid.strip("<>") if (inline and cid is not None) else None,
payload=payload,
)
)
+36 -1
View File
@@ -17,7 +17,7 @@ import nh3
# Bump when the sanitization policy changes; body_parts.sanitizer_v records it so a
# render re-sanitizes stale content from the raw blob (SPEC §10.4).
SANITIZER_VERSION = 2
SANITIZER_VERSION = 3
_ALLOWED_TAGS: set[str] = {
# structure
@@ -76,6 +76,7 @@ _ALLOWED_TAGS: set[str] = {
"span",
"strike",
"strong",
"style",
"sub",
"summary",
"sup",
@@ -97,6 +98,7 @@ _ALLOWED_TAGS: set[str] = {
# Attributes allowed on any tag (presentational email formatting).
_GENERIC_ATTRS: set[str] = {
"style",
"class",
"align",
"valign",
"dir",
@@ -119,6 +121,7 @@ _TAG_SPECIFIC: dict[str, set[str]] = {
"th": {"colspan", "rowspan", "scope"},
"table": {"cellpadding", "cellspacing", "border"},
"font": {"face", "color", "size"},
"style": {"type", "media"},
}
# nh3 has no "generic attributes" option, so expand the generic set onto every tag.
@@ -132,6 +135,12 @@ _STYLE_DANGEROUS = re.compile(
r"(expression\s*\(|javascript:|vbscript:|@import|-moz-binding|behavior\s*:)", re.IGNORECASE
)
_STYLE_URL = re.compile(r"url\s*\([^)]*\)", re.IGNORECASE)
# Script-like CSS that voids a whole <style> block (unlike @import, which we can strip).
_STYLE_BLOCK_DANGEROUS = re.compile(
r"(expression\s*\(|javascript:|vbscript:|-moz-binding|behavior\s*:)", re.IGNORECASE
)
_STYLE_IMPORT = re.compile(r"@import[^;]*;?", re.IGNORECASE)
_STYLE_ELEMENT = re.compile(r"(<style\b[^>]*>)(.*?)(</style>)", re.IGNORECASE | re.DOTALL)
@dataclass(frozen=True)
@@ -150,6 +159,28 @@ def _sanitize_style(value: str, allow_remote: bool) -> str | None:
return value
def _sanitize_style_elements(html: str, allow_remote: bool, state: dict[str, bool]) -> str:
"""Sanitize CSS inside ``<style>`` blocks (nh3 keeps the tag but not its text).
Many mailers (e.g. Fastmail's calendar invites) put all layout in a ``<style>``
block of class rules — dropping it loses tables/buttons entirely. We keep the
block but neutralize script-like CSS and, unless remote images are approved,
strip ``@import``/``url()`` so it can't fetch remote/tracking resources.
"""
def repl(match: re.Match[str]) -> str:
css = match.group(2)
if _STYLE_BLOCK_DANGEROUS.search(css):
return "" # nuke the block entirely
css = _STYLE_IMPORT.sub("", css)
if not allow_remote and _STYLE_URL.search(css):
state["has_remote"] = True
css = _STYLE_URL.sub("none", css)
return f"{match.group(1)}{css}{match.group(3)}"
return _STYLE_ELEMENT.sub(repl, html)
def sanitize_html(
html: str, message_id: uuid.UUID, *, allow_remote_images: bool = False
) -> SanitizeResult:
@@ -186,8 +217,12 @@ def sanitize_html(
url_schemes=_URL_SCHEMES,
link_rel="noopener noreferrer nofollow",
strip_comments=True,
# nh3 strips <script>/<style> *content* by default; we allow <style> (above)
# and sanitize its CSS ourselves, so only script content is stripped here.
clean_content_tags={"script"},
attribute_filter=attribute_filter,
)
cleaned = _sanitize_style_elements(cleaned, allow_remote_images, state)
return SanitizeResult(
html=cleaned,
has_remote_images=state["has_remote"],
+41
View File
@@ -0,0 +1,41 @@
"""SSRF-guarded, multi-method HTTP client exposed to plugins (SPEC §10.6).
``sdk.http_get`` covers simple GETs; heavy plugins that speak CalDAV/CardDAV need
other verbs (PROPFIND/REPORT/PUT/DELETE) with request bodies and basic auth. This
facade routes all of them through the same SSRF policy (HTTPS-only, public host,
no off-domain redirects, size cap) and returns status + headers + body.
"""
from __future__ import annotations
from app.accounts.ssrf import GuardedResponse, guarded_request
class PluginHttp:
async def request(
self,
method: str,
url: str,
*,
headers: dict[str, str] | None = None,
content: bytes | str | None = None,
auth: tuple[str, str] | None = None,
) -> GuardedResponse:
return await guarded_request(
method, url, headers=headers, content=content, auth=auth
)
async def get(self, url: str, **kw: object) -> GuardedResponse:
return await self.request("GET", url, **kw) # type: ignore[arg-type]
async def put(self, url: str, **kw: object) -> GuardedResponse:
return await self.request("PUT", url, **kw) # type: ignore[arg-type]
async def delete(self, url: str, **kw: object) -> GuardedResponse:
return await self.request("DELETE", url, **kw) # type: ignore[arg-type]
async def propfind(self, url: str, **kw: object) -> GuardedResponse:
return await self.request("PROPFIND", url, **kw) # type: ignore[arg-type]
async def report(self, url: str, **kw: object) -> GuardedResponse:
return await self.request("REPORT", url, **kw) # type: ignore[arg-type]
+113
View File
@@ -0,0 +1,113 @@
"""Send an email *as the user*, from one of their own accounts (SPEC §11 SDK).
Heavy plugins (calendar iMIP invites, later contacts) need to send a message
through the organizer's real mailbox — reusing the same SMTP path as the compose
outbox, but with a plugin-built body (e.g. a ``text/calendar; method=REQUEST``
part). This helper looks up the user's account, assembles the MIME, and sends via
the account's provider. It is exposed as ``sdk(name).mail`` so plugins never touch
``app.providers`` / ``app.sync`` directly.
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import format_datetime, make_msgid
from sqlalchemy import select
from app.db import session_scope
from app.models import Account
from app.sync.provider_factory import build_provider
class PluginMailError(Exception):
"""No usable account, or the send failed."""
class PluginMail:
"""Send outbound mail from one of a user's own accounts."""
async def send(
self,
user_id: uuid.UUID,
*,
to: list[str],
subject: str,
html: str,
text: str,
from_email: str | None = None,
ics: str | None = None,
ics_method: str = "REQUEST",
ics_filename: str = "invite.ics",
) -> None:
"""Send an email to ``to`` from the user's account.
``from_email`` selects which of the user's accounts to send from (falls
back to their first account). When ``ics`` is given it is added both as a
``text/calendar; method=<ics_method>`` alternative (so the recipient's
client shows its native RSVP UI) and as a downloadable attachment.
"""
recipients = [r for r in (to or []) if r]
if not recipients:
return
async with session_scope() as session:
account = None
if from_email:
account = await session.scalar(
select(Account).where(
Account.user_id == user_id,
Account.email == from_email,
)
)
if account is None:
account = await session.scalar(
select(Account).where(Account.user_id == user_id).order_by(Account.created_at)
)
if account is None:
raise PluginMailError("no account to send from")
raw = _build_message(
account, recipients, subject, html, text, ics, ics_method, ics_filename
)
envelope_from = account.email
provider = build_provider(account)
await provider.send(raw, envelope_from, recipients)
def _build_message(
account: Account,
to: list[str],
subject: str,
html: str,
text: str,
ics: str | None,
ics_method: str,
ics_filename: str,
) -> bytes:
root = MIMEMultipart("mixed")
from_name = account.display_name or ""
root["From"] = f"{from_name} <{account.email}>" if from_name else account.email
root["To"] = ", ".join(to)
root["Subject"] = subject
root["Date"] = format_datetime(datetime.now(UTC))
root["Message-ID"] = make_msgid(domain=account.email.rsplit("@", 1)[-1])
alt = MIMEMultipart("alternative")
alt.attach(MIMEText(text, "plain", "utf-8"))
alt.attach(MIMEText(html, "html", "utf-8"))
if ics is not None:
cal = MIMEText(ics, "calendar", "utf-8")
cal.set_param("method", ics_method)
cal.set_param("component", "VEVENT")
alt.attach(cal)
root.attach(alt)
if ics is not None:
att = MIMEApplication(ics.encode("utf-8"), _subtype="ics")
att.add_header("Content-Disposition", "attachment", filename=ics_filename)
root.attach(att)
return root.as_bytes()
+21
View File
@@ -29,7 +29,9 @@ from app.models import User
from app.outbox import service as outbox_service
from app.plugins.actions import PluginActions, PluginMessages
from app.plugins.db import plugin_session
from app.plugins.http import PluginHttp
from app.plugins.kv import PluginKvStore
from app.plugins.mail import PluginMail
from app.plugins.types import (
Hook,
JobFunc,
@@ -40,6 +42,7 @@ from app.plugins.types import (
Slot,
UiManifest,
)
from app.plugins.vault import PluginVault
from app.queue import enqueue_job
__all__ = [
@@ -122,6 +125,11 @@ class PluginSdk:
return outbox_service
# --- messages -----------------------------------------------------------
@property
def mail(self) -> PluginMail:
"""Send an email from one of the user's own accounts (iMIP invites, …)."""
return PluginMail()
@property
def messages(self) -> PluginMessages:
"""Read a user's message envelope (subject/sender/flags) for matching."""
@@ -137,6 +145,19 @@ class PluginSdk:
"""GET ``url`` through the §10.6 SSRF guard (blocks private IPs/oversize)."""
return await guarded_get(url)
@property
def http(self) -> PluginHttp:
"""Multi-method SSRF-guarded HTTP (GET/PUT/DELETE/PROPFIND/REPORT) for
CalDAV/CardDAV/ICS. Returns status + headers + body."""
return PluginHttp()
# --- secret vault -------------------------------------------------------
@property
def vault(self) -> PluginVault:
"""Envelope encrypt/decrypt for a plugin's own ``secret_ct`` columns
(CalDAV/CardDAV credentials, tokens) — §10.2 at-rest protection."""
return PluginVault()
def sdk(name: str) -> PluginSdk:
"""Return the SDK facade bound to plugin ``name`` (SPEC §11)."""
+21
View File
@@ -0,0 +1,21 @@
"""Secret encryption exposed to plugins (SPEC §10.2 vault).
Heavy plugins (calendar/contacts) store third-party credentials — CalDAV/CardDAV
passwords, ICS tokens — in their own ``secret_ct bytea`` columns. They encrypt via
this facade so those secrets are protected at rest with the same envelope scheme as
core account credentials, without importing ``app.security`` directly.
"""
from __future__ import annotations
from app.security.crypto import decrypt_secret, encrypt_secret
class PluginVault:
def encrypt(self, plaintext: bytes) -> bytes:
"""Envelope-encrypt ``plaintext`` for storage in a ``secret_ct`` column."""
return encrypt_secret(plaintext)
def decrypt(self, ciphertext: bytes) -> bytes:
"""Reverse :meth:`encrypt`. Raises on a wrong/rotated master key."""
return decrypt_secret(ciphertext)
+26
View File
@@ -126,6 +126,32 @@ def test_inline_image_nested_in_related_is_extracted() -> None:
assert parsed.has_attachments is False
def test_attachment_with_content_id_is_a_real_attachment() -> None:
# iMIP invites (e.g. Fastmail) send invite.ics as Content-Disposition: attachment
# yet also give it a Content-ID. It must show as a downloadable chip, not be
# hidden as an inline resource (regression: "no attachments" shown).
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
root = MIMEMultipart("mixed")
root["Subject"] = "invite"
root["From"] = "org@example.com"
root["To"] = "me@example.com"
root["Message-ID"] = "<inv@x.com>"
root.attach(MIMEText("body", "plain"))
ics = MIMEText("BEGIN:VCALENDAR\nEND:VCALENDAR", "calendar")
ics.add_header("Content-Disposition", "attachment", filename="invite.ics")
ics.add_header("Content-ID", "<cal123@x>")
root.attach(ics)
parsed = parse_message(root.as_bytes())
assert len(parsed.attachments) == 1
att = parsed.attachments[0]
assert att.filename == "invite.ics"
assert att.content_id is None # not treated as inline despite the Content-ID
assert parsed.has_attachments is True
def test_missing_message_id_returns_none() -> None:
msg = EmailMessage()
msg["Subject"] = "no id"
+32
View File
@@ -54,3 +54,35 @@ def test_javascript_uri_is_stripped() -> None:
def test_sanitizer_version_reported() -> None:
result = sanitize_html("<p>hi</p>", MID)
assert result.sanitizer_v >= 1
def test_style_block_and_classes_are_kept() -> None:
# Mailers (e.g. Fastmail invites) put layout in a <style> block + classes.
html = '<style>.banner{color:#333}</style><div class="banner">hi</div>'
result = sanitize_html(html, MID)
assert "<style" in result.html.lower()
assert ".banner" in result.html
assert 'class="banner"' in result.html
def test_style_block_script_css_is_dropped() -> None:
html = '<style>.x{width:expression(alert(1))}</style><p class="x">hi</p>'
result = sanitize_html(html, MID)
assert "expression(" not in result.html.lower()
# the block is nuked but the element/class survive
assert 'class="x"' in result.html
def test_style_block_import_and_url_stripped_when_remote_blocked() -> None:
html = "<style>@import url('http://evil/x.css'); .b{background:url(http://evil/p.gif)}</style>"
result = sanitize_html(html, MID)
assert "@import" not in result.html.lower()
assert "evil" not in result.html.lower()
assert result.has_remote_images
def test_script_content_still_stripped_with_style_allowed() -> None:
html = "<script>alert(1)</script><style>.a{color:red}</style>"
result = sanitize_html(html, MID)
assert "alert(1)" not in result.html
assert ".a{color:red}" in result.html
@@ -0,0 +1,170 @@
"""Calendar plugin A1 API: calendars/events CRUD + range expansion (SPEC §A).
Exercises the real HTTP surface: default-calendar bootstrap, event create/list with
recurrence expansion, edit, delete, read-only rejection, and per-user isolation.
Requires the calendar plugin installed + migrated.
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
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.main import create_app
from app.models import User
CAL = "/api/plugins/calendar"
class _Ctx:
user_id: uuid.UUID
sm: async_sessionmaker # type: ignore[type-arg]
client: httpx.AsyncClient
client_b: httpx.AsyncClient
@pytest_asyncio.fixture
async def ctx() -> AsyncIterator[_Ctx]:
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
c = _Ctx()
c.sm = sm
ids: list[uuid.UUID] = []
async with sm() as s:
for _ in range(2):
handle = f"cal{uuid.uuid4().hex[:8]}@test.local"
u = User(handle=handle, password_hash=hash_password("pw"))
s.add(u)
await s.flush()
ids.append(u.id)
await s.commit()
c.user_id = ids[0]
transport = ASGITransport(app=create_app())
async def client(uid: uuid.UUID) -> httpx.AsyncClient:
token = await create_session(uid, "pytest")
return httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
cookies={"session": token, "csrf": "csrf"},
headers={"X-CSRF": "csrf"},
)
c.client = await client(ids[0])
c.client_b = await client(ids[1])
try:
yield c
finally:
await c.client.aclose()
await c.client_b.aclose()
async with sm() as s: # cascade drops plg_calendar_* rows (user_id FK-less but scoped)
await s.execute(
text("DELETE FROM plg_calendar_calendars WHERE user_id = ANY(:ids)"),
{"ids": [str(i) for i in ids]},
)
await s.execute(delete(User).where(User.id.in_(ids)))
await s.commit()
await engine.dispose()
async def _events(
client: httpx.AsyncClient,
start: str = "2026-06-01T00:00:00Z",
end: str = "2026-06-02T00:00:00Z",
) -> list[dict[str, object]]:
r = await client.get(f"{CAL}/events", params={"start": start, "end": end})
return r.json()["events"]
async def test_default_calendar_and_event_crud(ctx: _Ctx) -> None:
# First calendars call bootstraps a default "Personal" calendar.
cals = (await ctx.client.get(f"{CAL}/calendars")).json()
assert [c["name"] for c in cals] == ["Personal"]
cal_id = cals[0]["id"]
# Create a timed event and read it back in a range.
r = await ctx.client.post(
f"{CAL}/events",
json={
"calendar_id": cal_id,
"summary": "Standup",
"dtstart": "2026-06-01T09:00:00Z",
"dtend": "2026-06-01T09:30:00Z",
},
)
assert r.status_code == 200
event_id = r.json()["id"]
listing = await _events(ctx.client)
assert len(listing) == 1
assert listing[0]["summary"] == "Standup"
assert listing[0]["color"] == "#3b82f6"
# Edit, then delete.
patched = await ctx.client.patch(f"{CAL}/events/{event_id}", json={"summary": "Daily standup"})
assert patched.status_code == 200
again = await _events(ctx.client)
assert again[0]["summary"] == "Daily standup"
assert (await ctx.client.delete(f"{CAL}/events/{event_id}")).status_code == 200
empty = await _events(ctx.client)
assert empty == []
async def test_recurring_event_expands_in_range(ctx: _Ctx) -> None:
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": "Weekly sync",
"dtstart": "2026-06-01T14:00:00Z", # Monday
"dtend": "2026-06-01T15:00:00Z",
"rrule": "FREQ=WEEKLY;BYDAY=MO",
},
)
# A 3-week window contains 3 Monday occurrences of one recurring event.
listing = await _events(ctx.client, end="2026-06-22T00:00:00Z")
assert len(listing) == 3
assert all(i["recurring"] for i in listing)
assert [i["start"][:10] for i in listing] == ["2026-06-01", "2026-06-08", "2026-06-15"]
async def test_read_only_calendar_rejects_writes(ctx: _Ctx) -> None:
cal_id = (await ctx.client.get(f"{CAL}/calendars")).json()[0]["id"]
# Simulate an ICS subscription: flip the calendar read-only directly.
async with ctx.sm() as s:
await s.execute(
text("UPDATE plg_calendar_calendars SET read_only = true WHERE id = :id"),
{"id": cal_id},
)
await s.commit()
r = await ctx.client.post(
f"{CAL}/events",
json={"calendar_id": cal_id, "summary": "nope", "dtstart": "2026-06-01T09:00:00Z"},
)
assert r.status_code == 403
body = r.json()
detail = body.get("error", {}).get("message") or body.get("detail")
assert detail == "read_only_calendar"
async def test_events_are_per_user_isolated(ctx: _Ctx) -> None:
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": "Private", "dtstart": "2026-06-05T10:00:00Z"},
)
# User B has their own default calendar and sees none of A's events.
b_events = await _events(ctx.client_b, end="2026-06-30T00:00:00Z")
assert b_events == []
@@ -0,0 +1,84 @@
"""Calendar recurrence expansion across a DST boundary (SPEC §A.3, acceptance A.7.2)."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo
from webmail_plugin_calendar.recurrence import expand_occurrences # type: ignore[import-not-found]
WARSAW = ZoneInfo("Europe/Warsaw")
def _local(occ: list[tuple[datetime, datetime]]) -> dict[str, datetime]:
# Map "YYYY-MM-DD" (Warsaw date) -> the UTC start of that occurrence.
return {s.astimezone(WARSAW).date().isoformat(): s for s, _ in occ}
def test_weekly_recurrence_keeps_local_time_across_dst() -> None:
# Monday 2026-03-23 10:00 Europe/Warsaw == 09:00 UTC (UTC+1, before DST).
# DST starts 2026-03-29, so from 03-30 the offset is UTC+2.
dtstart = datetime(2026, 3, 23, 9, 0, tzinfo=UTC)
occ = expand_occurrences(
dtstart=dtstart,
duration=timedelta(hours=1),
rrule="FREQ=WEEKLY;BYDAY=MO",
exdates=[],
range_start=datetime(2026, 3, 1, tzinfo=UTC),
range_end=datetime(2026, 4, 30, tzinfo=UTC),
tz="Europe/Warsaw",
)
by_date = _local(occ)
# Every occurrence is at 10:00 local wall-clock, on both sides of the DST change.
for start, _ in occ:
assert start.astimezone(WARSAW).strftime("%H:%M") == "10:00"
# ...but the UTC offset moves across the boundary.
assert by_date["2026-03-23"].hour == 9 # UTC+1
assert by_date["2026-03-30"].hour == 8 # UTC+2 (DST)
assert by_date["2026-04-06"].hour == 8
def test_exdates_remove_instances() -> None:
dtstart = datetime(2026, 3, 23, 9, 0, tzinfo=UTC)
occ = expand_occurrences(
dtstart=dtstart,
duration=timedelta(hours=1),
rrule="FREQ=WEEKLY;BYDAY=MO",
# Exclude the 2026-03-30 occurrence (10:00 Warsaw == 08:00 UTC under DST).
exdates=[datetime(2026, 3, 30, 8, 0, tzinfo=UTC)],
range_start=datetime(2026, 3, 1, tzinfo=UTC),
range_end=datetime(2026, 4, 30, tzinfo=UTC),
tz="Europe/Warsaw",
)
dates = set(_local(occ))
assert "2026-03-23" in dates
assert "2026-03-30" not in dates # excluded
assert "2026-04-06" in dates
def test_single_event_no_rrule() -> None:
dtstart = datetime(2026, 6, 1, 12, 0, tzinfo=UTC)
occ = expand_occurrences(
dtstart=dtstart,
duration=timedelta(minutes=30),
rrule=None,
exdates=[],
range_start=datetime(2026, 6, 1, tzinfo=UTC),
range_end=datetime(2026, 6, 2, tzinfo=UTC),
tz="UTC",
)
assert occ == [(dtstart, dtstart + timedelta(minutes=30))]
# Outside the window -> nothing.
empty = expand_occurrences(
dtstart=dtstart,
duration=timedelta(minutes=30),
rrule=None,
exdates=[],
range_start=datetime(2026, 7, 1, tzinfo=UTC),
range_end=datetime(2026, 7, 2, tzinfo=UTC),
tz="UTC",
)
assert empty == []
@@ -0,0 +1,33 @@
"""SDK security surface for heavy plugins: vault + multi-method guarded HTTP."""
from __future__ import annotations
import pytest
from app.accounts.ssrf import SsrfError, guarded_request
from app.plugins.sdk import sdk
def test_vault_round_trips_secrets() -> None:
vault = sdk("calendar").vault
secret = b"caldav-app-password-123"
blob = vault.encrypt(secret)
assert blob != secret # encrypted at rest
assert vault.decrypt(blob) == secret
async def test_guarded_request_rejects_non_https() -> None:
with pytest.raises(SsrfError, match="https"):
await guarded_request("PROPFIND", "http://dav.example/cal/")
async def test_guarded_request_rejects_private_host() -> None:
# Resolves to a loopback/private address -> blocked before any connection.
with pytest.raises(SsrfError, match="non-public|resolve"):
await guarded_request("GET", "https://localhost/")
def test_http_facade_exposes_dav_verbs() -> None:
http = sdk("calendar").http
for verb in ("request", "get", "put", "delete", "propfind", "report"):
assert callable(getattr(http, verb))
+60
View File
@@ -0,0 +1,60 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { usePluginPages } from "../plugins/manifest";
// Product switcher shown in the sidebar header: Webmail + any plugin "app.page"
// (Calendar, later Contacts). Plugin-agnostic — the app list comes from the
// manifest, so a new page-plugin appears here with no core change.
export function AppSwitcher() {
const navigate = useNavigate();
const pages = usePluginPages();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [open]);
const apps = [
{ key: "mail", label: "Mail", to: "/mail" },
...pages.map((p) => ({ key: `${p.plugin}${p.route}`, label: p.nav.label, to: `/p${p.route}` })),
];
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-1.5 rounded px-1.5 py-1 text-[15px] font-semibold text-app-fg hover:bg-app-hover"
>
Mail
<svg width="20" height="20" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M4 6l4 4 4-4" />
</svg>
</button>
{open && (
<div className="absolute left-0 top-full z-20 mt-1 min-w-[160px] rounded-lg border border-app-border bg-app-elevated py-1 shadow-lg">
{apps.map((a) => (
<button
key={a.key}
onClick={() => {
setOpen(false);
navigate(a.to);
}}
className={`flex w-full items-center px-3 py-1.5 text-left text-sm hover:bg-app-hover ${
a.key === "mail" ? "font-medium text-app-fg" : "text-app-muted"
}`}
>
{a.label}
</button>
))}
</div>
)}
</div>
);
}
+3 -3
View File
@@ -1,12 +1,12 @@
import { useT } from "../../i18n";
import { formatClock, getDateLocale } from "../../lib/timeFormat";
import type { DraftOut, RemoteDraftOut } from "../../lib/types";
function timeLabel(iso: string): string {
const d = new Date(iso);
const now = new Date();
if (d.toDateString() === now.toDateString())
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
return d.toLocaleDateString([], { month: "short", day: "numeric" });
if (d.toDateString() === now.toDateString()) return formatClock(d);
return d.toLocaleDateString(getDateLocale(), { month: "short", day: "numeric" });
}
function DraftRow({
+4 -4
View File
@@ -2,17 +2,17 @@ import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef } from "react";
import { useT } from "../../i18n";
import { formatClock, getDateLocale } from "../../lib/timeFormat";
import type { Address, ThreadSummary } from "../../lib/types";
import { Icon } from "../Icon";
function timeLabel(iso: string): string {
const d = new Date(iso);
const now = new Date();
if (d.toDateString() === now.toDateString())
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
if (d.toDateString() === now.toDateString()) return formatClock(d);
if (d.getFullYear() === now.getFullYear())
return d.toLocaleDateString([], { month: "short", day: "numeric" });
return d.toLocaleDateString([], { year: "2-digit", month: "short", day: "numeric" });
return d.toLocaleDateString(getDateLocale(), { month: "short", day: "numeric" });
return d.toLocaleDateString(getDateLocale(), { year: "2-digit", month: "short", day: "numeric" });
}
// Stable per-sender hue so a sender's avatar keeps a consistent colour.
@@ -4,6 +4,7 @@ import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { useT } from "../../i18n";
import { api } from "../../lib/api";
import { printMessages } from "../../lib/print";
import { getDateLocale, getHour12 } from "../../lib/timeFormat";
import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types";
import { PluginSlot } from "../../plugins/PluginSlot";
import { Composer, type ComposerInit } from "../compose/Composer";
@@ -39,13 +40,17 @@ function recipientSummary(addrs: { name: string; email: string }[]): string {
function attributionFor(msg: MessageEnvelope): string {
const d = new Date(msg.date);
const date = d.toLocaleDateString("en-US", {
const date = d.toLocaleDateString(getDateLocale(), {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
});
const time = d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
const time = d.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: getHour12(),
});
const who = msg.from_addr[0]?.name || msg.from_addr[0]?.email || "";
return `On ${date}, at ${time}, ${who} wrote:`;
}
@@ -110,13 +115,14 @@ function fmtRelative(iso: string): string {
}
function fmtFull(iso: string): string {
return new Date(iso).toLocaleString("en-US", {
return new Date(iso).toLocaleString(getDateLocale(), {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: getHour12(),
});
}
+7
View File
@@ -223,6 +223,13 @@ export const en = {
"settings.revoke": "Revoke",
"settings.threadOrder": "Conversation order",
"settings.timezone": "Time zone",
"settings.timeFormat": "Time format",
"settings.timeFormat24": "24-hour (14:30)",
"settings.timeFormat12": "12-hour (2:30 PM)",
"settings.integrations": "Integrations",
"settings.jitsi": "Jitsi Meet",
"settings.jitsiHint":
"Server used to generate video-call links for calendar events. Leave empty to use the public https://meet.jit.si/ server.",
"settings.addAccount": "Add account",
"settings.syncState": "State: {state}",
"settings.confirmRemoveAccount": "Remove this account and all its mail?",
+3 -1
View File
@@ -166,7 +166,9 @@ export const api = {
// --- settings ---
settings: () => apiFetch<UserSettings>("/settings"),
patchSettings: (patch: Partial<Pick<UserSettings, "thread_order" | "tz">>) =>
patchSettings: (
patch: Partial<Pick<UserSettings, "thread_order" | "tz" | "time_format" | "jitsi_server">>,
) =>
apiFetch<UserSettings>("/settings", { method: "PATCH", body: JSON.stringify(patch) }),
quota: () => apiFetch<QuotaOut>("/settings/quota"),
folderCounts: () => apiFetch<FolderCounts>("/mailboxes/counts"),
+3 -1
View File
@@ -3,6 +3,7 @@
// the browser's print dialog. Reuses the same sanitized HTML the reader shows.
import { api } from "./api";
import { getDateLocale, getHour12 } from "./timeFormat";
import type { Address, AttachmentOut, MessageEnvelope } from "./types";
function esc(s: string): string {
@@ -24,13 +25,14 @@ function fmtList(addrs: Address[]): string {
function fmtDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleString(undefined, {
return d.toLocaleString(getDateLocale(), {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: getHour12(),
});
}
+41
View File
@@ -0,0 +1,41 @@
// Shared 12h/24h time preference (SPEC §9.1). The value lives in user settings
// (`time_format`), synced here so the module-level date formatters used across
// Mail can read it synchronously during render. 24-hour is the default.
let hour12 = false;
try {
if (typeof localStorage !== "undefined" && localStorage.getItem("time_format") === "12") {
hour12 = true;
}
} catch {
/* localStorage unavailable (SSR/tests) — keep the 24h default. */
}
/** Called when the settings query resolves/changes to sync the preference. */
export function setTimeFormat(fmt: "12" | "24"): void {
hour12 = fmt === "12";
try {
localStorage.setItem("time_format", fmt);
} catch {
/* ignore */
}
}
/** `hour12` option for Intl date/time formatting, reflecting the preference. */
export function getHour12(): boolean {
return hour12;
}
/**
* Locale for date formatting, tied to the time preference: 24-hour → en-GB
* (day/month/year, European), 12-hour → en-US (month/day/year, American).
*/
export function getDateLocale(): string {
return hour12 ? "en-US" : "en-GB";
}
/** Format just the clock portion (e.g. "14:30" or "2:30 PM") of a Date. */
export function formatClock(d: Date): string {
return d.toLocaleTimeString([], { hour: hour12 ? "numeric" : "2-digit", minute: "2-digit", hour12 });
}
+2
View File
@@ -151,6 +151,8 @@ export interface SessionInfo {
export interface UserSettings {
thread_order: "asc" | "desc";
tz: string;
time_format: "12" | "24";
jitsi_server: string;
allowed_image_senders: string[];
}
+5 -12
View File
@@ -5,7 +5,7 @@
// header keeps a way back to Mail. (Sidebar persistence is a later refinement.)
import { useEffect, useMemo, useRef } from "react";
import { Link, Navigate, useLocation } from "react-router-dom";
import { Navigate, useLocation } from "react-router-dom";
import { useT } from "../i18n";
import { loadBundle } from "./bundleLoader";
@@ -40,7 +40,7 @@ function PageMount({
};
}, [bundle, ctx, props, pluginName]);
return <div ref={ref} data-plugin-page={pluginName} className="min-h-0 flex-1 overflow-auto" />;
return <div ref={ref} data-plugin-page={pluginName} className="h-full overflow-auto" />;
}
export function PluginPage() {
@@ -58,17 +58,10 @@ export function PluginPage() {
}
if (!match) return <Navigate to="/mail" replace />;
// Full-bleed: app pages (like Calendar) own their entire chrome, including the
// product switcher for navigating back to Mail.
return (
<div className="flex h-screen flex-col bg-app-bg">
<header className="flex items-center gap-3 border-b border-app-border px-4 py-2">
<Link
to="/mail"
className="rounded px-2 py-1 text-sm text-app-muted hover:bg-app-hover hover:text-app-fg"
>
← {t("nav.mail")}
</Link>
<span className="text-sm font-semibold text-app-fg">{match.nav.label}</span>
</header>
<div className="h-screen bg-app-bg">
<PageMount pluginName={match.plugin} bundle={match.bundle} subpath={subpath} />
</div>
);
+4 -1
View File
@@ -1,6 +1,7 @@
// Builds the PluginContext handed to plugin bundles at mount (SPEC §11.3).
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import type { MessageKey } from "../i18n/en";
import { useT } from "../i18n";
@@ -11,6 +12,7 @@ import type { PluginContext } from "./types";
/** A memoised context bound to one plugin, usable from slots and pages. */
export function usePluginContext(pluginName: string): PluginContext {
const t = useT();
const navigate = useNavigate();
return useMemo<PluginContext>(
() => ({
pluginName,
@@ -27,7 +29,8 @@ export function usePluginContext(pluginName: string): PluginContext {
// Plugins mostly ship their own strings; app keys resolve, unknown keys
// fall through as-is (the app translator returns the key when missing).
t: (key, vars) => t(key as MessageKey, vars),
navigate: (path) => navigate(path),
}),
[pluginName, t],
[pluginName, t, navigate],
);
}
+2
View File
@@ -54,6 +54,8 @@ export interface PluginContext {
api: PluginApi;
events: PluginEvents;
t: (key: string, vars?: Record<string, string | number>) => string;
/** Navigate the app's SPA router (e.g. back to "/mail" from a page bundle). */
navigate: (path: string) => void;
}
export type PluginUnmount = () => void;
+7 -5
View File
@@ -2,6 +2,7 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tansta
import { useEffect, useRef, useState } from "react";
import { Link, useMatch, useNavigate } from "react-router-dom";
import { AppSwitcher } from "../components/AppSwitcher";
import { Composer, type ComposerInit, draftToInit } from "../components/compose/Composer";
import { DraftsList } from "../components/list/DraftsList";
import { Icon } from "../components/Icon";
@@ -14,6 +15,7 @@ import { ThreadView } from "../components/thread/ThreadView";
import { useT } from "../i18n";
import type { MessageKey } from "../i18n/en";
import { api } from "../lib/api";
import { setTimeFormat } from "../lib/timeFormat";
import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types";
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
import { usePluginPages } from "../plugins/manifest";
@@ -137,6 +139,10 @@ export function MailLayout() {
const savedSearchesQuery = useQuery({ queryKey: ["saved-searches"], queryFn: api.savedSearches });
const settingsQuery = useQuery({ queryKey: ["settings"], queryFn: api.settings });
const threadOrder: "asc" | "desc" = settingsQuery.data?.thread_order ?? "asc";
const timeFormat = settingsQuery.data?.time_format;
useEffect(() => {
if (timeFormat) setTimeFormat(timeFormat);
}, [timeFormat]);
const quotaQuery = useQuery({ queryKey: ["quota"], queryFn: api.quota });
const countsQuery = useQuery({ queryKey: ["folder-counts"], queryFn: api.folderCounts });
@@ -430,11 +436,7 @@ export function MailLayout() {
collapsed ? "flex-col items-center gap-1 px-2" : "items-center justify-between px-3"
}`}
>
{!collapsed && (
<span className="text-[13px] font-semibold uppercase tracking-wide text-app-faint">
{t("common.appName")}
</span>
)}
{!collapsed && <AppSwitcher />}
<div className={`flex items-center gap-1 ${collapsed ? "flex-col" : ""}`}>
<button
onClick={() => setCollapsed((v) => !v)}
+50 -1
View File
@@ -8,9 +8,10 @@ import { useT } from "../../i18n";
import { api, ApiError } from "../../lib/api";
import { useMe } from "../../lib/auth";
import { useTheme } from "../../lib/theme";
import { setTimeFormat } from "../../lib/timeFormat";
import { PluginSlot } from "../../plugins/PluginSlot";
const SECTIONS = ["profile", "security", "accounts", "appearance"] as const;
const SECTIONS = ["profile", "security", "accounts", "appearance", "integrations"] as const;
type Section = (typeof SECTIONS)[number];
export function Settings() {
@@ -45,6 +46,7 @@ export function Settings() {
{section === "security" && <SecuritySection />}
{section === "accounts" && <AccountsSection />}
{section === "appearance" && <AppearanceSection />}
{section === "integrations" && <IntegrationsSection />}
{/* Plugin-contributed settings UI (SPEC §11.3 settings.page), under Accounts.
Matches the accounts section width (max-w-5xl) with a gap above it. */}
{section === "accounts" && (
@@ -595,7 +597,54 @@ function AppearanceSection() {
<option value="desc">{t("mail.orderNewest")}</option>
</select>
</label>
<label className="flex items-center justify-between border-b border-app-border py-2 text-sm">
<span className="text-app-muted">{t("settings.timeFormat")}</span>
<select
className="rounded border border-app-border bg-app-surface px-2 py-1"
value={settings.data?.time_format ?? "24"}
onChange={(e) => {
const fmt = e.target.value as "12" | "24";
setTimeFormat(fmt);
patch.mutate({ time_format: fmt });
}}
>
<option value="24">{t("settings.timeFormat24")}</option>
<option value="12">{t("settings.timeFormat12")}</option>
</select>
</label>
<Row label={t("settings.timezone")} value={settings.data?.tz ?? "UTC"} />
</section>
);
}
const DEFAULT_JITSI = "https://meet.jit.si/";
function IntegrationsSection() {
const t = useT();
const queryClient = useQueryClient();
const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings });
const patch = useMutation({
mutationFn: api.patchSettings,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }),
});
const [jitsi, setJitsi] = useState<string | null>(null);
const value = jitsi ?? settings.data?.jitsi_server ?? "";
return (
<section className="max-w-4xl space-y-6">
<h2 className="text-lg font-semibold">{t("settings.integrations")}</h2>
<div className="space-y-2">
<h3 className="text-sm font-medium">{t("settings.jitsi")}</h3>
<p className="text-sm text-app-muted">{t("settings.jitsiHint")}</p>
<input
type="url"
className="w-full rounded border border-app-border bg-app-surface px-3 py-2 text-sm"
placeholder={DEFAULT_JITSI}
value={value}
onChange={(e) => setJitsi(e.target.value)}
onBlur={(e) => patch.mutate({ jitsi_server: e.target.value.trim() })}
/>
</div>
</section>
);
}
+6 -3
View File
@@ -1,6 +1,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import { I18nProvider } from "../i18n";
@@ -41,9 +42,11 @@ function wrap(children: ReactNode) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(["plugin-ui-manifest"], MANIFEST); // seed so no network fetch
return (
<I18nProvider>
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
</I18nProvider>
<MemoryRouter>
<I18nProvider>
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
</I18nProvider>
</MemoryRouter>
);
}
@@ -0,0 +1,24 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "webmail-plugin-calendar"
version = "0.1.0"
description = "Calendar — the reference heavy plugin (SPEC Appendix A). Local events, iMIP, CalDAV/ICS."
requires-python = ">=3.11"
# Plugin owns its dependencies (SPEC §A.2). caldav is only needed for A3.
dependencies = [
"python-dateutil>=2.9",
"icalendar>=5.0,<7",
"tzdata",
]
[project.entry-points."webmail.plugins"]
calendar = "webmail_plugin_calendar:plugin"
[tool.setuptools]
packages = ["webmail_plugin_calendar"]
[tool.setuptools.package-data]
webmail_plugin_calendar = ["ui/*.js", "migrations/versions/*.py"]
@@ -0,0 +1,34 @@
"""``calendar`` — the reference heavy plugin (SPEC Appendix A).
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.
"""
from __future__ import annotations
from pathlib import Path
from app.plugins.sdk import Page, PageNav, Plugin, UiManifest
from webmail_plugin_calendar.router import router
_HERE = Path(__file__).resolve().parent
plugin = Plugin(
name="calendar",
version="0.1.0",
builtin=True,
router=router,
ui_manifest=UiManifest(
pages=[
Page(
route="/calendar",
nav=PageNav(icon="calendar", label="Calendar", order=10),
bundle="/api/plugins/calendar/ui/page.js",
)
],
),
migrations=str(_HERE / "migrations" / "versions"),
)
@@ -0,0 +1,289 @@
"""iMIP invitations (SPEC §A.2): build the ICS + HTML invite and send it from the
organizer's mailbox via ``sdk.mail``, plus signed RSVP tokens.
An invite carries a ``text/calendar; method=REQUEST`` part (so a recipient's own
client shows its native Yes/Maybe/No UI) *and* Reply buttons that link back to the
public ``/rsvp`` endpoint. The RSVP token is the event coordinates
(``user_id``/``event_id``/``email``) sealed with the SDK vault — tamper-proof and
opaque, so an external guest can respond without a webmail login.
"""
from __future__ import annotations
import base64
import html as html_mod
import json
import uuid
from datetime import UTC, datetime
from zoneinfo import ZoneInfo
from icalendar import Calendar, Event, vCalAddress, vText
from app.plugins.sdk import sdk
_SDK = sdk("calendar")
RESPONSES = {"accepted": "Yes", "declined": "No", "tentative": "Maybe"}
# --- RSVP tokens (sealed with the vault, so guests need no login) -------------
def make_token(user_id: uuid.UUID, event_id: uuid.UUID, email: str) -> str:
payload = json.dumps({"u": str(user_id), "e": str(event_id), "m": email}).encode()
return base64.urlsafe_b64encode(_SDK.vault.encrypt(payload)).decode()
def read_token(token: str) -> tuple[uuid.UUID, uuid.UUID, str] | None:
try:
data = json.loads(_SDK.vault.decrypt(base64.urlsafe_b64decode(token.encode())))
return uuid.UUID(data["u"]), uuid.UUID(data["e"]), str(data["m"])
except Exception:
return None
# --- date formatting ----------------------------------------------------------
def _local(dt: datetime, tz: str) -> datetime:
try:
return dt.astimezone(ZoneInfo(tz))
except Exception:
return dt.astimezone(UTC)
def format_when(dtstart: datetime, dtend: datetime | None, tz: str, all_day: bool) -> str:
s = _local(dtstart, tz)
if all_day:
return s.strftime("%A %Y-%m-%d")
e = _local(dtend, tz) if dtend else None
times = s.strftime("%H:%M") + (f" – {e.strftime('%H:%M')}" if e else "")
return f"{s.strftime('%A %Y-%m-%d')} · {times} ({s.tzname() or tz})"
def _subject_when(dtstart: datetime, dtend: datetime | None, tz: str, all_day: bool) -> str:
s = _local(dtstart, tz)
if all_day:
return s.strftime("%a %Y-%m-%d")
e = _local(dtend, tz) if dtend else None
times = s.strftime("%H:%M") + (f" - {e.strftime('%H:%M')}" if e else "")
return f"{s.strftime('%a %Y-%m-%d')} {times} ({s.tzname() or tz})"
# --- ICS ----------------------------------------------------------------------
def build_ics(
*,
ical_uid: str,
summary: str,
description: str,
location: str,
dtstart: datetime,
dtend: datetime | None,
organizer: dict[str, str] | None,
attendees: list[dict[str, str]],
conference_url: str = "",
sequence: int = 0,
) -> str:
cal = Calendar()
cal.add("prodid", "-//Webmail//Calendar//EN")
cal.add("version", "2.0")
cal.add("method", "REQUEST")
ev = Event()
ev.add("uid", ical_uid)
ev.add("dtstamp", datetime.now(UTC))
ev.add("dtstart", dtstart)
if dtend:
ev.add("dtend", dtend)
ev.add("summary", summary or "(no title)")
desc = description or ""
if conference_url:
desc = f"{desc}\n\nJoin: {conference_url}".strip()
ev.add("url", conference_url)
if desc:
ev.add("description", desc)
if location:
ev.add("location", location)
ev.add("sequence", sequence)
if organizer and organizer.get("email"):
org = vCalAddress(f"MAILTO:{organizer['email']}")
if organizer.get("name"):
org.params["cn"] = vText(organizer["name"])
ev.add("organizer", org)
for a in attendees:
att = vCalAddress(f"MAILTO:{a['email']}")
if a.get("name"):
att.params["cn"] = vText(a["name"])
att.params["role"] = vText("REQ-PARTICIPANT")
att.params["partstat"] = vText((a.get("partstat") or "needs-action").upper())
att.params["rsvp"] = vText("TRUE")
ev.add("attendee", att)
cal.add_component(ev)
return cal.to_ical().decode("utf-8")
# --- HTML / text body ---------------------------------------------------------
def _esc(s: object) -> str:
return html_mod.escape(str(s or ""))
def _guest_lines_html(organizer: dict[str, str] | None, attendees: list[dict[str, str]]) -> str:
rows = ""
if organizer and organizer.get("email"):
name = organizer.get("name") or organizer["email"]
rows += f'<div>{_esc(name)} <span style="color:#5f6368">&ndash; organizer</span></div>'
for a in attendees:
rows += f'<div>{_esc(a.get("name") or a["email"])}</div>'
return rows
def build_html(
*,
when: str,
description: str,
location: str,
conference_url: str,
organizer: dict[str, str] | None,
attendees: list[dict[str, str]],
rsvp_base: str,
token: str,
) -> str:
join = ""
link = ""
if conference_url:
join = (
f'<div style="margin:0 0 22px"><a href="{_esc(conference_url)}" '
'style="display:inline-block;background:#1a73e8;color:#fff;text-decoration:none;'
'padding:10px 20px;border-radius:6px;font-weight:600">Join Jitsi Meet</a></div>'
)
link = (
'<p style="margin:0 0 4px;font-weight:700">Meeting link</p>'
f'<p style="margin:0 0 22px"><a href="{_esc(conference_url)}" '
f'style="color:#1a73e8;text-decoration:none">{_esc(conference_url)}</a></p>'
)
desc = f'<p style="margin:0 0 22px">{_esc(description)}</p>' if description else ""
loc = (
'<p style="margin:0 0 4px;font-weight:700">Where</p>'
f'<p style="margin:0 0 22px;color:#5f6368">{_esc(location)}</p>'
if location
else ""
)
def btn(resp: str, label: str) -> str:
url = f"{rsvp_base}?t={token}&r={resp}"
return (
f'<a href="{url}" style="display:inline-block;border:1px solid #dadce0;'
'border-radius:4px;padding:8px 20px;margin-right:8px;color:#1a73e8;'
f'text-decoration:none">{label}</a>'
)
rsvp = btn("accepted", "Yes") + btn("declined", "No") + btn("tentative", "Maybe")
return (
'<html><body style="margin:0;background:#f1f3f4;font-family:Arial,Helvetica,sans-serif;'
'color:#3c4043;line-height:1.5">'
'<div style="max-width:600px;margin:0 auto;background:#fff;border:1px solid #e0e0e0;'
'border-radius:8px;padding:28px 32px">'
f"{join}{link}{desc}"
'<p style="margin:0 0 4px;font-weight:700">When</p>'
f'<p style="margin:0 0 22px;color:#5f6368">{_esc(when)}</p>'
f"{loc}"
'<p style="margin:0 0 6px;font-weight:700">Guests</p>'
f'<div style="margin:0 0 22px">{_guest_lines_html(organizer, attendees)}</div>'
'<p style="margin:0 0 8px"><b>Reply</b> to this invitation</p>'
f"<div>{rsvp}</div>"
"</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>"
"</body></html>"
)
def build_text(when: str, description: str, conference_url: str, rsvp_base: str, token: str) -> str:
lines = []
if conference_url:
lines.append(f"Join Jitsi Meet: {conference_url}")
lines.append("")
if description:
lines.append(description)
lines.append("")
lines.append(f"When: {when}")
lines.append("")
lines.append("Reply to this invitation:")
lines.append(f" Yes: {rsvp_base}?t={token}&r=accepted")
lines.append(f" No: {rsvp_base}?t={token}&r=declined")
lines.append(f" Maybe: {rsvp_base}?t={token}&r=tentative")
return "\n".join(lines)
# --- send ---------------------------------------------------------------------
async def send_invites(
*,
user_id: uuid.UUID,
event_id: uuid.UUID,
ical_uid: str,
summary: str,
description: str,
location: str,
dtstart: datetime,
dtend: datetime | None,
tz: str,
all_day: bool,
organizer: dict[str, str] | None,
attendees: list[dict[str, str]],
conference_url: str,
sequence: int = 0,
notify_emails: set[str] | None = None,
) -> None:
"""Send one iMIP REQUEST per attendee, from the organizer's mailbox.
The ICS always lists the full ``attendees`` roster; ``notify_emails`` (lower-
cased) restricts *who is actually emailed* — used on edit to invite only the
newly-added guests without re-notifying everyone.
"""
if not attendees:
return
from_email = organizer.get("email") if organizer else None
rsvp_base = f"https://{_SDK.settings.app_domain}/api/plugins/calendar/rsvp"
when = format_when(dtstart, dtend, tz, all_day)
subject_when = _subject_when(dtstart, dtend, tz, all_day)
ics = build_ics(
ical_uid=ical_uid,
summary=summary,
description=description,
location=location,
dtstart=dtstart,
dtend=dtend,
organizer=organizer,
attendees=attendees,
conference_url=conference_url,
sequence=sequence,
)
for a in attendees:
email = a.get("email")
if not email:
continue
if notify_emails is not None and email.lower() not in notify_emails:
continue
token = make_token(user_id, event_id, email)
html = build_html(
when=when,
description=description,
location=location,
conference_url=conference_url,
organizer=organizer,
attendees=attendees,
rsvp_base=rsvp_base,
token=token,
)
text = build_text(when, description, conference_url, rsvp_base, token)
subject = f"Invitation: {summary or '(no title)'} - {subject_when} ({email})"
await _SDK.mail.send(
user_id,
to=[email],
subject=subject,
html=html,
text=text,
from_email=from_email,
ics=ics,
)
@@ -0,0 +1,79 @@
"""calendar plugin: calendars + events tables with RLS (SPEC §A.3)
Revision ID: calendar0001
Revises:
Create Date: 2026-07-28
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
from app.plugins.rls import enable_rls
revision: str = "calendar0001"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
CREATE TABLE plg_calendar_calendars (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
name text NOT NULL,
color text NOT NULL DEFAULT '#3b82f6',
kind text NOT NULL DEFAULT 'local', -- local|caldav|ics_subscription
source_config jsonb NOT NULL DEFAULT '{}',
secret_ct bytea, -- CalDAV creds (vault, §10.2)
sync_token text,
read_only boolean NOT NULL DEFAULT false,
UNIQUE (user_id, name)
)
"""
)
op.execute(
"""
CREATE TABLE plg_calendar_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
calendar_id uuid NOT NULL
REFERENCES plg_calendar_calendars(id) ON DELETE CASCADE,
ical_uid text NOT NULL,
sequence int NOT NULL DEFAULT 0,
summary text NOT NULL DEFAULT '',
description text NOT NULL DEFAULT '',
location text NOT NULL DEFAULT '',
dtstart timestamptz NOT NULL,
dtend timestamptz,
all_day boolean NOT NULL DEFAULT false,
tz text NOT NULL DEFAULT 'UTC',
rrule text,
exdates timestamptz[] NOT NULL DEFAULT '{}',
recurrence_id timestamptz,
organizer jsonb,
attendees jsonb NOT NULL DEFAULT '[]',
status text NOT NULL DEFAULT 'confirmed', -- confirmed|tentative|cancelled
my_partstat text,
reminders int[] NOT NULL DEFAULT '{}',
origin_message_id uuid,
raw_ical text,
UNIQUE (calendar_id, ical_uid, recurrence_id)
)
"""
)
op.execute(
"CREATE INDEX ix_plg_calendar_events_user_dtstart "
"ON plg_calendar_events (user_id, dtstart)"
)
enable_rls("plg_calendar_calendars")
enable_rls("plg_calendar_events")
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS plg_calendar_events")
op.execute("DROP TABLE IF EXISTS plg_calendar_calendars")
@@ -0,0 +1,28 @@
"""calendar: per-event color + show_as (busy/free) (SPEC §A form)
Revision ID: calendar0002
Revises: calendar0001
Create Date: 2026-07-28
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "calendar0002"
down_revision: str | None = "calendar0001"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# color NULL = inherit the calendar's colour; show_as maps to iCalendar TRANSP.
op.execute("ALTER TABLE plg_calendar_events ADD COLUMN color text")
op.execute("ALTER TABLE plg_calendar_events ADD COLUMN show_as text NOT NULL DEFAULT 'busy'")
def downgrade() -> None:
op.execute("ALTER TABLE plg_calendar_events DROP COLUMN IF EXISTS show_as")
op.execute("ALTER TABLE plg_calendar_events DROP COLUMN IF EXISTS color")
@@ -0,0 +1,26 @@
"""calendar: per-event conference_url (Jitsi Meet video-call link) (SPEC §A form)
Revision ID: calendar0003
Revises: calendar0002
Create Date: 2026-07-28
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "calendar0003"
down_revision: str | None = "calendar0002"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# Video-call URL (Jitsi Meet room), generated in the event form; empty = none.
op.execute("ALTER TABLE plg_calendar_events ADD COLUMN conference_url text NOT NULL DEFAULT ''")
def downgrade() -> None:
op.execute("ALTER TABLE plg_calendar_events DROP COLUMN IF EXISTS conference_url")
@@ -0,0 +1,93 @@
"""Range expansion + override merge for the events list (SPEC §A.3).
Given the base events (``recurrence_id IS NULL``) and detached override rows
(``recurrence_id`` set) that overlap a window, produce the flat list of instances
the UI renders: expand each base event's RRULE (DST-correct, EXDATE-aware), drop the
occurrence an override replaces, and emit the override at its own moved time.
"""
from __future__ import annotations
from collections.abc import Mapping
from datetime import UTC, datetime, timedelta
from typing import Any
from webmail_plugin_calendar.recurrence import expand_occurrences
Row = Mapping[str, Any]
def _duration(dtstart: datetime, dtend: datetime | None, all_day: bool) -> timedelta:
if dtend is not None:
return dtend - dtstart
return timedelta(days=1) if all_day else timedelta(0)
def _instance(
row: Row, start: datetime, end: datetime, recurrence_id: datetime | None
) -> dict[str, Any]:
origin = row.get("origin_message_id")
return {
"id": str(row["id"]),
"calendar_id": str(row["calendar_id"]),
"ical_uid": row["ical_uid"],
"summary": row["summary"],
"description": row.get("description") or "",
"location": row["location"],
"tz": row.get("tz"),
"rrule": row.get("rrule"),
"reminders": list(row.get("reminders") or []),
"start": start.isoformat(),
"end": end.isoformat(),
"all_day": row["all_day"],
"status": row["status"],
"recurring": row["rrule"] is not None,
"recurrence_id": recurrence_id.isoformat() if recurrence_id else None,
"color": row["color"],
"show_as": row.get("show_as", "busy"),
"conference_url": row.get("conference_url", ""),
"attendees": row.get("attendees") or [],
"organizer": row.get("organizer"),
"my_partstat": row.get("my_partstat"),
"origin_message_id": str(origin) if origin else None,
}
def expand_range(
base_rows: list[Row],
override_rows: list[Row],
range_start: datetime,
range_end: datetime,
) -> list[dict[str, Any]]:
# Overrides keyed by (ical_uid, original-occurrence-start in UTC); also emitted
# as their own instance at the moved time.
replaced: set[tuple[str, datetime]] = set()
out: list[dict[str, Any]] = []
for o in override_rows:
replaced.add((o["ical_uid"], o["recurrence_id"].astimezone(UTC)))
if o["status"] == "cancelled":
continue
dur = _duration(o["dtstart"], o["dtend"], o["all_day"])
start, end = o["dtstart"], o["dtstart"] + dur
if end > range_start and start < range_end:
out.append(_instance(o, start, end, o["recurrence_id"]))
for b in base_rows:
dur = _duration(b["dtstart"], b["dtend"], b["all_day"])
occ = expand_occurrences(
dtstart=b["dtstart"],
duration=dur,
rrule=b["rrule"],
exdates=list(b["exdates"] or []),
range_start=range_start,
range_end=range_end,
tz=b["tz"],
all_day=b["all_day"],
)
for start, end in occ:
if (b["ical_uid"], start.astimezone(UTC)) in replaced:
continue # a detached override provides this instance
out.append(_instance(b, start, end, start if b["rrule"] else None))
out.sort(key=lambda i: i["start"])
return out
@@ -0,0 +1,81 @@
"""Recurrence expansion (SPEC §A.3).
Events store ``dtstart`` in UTC plus the original ``tz``. RRULEs are expanded at
query time (no materialization) over the requested range only. DST correctness:
a weekly "Monday 10:00 Europe/Warsaw" event stays at 10:00 *local* on both sides of
a DST change (its UTC offset shifts). dateutil computes occurrences by naive
arithmetic, so we expand on the wall-clock time in the event's own zone, then
localize each occurrence back to UTC — that keeps the local time fixed and lets the
offset move. Iterations are capped per event; EXDATEs remove instances. Detached
overrides (``recurrence_id`` rows) are merged by the caller, not here.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from itertools import islice
from zoneinfo import ZoneInfo
from dateutil.rrule import rrulestr
MAX_INSTANCES = 1000
def _localize_until(rrule: str, zone: ZoneInfo) -> str:
"""Rewrite a UTC (``…Z``) ``UNTIL`` to naive wall-clock in ``zone``.
We expand on naive wall-clock (see module docstring), and dateutil requires
``UNTIL`` to match ``DTSTART``'s awareness — so a standards-compliant UTC UNTIL
must be converted to the same naive local clock the expansion runs in.
"""
parts = []
for part in rrule.split(";"):
if part.startswith("UNTIL=") and part.endswith("Z"):
try:
dt = datetime.strptime(part[6:-1], "%Y%m%dT%H%M%S").replace(tzinfo=UTC)
part = "UNTIL=" + dt.astimezone(zone).replace(tzinfo=None).strftime("%Y%m%dT%H%M%S")
except ValueError:
pass
parts.append(part)
return ";".join(parts)
def expand_occurrences(
*,
dtstart: datetime,
duration: timedelta,
rrule: str | None,
exdates: list[datetime],
range_start: datetime,
range_end: datetime,
tz: str,
all_day: bool = False,
) -> list[tuple[datetime, datetime]]:
"""Return ``(start, end)`` occurrences (UTC, aware) overlapping ``[range_start,
range_end)``. All datetimes in/out are timezone-aware."""
# All-day events are date-anchored — expand in UTC so they don't shift.
zone = ZoneInfo("UTC") if all_day else ZoneInfo(tz or "UTC")
local_start = dtstart.astimezone(zone).replace(tzinfo=None)
def to_utc(naive: datetime) -> datetime:
return naive.replace(tzinfo=zone).astimezone(UTC)
excluded = {e.astimezone(UTC) for e in exdates}
if rrule:
lo = range_start.astimezone(zone).replace(tzinfo=None) - duration
hi = range_end.astimezone(zone).replace(tzinfo=None)
rule = rrulestr(_localize_until(rrule, zone), dtstart=local_start)
naive_starts = list(islice(rule.between(lo, hi, inc=True), MAX_INSTANCES))
else:
naive_starts = [local_start]
out: list[tuple[datetime, datetime]] = []
for naive in naive_starts:
start = to_utc(naive)
if start in excluded:
continue
end = start + duration
if end > range_start and start < range_end: # overlaps the window
out.append((start, end))
return out
@@ -0,0 +1,556 @@
"""HTTP surface for the calendar plugin, mounted at /api/plugins/calendar (SPEC §A).
Phase A1: calendars + events CRUD and the range-expansion events list, all through
the RLS-scoped ``sdk.session`` (per-user isolation enforced by Postgres). Recurrence
is expanded at query time (see queries.py / recurrence.py). Read-only calendars
(ICS subscriptions, A3) reject writes with 403 ``read_only_calendar``.
"""
from __future__ import annotations
import json
import logging
import uuid
from datetime import UTC, datetime, timedelta
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse, HTMLResponse
from sqlalchemy import text
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.queries import expand_range
from webmail_plugin_calendar.schemas import (
CalendarIn,
CalendarPatch,
EditFollowingIn,
EventIn,
EventPatch,
)
router = APIRouter()
_UI = Path(__file__).resolve().parent / "ui"
_SDK = sdk("calendar")
_log = logging.getLogger("webmail_plugin_calendar")
def _aware(dt: datetime) -> datetime:
return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
async def _ensure_default_calendar(session: AsyncSession, user_id: uuid.UUID) -> None:
count = (await session.execute(text("SELECT count(*) FROM plg_calendar_calendars"))).scalar()
if not count:
await session.execute(
text("INSERT INTO plg_calendar_calendars (user_id, name) VALUES (:u, 'Personal')"),
{"u": str(user_id)},
)
async def _assert_writable(session: AsyncSession, calendar_id: uuid.UUID) -> None:
read_only = (
await session.execute(
text("SELECT read_only FROM plg_calendar_calendars WHERE id = :id"),
{"id": str(calendar_id)},
)
).scalar_one_or_none()
if read_only is None:
raise HTTPException(status_code=404, detail="calendar_not_found")
if read_only:
raise HTTPException(status_code=403, detail="read_only_calendar")
# --- calendars ---------------------------------------------------------------
@router.get("/calendars")
async def list_calendars(user_id: CurrentUserId) -> list[dict[str, object]]:
async with _SDK.session(user_id) as s:
await _ensure_default_calendar(s, user_id)
rows = (
await s.execute(
text(
"SELECT id, name, color, kind, read_only FROM plg_calendar_calendars "
"ORDER BY name"
)
)
).mappings().all()
return [
{
"id": str(r["id"]),
"name": r["name"],
"color": r["color"],
"kind": r["kind"],
"read_only": r["read_only"],
}
for r in rows
]
@router.post("/calendars", dependencies=[Depends(require_csrf)])
async def create_calendar(body: CalendarIn, user_id: CurrentUserId) -> dict[str, str]:
try:
async with _SDK.session(user_id) as s:
new_id = (
await s.execute(
text(
"INSERT INTO plg_calendar_calendars (user_id, name, color) "
"VALUES (:u, :n, :c) RETURNING id"
),
{"u": str(user_id), "n": body.name, "c": body.color},
)
).scalar_one()
except IntegrityError as exc:
raise HTTPException(status_code=409, detail="calendar_exists") from exc
return {"id": str(new_id)}
@router.patch("/calendars/{calendar_id}", dependencies=[Depends(require_csrf)])
async def patch_calendar(
calendar_id: uuid.UUID, body: CalendarPatch, user_id: CurrentUserId
) -> dict[str, str]:
fields = body.model_dump(exclude_unset=True)
if not fields:
return {"status": "noop"}
set_sql = ", ".join(f"{k} = :{k}" for k in fields)
async with _SDK.session(user_id) as s:
result = await s.execute(
text(f"UPDATE plg_calendar_calendars SET {set_sql} WHERE id = :id"),
{**fields, "id": str(calendar_id)},
)
if result.rowcount == 0:
raise HTTPException(status_code=404, detail="calendar_not_found")
return {"status": "ok"}
@router.delete("/calendars/{calendar_id}", dependencies=[Depends(require_csrf)])
async def delete_calendar(calendar_id: uuid.UUID, user_id: CurrentUserId) -> dict[str, str]:
async with _SDK.session(user_id) as s:
result = await s.execute(
text("DELETE FROM plg_calendar_calendars WHERE id = :id"), {"id": str(calendar_id)}
)
if result.rowcount == 0:
raise HTTPException(status_code=404, detail="calendar_not_found")
return {"status": "ok"}
# --- events ------------------------------------------------------------------
@router.get("/events")
async def list_events(
user_id: CurrentUserId,
start: datetime = Query(...),
end: datetime = Query(...),
) -> dict[str, object]:
range_start, range_end = _aware(start), _aware(end)
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 "
"WHERE e.dtstart < :end"
),
{"end": range_end},
)
).mappings().all()
base = [
r
for r in rows
if r["recurrence_id"] is None
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)}
async def _insert_event(
s: AsyncSession, user_id: uuid.UUID, body: EventIn, attendees: list[dict[str, str]]
) -> tuple[uuid.UUID, str]:
ical_uid = f"{uuid.uuid4()}@webmail.local"
organizer = json.dumps(body.organizer.model_dump()) if body.organizer else None
new_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, reminders, attendees, organizer, color, show_as, "
" conference_url) "
"VALUES (:u, :cal, :uid, :sum, :desc, :loc, :start, :end, :allday, :tz, "
" :rrule, :rem, :att, :org, :color, :showas, :conf) RETURNING id"
),
{
"u": str(user_id),
"cal": str(body.calendar_id),
"uid": ical_uid,
"sum": body.summary,
"desc": body.description,
"loc": body.location,
"start": _aware(body.dtstart),
"end": _aware(body.dtend) if body.dtend else None,
"allday": body.all_day,
"tz": body.tz,
"rrule": body.rrule,
"rem": body.reminders,
"att": json.dumps(attendees),
"org": organizer,
"color": body.color,
"showas": body.show_as,
"conf": body.conference_url,
},
)
).scalar_one()
return new_id, ical_uid
def _merge_attendees(
existing: list[dict[str, str]] | None, incoming: list[dict[str, str]]
) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
"""Merge incoming attendees over existing: preserve prior RSVP status by email,
default new guests to needs-action. Returns (merged, newly-added)."""
prior = {str(a.get("email", "")).lower(): a for a in (existing or [])}
merged: list[dict[str, str]] = []
added: list[dict[str, str]] = []
for a in incoming:
email = a.get("email") or ""
if not email:
continue
match = prior.get(email.lower())
a["partstat"] = match.get("partstat", "needs-action") if match else "needs-action"
if match is None:
added.append(a)
merged.append(a)
return merged, added
def _strip_recurrence_end(rrule: str) -> str:
"""Drop any UNTIL=/COUNT= parts so a fresh UNTIL can be appended."""
return ";".join(p for p in rrule.split(";") if not p.startswith(("UNTIL=", "COUNT=")))
async def _send_invites_from(
user_id: uuid.UUID,
event_id: uuid.UUID,
ical_uid: str,
body: EventIn,
attendees: list[dict[str, str]],
*,
sequence: int = 0,
notify: set[str] | None = None,
) -> None:
"""Best-effort iMIP send for an event described by ``body`` + ``attendees``."""
if not attendees:
return
try:
await invites.send_invites(
user_id=user_id,
event_id=event_id,
ical_uid=ical_uid,
summary=body.summary,
description=body.description,
location=body.location,
dtstart=_aware(body.dtstart),
dtend=_aware(body.dtend) if body.dtend else None,
tz=body.tz,
all_day=body.all_day,
organizer=body.organizer.model_dump() if body.organizer else None,
attendees=attendees,
conference_url=body.conference_url,
sequence=sequence,
notify_emails=notify,
)
except Exception: # noqa: BLE001 - invitation delivery is best-effort
_log.warning("calendar: failed to send invitations for %s", event_id, exc_info=True)
@router.post("/events", dependencies=[Depends(require_csrf)])
async def create_event(body: EventIn, user_id: CurrentUserId) -> dict[str, str]:
attendees = [a.model_dump() for a in body.attendees]
async with _SDK.session(user_id) as s:
await _assert_writable(s, body.calendar_id)
new_id, ical_uid = await _insert_event(s, user_id, body, attendees)
# iMIP: email each guest from the organizer's mailbox (best-effort, after commit).
await _send_invites_from(user_id, new_id, ical_uid, body, attendees)
return {"id": str(new_id), "ical_uid": ical_uid}
@router.post("/events/{event_id}/edit-following", dependencies=[Depends(require_csrf)])
async def edit_following(
event_id: uuid.UUID, body: EditFollowingIn, user_id: CurrentUserId
) -> dict[str, str]:
"""Edit a recurring event with "this and following" semantics: occurrences BEFORE
the clicked one keep the original series; the clicked one onward become a new
series carrying the edits. Editing the first occurrence updates the series in place.
"""
ev = body.event
split = _aware(body.recurrence_start)
incoming = [a.model_dump() for a in ev.attendees]
async with _SDK.session(user_id) as s:
base = (
await s.execute(
text(
"SELECT calendar_id, ical_uid, dtstart, rrule, attendees, sequence "
"FROM plg_calendar_events WHERE id = :id"
),
{"id": str(event_id)},
)
).mappings().first()
if base is None:
raise HTTPException(status_code=404, detail="event_not_found")
await _assert_writable(s, base["calendar_id"])
base_start = _aware(base["dtstart"])
if base["rrule"] is None or split <= base_start:
# First occurrence (or non-recurring): update the whole series in place.
merged, added = _merge_attendees(base["attendees"], incoming)
organizer = json.dumps(ev.organizer.model_dump()) if ev.organizer else 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, reminders=:rem, attendees=CAST(:att AS jsonb), "
"organizer=CAST(:org AS jsonb), color=:color, show_as=:showas, "
"conference_url=:conf, sequence=sequence+1 WHERE id=:id"
),
{
"sum": ev.summary, "desc": ev.description, "loc": ev.location,
"start": _aware(ev.dtstart), "end": _aware(ev.dtend) if ev.dtend else None,
"allday": ev.all_day, "tz": ev.tz, "rrule": ev.rrule,
"rem": ev.reminders, "att": json.dumps(merged), "org": organizer,
"color": ev.color, "showas": ev.show_as, "conf": ev.conference_url,
"id": str(event_id),
},
)
target_id, target_uid = event_id, str(base["ical_uid"])
notify: set[str] | None = {str(a["email"]).lower() for a in added}
seq = int(base["sequence"]) + 1
merged_att = merged
else:
# Truncate the original series just before the split, then start a new one.
until = (split - timedelta(seconds=1)).astimezone(UTC).strftime("%Y%m%dT%H%M%SZ")
trimmed = _strip_recurrence_end(str(base["rrule"]))
new_rrule = f"{trimmed};UNTIL={until}" if trimmed else f"UNTIL={until}"
await s.execute(
text("UPDATE plg_calendar_events SET rrule=:r WHERE id=:id"),
{"r": new_rrule, "id": str(event_id)},
)
merged_att = [{**a, "partstat": "needs-action"} for a in incoming]
target_id, target_uid = await _insert_event(s, user_id, ev, merged_att)
notify = None # brand-new series → notify every guest
seq = 0
await _send_invites_from(
user_id, target_id, target_uid, ev, merged_att, sequence=seq, notify=notify
)
return {"status": "ok", "id": str(target_id)}
_PATCH_DATE_FIELDS = {"dtstart", "dtend"}
@router.patch("/events/{event_id}", dependencies=[Depends(require_csrf)])
async def patch_event(
event_id: uuid.UUID, body: EventPatch, user_id: CurrentUserId
) -> dict[str, str]:
fields = body.model_dump(exclude_unset=True)
# Attendees are merged separately (preserve prior RSVP status; find new guests).
new_attendees = fields.pop("attendees", None)
if not fields and new_attendees is None:
return {"status": "noop"}
for key in _PATCH_DATE_FIELDS & fields.keys():
if fields[key] is not None:
fields[key] = _aware(fields[key])
invite_new: list[dict[str, str]] = []
merged_attendees: list[dict[str, str]] | None = None
async with _SDK.session(user_id) as s:
existing = (
await s.execute(
text(
"SELECT calendar_id, ical_uid, summary, description, location, dtstart, "
"dtend, all_day, tz, conference_url, organizer, attendees, sequence "
"FROM plg_calendar_events WHERE id = :id"
),
{"id": str(event_id)},
)
).mappings().first()
if existing is None:
raise HTTPException(status_code=404, detail="event_not_found")
await _assert_writable(s, existing["calendar_id"])
params: dict[str, object] = {"id": str(event_id)}
set_parts = [f"{k} = :{k}" for k in fields]
params.update(fields)
if new_attendees is not None:
prior = {
str(a.get("email", "")).lower(): a for a in (existing["attendees"] or [])
}
merged_attendees = []
for a in new_attendees:
email = a.get("email") or ""
if not email:
continue
match = prior.get(email.lower())
a["partstat"] = match.get("partstat", "needs-action") if match else "needs-action"
if match is None:
invite_new.append(a)
merged_attendees.append(a)
set_parts.append("attendees = CAST(:attendees AS jsonb)")
params["attendees"] = json.dumps(merged_attendees)
# Bump SEQUENCE on every edit — iMIP re-sends require a higher sequence.
await s.execute(
text(
f"UPDATE plg_calendar_events SET {', '.join(set_parts)}, "
"sequence = sequence + 1 WHERE id = :id"
),
params,
)
# Email only the newly-added guests; the ICS lists the full (merged) roster.
if invite_new and merged_attendees is not None:
def eff(key: str) -> object:
return fields[key] if key in fields else existing[key]
try:
await invites.send_invites(
user_id=user_id,
event_id=event_id,
ical_uid=str(existing["ical_uid"]),
summary=str(eff("summary") or ""),
description=str(eff("description") or ""),
location=str(eff("location") or ""),
dtstart=_aware(eff("dtstart")), # type: ignore[arg-type]
dtend=_aware(eff("dtend")) if eff("dtend") else None, # type: ignore[arg-type]
tz=str(eff("tz") or "UTC"),
all_day=bool(eff("all_day")),
organizer=existing["organizer"],
attendees=merged_attendees,
conference_url=str(eff("conference_url") or ""),
sequence=int(existing["sequence"]) + 1,
notify_emails={str(a["email"]).lower() for a in invite_new},
)
except Exception: # noqa: BLE001 - invitation delivery is best-effort
_log.warning("calendar: edit invitations failed for %s", event_id, exc_info=True)
return {"status": "ok"}
@router.delete("/events/{event_id}", dependencies=[Depends(require_csrf)])
async def delete_event(
event_id: uuid.UUID,
user_id: CurrentUserId,
scope: str = Query("all"),
occurrence: datetime | None = Query(None),
) -> dict[str, str]:
"""Delete an event. For recurring events ``scope`` selects what to remove:
``all`` (whole series), ``this`` (add an EXDATE for ``occurrence``), or
``following`` (truncate the series just before ``occurrence``)."""
async with _SDK.session(user_id) as s:
row = (
await s.execute(
text("SELECT calendar_id, dtstart, rrule FROM plg_calendar_events WHERE id = :id"),
{"id": str(event_id)},
)
).mappings().first()
if row is None:
raise HTTPException(status_code=404, detail="event_not_found")
await _assert_writable(s, row["calendar_id"])
recurring = row["rrule"] is not None
if not recurring or scope == "all" or occurrence is None:
await s.execute(
text("DELETE FROM plg_calendar_events WHERE id = :id"), {"id": str(event_id)}
)
elif scope == "this":
await s.execute(
text(
"UPDATE plg_calendar_events "
"SET exdates = array_append(exdates, :occ) WHERE id = :id"
),
{"occ": _aware(occurrence), "id": str(event_id)},
)
elif scope == "following":
occ = _aware(occurrence)
if occ <= _aware(row["dtstart"]):
# Truncating from the first occurrence removes everything.
await s.execute(
text("DELETE FROM plg_calendar_events WHERE id = :id"), {"id": str(event_id)}
)
else:
until = (occ - timedelta(seconds=1)).astimezone(UTC).strftime("%Y%m%dT%H%M%SZ")
trimmed = _strip_recurrence_end(str(row["rrule"]))
new_rrule = f"{trimmed};UNTIL={until}" if trimmed else f"UNTIL={until}"
await s.execute(
text("UPDATE plg_calendar_events SET rrule = :r WHERE id = :id"),
{"r": new_rrule, "id": str(event_id)},
)
else:
raise HTTPException(status_code=400, detail="bad_scope")
return {"status": "ok"}
# --- RSVP (public: guests respond from the invitation email, no login) --------
def _rsvp_page(message: str) -> str:
return (
"<!doctype html><html><head><meta charset='utf-8'>"
"<meta name='viewport' content='width=device-width, initial-scale=1'>"
"<title>Invitation response</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"<p style='margin:0;color:#5f6368'>{message}</p></div></body></html>"
)
@router.get("/rsvp")
async def rsvp(t: str = Query(...), r: str = Query(...)) -> HTMLResponse:
parsed = invites.read_token(t)
if parsed is None or r not in invites.RESPONSES:
return HTMLResponse(
_rsvp_page("This invitation link is invalid or has expired."), status_code=400
)
user_id, event_id, email = parsed
async with _SDK.session(user_id) as s:
row = (
await s.execute(
text("SELECT attendees FROM plg_calendar_events WHERE id = :id"),
{"id": str(event_id)},
)
).mappings().first()
if row is None:
return HTMLResponse(_rsvp_page("This event no longer exists."), status_code=404)
attendees = list(row["attendees"] or [])
matched = False
for a in attendees:
if str(a.get("email", "")).lower() == email.lower():
a["partstat"] = r
matched = True
if not matched:
return HTMLResponse(
_rsvp_page("You are not on the guest list for this event."), status_code=404
)
await s.execute(
text("UPDATE plg_calendar_events SET attendees = CAST(:att AS jsonb) WHERE id = :id"),
{"att": json.dumps(attendees), "id": str(event_id)},
)
return HTMLResponse(_rsvp_page(f"Your response “{invites.RESPONSES[r]}” was recorded."))
# --- bundle ------------------------------------------------------------------
@router.get("/ui/{filename}")
async def bundle(filename: str) -> FileResponse:
if filename != "page.js":
raise HTTPException(status_code=404)
return FileResponse(_UI / filename, media_type="text/javascript")
@@ -0,0 +1,66 @@
"""Request models for the calendar API (SPEC §A)."""
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel
class CalendarIn(BaseModel):
name: str
color: str = "#3b82f6"
class CalendarPatch(BaseModel):
name: str | None = None
color: str | None = None
class Attendee(BaseModel):
email: str
name: str = ""
# RSVP status: needs-action | accepted | declined | tentative
partstat: str = "needs-action"
class EventIn(BaseModel):
calendar_id: uuid.UUID
summary: str = ""
description: str = ""
location: str = ""
dtstart: datetime
dtend: datetime | None = None
all_day: bool = False
tz: str = "UTC"
rrule: str | None = None
reminders: list[int] = []
attendees: list[Attendee] = [] # stored in A1; iMIP invites are sent in A2
organizer: Attendee | None = None
color: str | None = None # None = inherit the calendar colour
show_as: str = "busy" # busy | free
conference_url: str = "" # Jitsi Meet video-call link; empty = none
class EditFollowingIn(BaseModel):
# ``recurrence_start`` = the clicked occurrence's original start; ``event`` = the
# edited fields. Splits the series so occurrences BEFORE it are untouched.
recurrence_start: datetime
event: EventIn
class EventPatch(BaseModel):
summary: str | None = None
description: str | None = None
location: str | None = None
dtstart: datetime | None = None
dtend: datetime | None = None
all_day: bool | None = None
tz: str | None = None
rrule: str | None = None
reminders: list[int] | None = None
attendees: list[Attendee] | None = None
color: str | None = None
show_as: str | None = None
conference_url: str | None = None
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB