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:
@@ -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))
|
||||
Reference in New Issue
Block a user