From d46ad13b7b0da11816f301eee0eb355b12535ee7 Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Mon, 27 Jul 2026 08:30:24 +0200 Subject: [PATCH] M3 plugin platform: frontend host, reference plugins, UI kit + docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes M3 (SPEC §11 + §12 acceptance) on top of the backend platform (4e077f1). Frontend host (§11.3): - src/plugins/: manifest loader, runtime ESM bundle loader (framework-agnostic mount contract), PluginSlot, PluginPage (app.page at /p/*), plugin context (scoped api/events/i18n), capabilities hook. Wired slots into ThreadView (message.actions/message.banner), MailLayout sidebar (sidebar.section + page nav) and Settings→Accounts (settings.page); ws.ts fans out core + plugin.* events. - plugin-ui.css: theme-aware .wm-* classes so bundles don't re-derive inline styles. SDK additions: - CurrentUserId + require_csrf (plugin-router auth), messages.envelope/header, actions.set_flags/trash, sdk.enqueue. Reference plugins (plugins/, installed via webmail.plugins entry points): - hello: skeleton page-plugin (app.page + plg_hello_note w/ RLS + cron job) — install→run→uninstall --drop-data leaves zero residue. - labels: message.actions + sidebar.section slots, KV only, zero core diffs. - filters: message.new hook -> match (subject/from/to) -> Mark read / Pin / Delete (delete = move thread to Trash via the core path). docs/plugins.md: full authoring guide + a complete "Unsubscribe button" worked example (the §12 "writable from docs alone" bar). Also: conftest resets the arq pool between tests (loop-bound global, like engine/redis). Gates: backend ruff + mypy --strict (107) + full suite 337; frontend tsc + eslint + vitest (11) + vite build. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Rcj3TpydMTzTSmU7kunzf8 --- backend/app/plugins/actions.py | 119 ++++++ backend/app/plugins/sdk.py | 29 +- backend/tests/conftest.py | 4 + backend/tests/plugins/test_filters_plugin.py | 156 +++++++ backend/tests/plugins/test_hello_plugin.py | 103 +++++ backend/tests/plugins/test_labels_plugin.py | 102 +++++ backend/tests/plugins/test_sdk_messages.py | 67 +++ docs/plugins.md | 384 ++++++++++++++++++ frontend/src/App.tsx | 9 + frontend/src/components/thread/ThreadView.tsx | 5 + frontend/src/lib/api.ts | 4 +- frontend/src/lib/ws.ts | 28 +- frontend/src/main.tsx | 1 + frontend/src/plugins/PluginPage.tsx | 75 ++++ frontend/src/plugins/PluginSlot.tsx | 63 +++ frontend/src/plugins/bundleLoader.ts | 35 ++ frontend/src/plugins/capabilities.ts | 34 ++ frontend/src/plugins/context.ts | 33 ++ frontend/src/plugins/manifest.ts | 39 ++ frontend/src/plugins/plugin-ui.css | 128 ++++++ frontend/src/plugins/types.ts | 69 ++++ frontend/src/routes/MailLayout.tsx | 20 + frontend/src/routes/settings/Settings.tsx | 8 + frontend/src/test/pluginBundleLoader.test.ts | 35 ++ frontend/src/test/pluginCapabilities.test.ts | 36 ++ frontend/src/test/pluginSlot.test.tsx | 69 ++++ plugins/webmail_plugin_filters/pyproject.toml | 19 + .../webmail_plugin_filters/__init__.py | 23 ++ .../webmail_plugin_filters/hook.py | 62 +++ .../webmail_plugin_filters/router.py | 51 +++ .../webmail_plugin_filters/ui/settings.js | 84 ++++ plugins/webmail_plugin_hello/pyproject.toml | 20 + .../webmail_plugin_hello/__init__.py | 34 ++ .../webmail_plugin_hello/jobs.py | 13 + .../migrations/versions/0001_note.py | 33 ++ .../webmail_plugin_hello/router.py | 55 +++ .../webmail_plugin_hello/ui/page.js | 49 +++ plugins/webmail_plugin_labels/pyproject.toml | 19 + .../webmail_plugin_labels/__init__.py | 23 ++ .../webmail_plugin_labels/router.py | 55 +++ .../webmail_plugin_labels/ui/actions.js | 62 +++ .../webmail_plugin_labels/ui/sidebar.js | 30 ++ 42 files changed, 2284 insertions(+), 3 deletions(-) create mode 100644 backend/app/plugins/actions.py create mode 100644 backend/tests/plugins/test_filters_plugin.py create mode 100644 backend/tests/plugins/test_hello_plugin.py create mode 100644 backend/tests/plugins/test_labels_plugin.py create mode 100644 backend/tests/plugins/test_sdk_messages.py create mode 100644 docs/plugins.md create mode 100644 frontend/src/plugins/PluginPage.tsx create mode 100644 frontend/src/plugins/PluginSlot.tsx create mode 100644 frontend/src/plugins/bundleLoader.ts create mode 100644 frontend/src/plugins/capabilities.ts create mode 100644 frontend/src/plugins/context.ts create mode 100644 frontend/src/plugins/manifest.ts create mode 100644 frontend/src/plugins/plugin-ui.css create mode 100644 frontend/src/plugins/types.ts create mode 100644 frontend/src/test/pluginBundleLoader.test.ts create mode 100644 frontend/src/test/pluginCapabilities.test.ts create mode 100644 frontend/src/test/pluginSlot.test.tsx create mode 100644 plugins/webmail_plugin_filters/pyproject.toml create mode 100644 plugins/webmail_plugin_filters/webmail_plugin_filters/__init__.py create mode 100644 plugins/webmail_plugin_filters/webmail_plugin_filters/hook.py create mode 100644 plugins/webmail_plugin_filters/webmail_plugin_filters/router.py create mode 100644 plugins/webmail_plugin_filters/webmail_plugin_filters/ui/settings.js create mode 100644 plugins/webmail_plugin_hello/pyproject.toml create mode 100644 plugins/webmail_plugin_hello/webmail_plugin_hello/__init__.py create mode 100644 plugins/webmail_plugin_hello/webmail_plugin_hello/jobs.py create mode 100644 plugins/webmail_plugin_hello/webmail_plugin_hello/migrations/versions/0001_note.py create mode 100644 plugins/webmail_plugin_hello/webmail_plugin_hello/router.py create mode 100644 plugins/webmail_plugin_hello/webmail_plugin_hello/ui/page.js create mode 100644 plugins/webmail_plugin_labels/pyproject.toml create mode 100644 plugins/webmail_plugin_labels/webmail_plugin_labels/__init__.py create mode 100644 plugins/webmail_plugin_labels/webmail_plugin_labels/router.py create mode 100644 plugins/webmail_plugin_labels/webmail_plugin_labels/ui/actions.js create mode 100644 plugins/webmail_plugin_labels/webmail_plugin_labels/ui/sidebar.js diff --git a/backend/app/plugins/actions.py b/backend/app/plugins/actions.py new file mode 100644 index 0000000..c2e13dd --- /dev/null +++ b/backend/app/plugins/actions.py @@ -0,0 +1,119 @@ +"""Message read + flag helpers exposed to plugins via the SDK (SPEC §11). + +Plugins like ``filters`` need to inspect a newly-arrived message and flag it. +Both operations are user-scoped: a plugin can only read/act on the given user's +own message (the same app-level ``user_id`` scoping core uses — core tables have +no RLS). Flagging mirrors the core action path: update the DB, enqueue the IMAP +push, and publish ``flags.changed`` so the UI updates. + +(Only flag actions are exposed for now; a ``move`` action would follow the same +wrap-the-core-path pattern.) +""" + +from __future__ import annotations + +import uuid +from collections.abc import Iterable +from email import message_from_bytes +from typing import Any + +from sqlalchemy import select + +from app.db import session_scope +from app.events import get_event_bus +from app.indexer import blobstore +from app.models import Message + + +class PluginMessages: + """Read-only, user-scoped view of a message's matchable fields.""" + + async def header(self, user_id: uuid.UUID, message_id: uuid.UUID, name: str) -> str | None: + """Return a raw header (e.g. ``List-Unsubscribe``) from the user's message, + or None if the message/header/blob is missing. Parses the stored RFC822.""" + async with session_scope() as session: + msg = await session.scalar( + select(Message).where(Message.id == message_id, Message.user_id == user_id) + ) + if msg is None or not msg.blob_sha256: + return None + sha = msg.blob_sha256 + try: + raw = blobstore.load(user_id, sha) + except FileNotFoundError: + return None + return message_from_bytes(raw).get(name) + + async def envelope(self, user_id: uuid.UUID, message_id: uuid.UUID) -> dict[str, Any] | None: + async with session_scope() as session: + msg = await session.scalar( + select(Message).where(Message.id == message_id, Message.user_id == user_id) + ) + if msg is None: + return None + sender = msg.from_addr[0] if msg.from_addr else {} + return { + "message_id": str(msg.id), + "subject": msg.subject or "", + "from_email": (sender.get("email") or "").lower(), + "from_name": sender.get("name") or "", + # Full recipient list (multi-account setups match on "to"). + "to": [ + {"email": (a.get("email") or "").lower(), "name": a.get("name") or ""} + for a in msg.to_addr + ], + "flags": list(msg.flags), + } + + +class PluginActions: + """User-scoped actions a plugin can take on a message.""" + + async def set_flags( + self, + user_id: uuid.UUID, + message_id: uuid.UUID, + *, + add: Iterable[str] = (), + remove: Iterable[str] = (), + ) -> bool: + """Add/remove IMAP flags on the user's message (mirrors the core path). + + Only user-settable flags survive (\\Seen/\\Flagged/\\Answered). Returns + False if nothing owned matches or nothing would change.""" + from app.queue import enqueue_job + from app.sync.actions import apply_flags_to_messages, sanitize_flags + + add_f, remove_f = sanitize_flags(add), sanitize_flags(remove) + if not add_f and not remove_f: + return False + async with session_scope() as session: + msg = await session.scalar( + select(Message).where(Message.id == message_id, Message.user_id == user_id) + ) + if msg is None: + return False + await apply_flags_to_messages(session, [msg], add_f, remove_f) + flags = list(msg.flags) + await enqueue_job("push_flags_job", str(message_id), sorted(add_f), sorted(remove_f)) + await get_event_bus().publish( + "flags.changed", + {"user_id": str(user_id), "message_id": str(message_id), "flags": flags}, + ) + return True + + async def trash(self, user_id: uuid.UUID, message_id: uuid.UUID) -> bool: + """Move the message's thread to Trash — the same path as the UI Delete + (move_thread_to_folder_job handles the IMAP move, restore state, folder + creation and events). Returns False if the message isn't the user's.""" + from app.queue import enqueue_job + + async with session_scope() as session: + msg = await session.scalar( + select(Message).where(Message.id == message_id, Message.user_id == user_id) + ) + if msg is None or msg.thread_id is None: + return False + thread_id = msg.thread_id + await enqueue_job("move_thread_to_folder_job", str(thread_id), "trash") + return True diff --git a/backend/app/plugins/sdk.py b/backend/app/plugins/sdk.py index e96e06c..a3c6b46 100644 --- a/backend/app/plugins/sdk.py +++ b/backend/app/plugins/sdk.py @@ -15,15 +15,19 @@ from __future__ import annotations import uuid from contextlib import AbstractAsyncContextManager from dataclasses import dataclass -from typing import Any +from typing import Annotated, Any +from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession from app.accounts.ssrf import guarded_get from app.config import Settings, get_settings from app.events import get_event_bus +from app.identity.deps import current_user, require_csrf from app.indexer import blobstore +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.kv import PluginKvStore from app.plugins.types import ( @@ -39,6 +43,7 @@ from app.plugins.types import ( from app.queue import enqueue_job __all__ = [ + "CurrentUserId", "Hook", "JobFunc", "JobSpec", @@ -49,10 +54,21 @@ __all__ = [ "Slot", "UiManifest", "blobstore", + "require_csrf", "sdk", ] +async def _current_user_id(user: Annotated[User, Depends(current_user)]) -> uuid.UUID: + return user.id + + +# Auth for plugin routers (plugins must not import app.identity directly, SPEC §11): +# `CurrentUserId` resolves the authenticated user's id; `require_csrf` guards mutating +# routes. Use with the RLS-scoped `sdk(name).session(user_id)`. +CurrentUserId = Annotated[uuid.UUID, Depends(_current_user_id)] + + @dataclass(frozen=True) class PluginSdk: """A lightweight facade over shared services, scoped to one plugin. @@ -105,6 +121,17 @@ class PluginSdk: """Draft/send helpers (``upsert_draft``, ``create_outbox``, …).""" return outbox_service + # --- messages ----------------------------------------------------------- + @property + def messages(self) -> PluginMessages: + """Read a user's message envelope (subject/sender/flags) for matching.""" + return PluginMessages() + + @property + def actions(self) -> PluginActions: + """User-scoped message actions (``set_flags``) — e.g. for filter rules.""" + return PluginActions() + # --- outbound HTTP ------------------------------------------------------ async def http_get(self, url: str) -> str | None: """GET ``url`` through the §10.6 SSRF guard (blocks private IPs/oversize).""" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 0bc676c..169724a 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -43,10 +43,14 @@ async def _reset_global_engine() -> AsyncIterator[None]: await get_redis().flushdb() yield from app.db import dispose_engine + from app.queue import close_arq_pool from app.redis_client import close_redis await dispose_engine() await close_redis() + # The arq enqueue pool is a loop-bound global like the engine/redis; reset it so + # a test that enqueues doesn't reuse a pool from a previous (closed) event loop. + await close_arq_pool() @pytest_asyncio.fixture diff --git a/backend/tests/plugins/test_filters_plugin.py b/backend/tests/plugins/test_filters_plugin.py new file mode 100644 index 0000000..fd66411 --- /dev/null +++ b/backend/tests/plugins/test_filters_plugin.py @@ -0,0 +1,156 @@ +"""filters reference plugin: message.new hook match->flag + rule CRUD (SPEC §11, §12 M3). + +Fires the hook directly with a message.new payload (as the worker would) and checks +the DB flag was applied; also exercises the rule CRUD HTTP surface. Hook + KV + slot, +zero core diffs. +""" + +from __future__ import annotations + +import uuid +from collections.abc import AsyncIterator +from datetime import UTC, datetime + +import httpx +import pytest +import pytest_asyncio +from httpx import ASGITransport +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool +from webmail_plugin_filters.hook import on_message_new # type: ignore[import-not-found] + +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 Account, Mailbox, Message, Thread, User +from app.plugins.sdk import sdk + + +class _Ctx: + user_id: uuid.UUID + message_id: uuid.UUID + thread_id: uuid.UUID + sm: async_sessionmaker # type: ignore[type-arg] + client: 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 + async with sm() as s: + user = User( + handle=f"fl{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw") + ) + s.add(user) + await s.flush() + account = Account( + user_id=user.id, email="me@example.com", imap_config={}, smtp_config={}, + secret_ct=b"", discovery_src="manual", + ) + s.add(account) + await s.flush() + mbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox") + trash = Mailbox(account_id=account.id, user_id=user.id, path="Trash", special_use="trash") + s.add_all([mbox, trash]) + await s.flush() + thread = Thread( + user_id=user.id, subject_norm="your invoice 123", last_message_at=datetime.now(UTC) + ) + s.add(thread) + await s.flush() + msg = Message( + user_id=user.id, account_id=account.id, mailbox_id=mbox.id, uid=1, + thread_id=thread.id, + subject="Your Invoice 123", + from_addr=[{"name": "Billing", "email": "billing@acme.com"}], + to_addr=[{"name": "Me", "email": "me@example.com"}], + flags=[], internal_date=datetime.now(UTC), blob_sha256="", + ) + s.add(msg) + await s.commit() + c.user_id, c.message_id, c.thread_id = user.id, msg.id, thread.id + + token = await create_session(c.user_id, "pytest") + c.client = httpx.AsyncClient( + transport=ASGITransport(app=create_app()), + base_url="http://testserver", + cookies={"session": token, "csrf": "csrf"}, + headers={"X-CSRF": "csrf"}, + ) + try: + yield c + finally: + await c.client.aclose() + async with sm() as s: + await s.execute(delete(User).where(User.id == c.user_id)) + await s.commit() + await engine.dispose() + + +async def _flags(ctx: _Ctx) -> list[str]: + async with ctx.sm() as s: + msg = await s.scalar(select(Message).where(Message.id == ctx.message_id)) + return list(msg.flags) if msg else [] + + +async def test_hook_applies_flag_on_match(ctx: _Ctx) -> None: + await sdk("filters").kv(ctx.user_id).set( + "_rules", [{"field": "subject", "contains": "invoice", "action": "read"}] + ) + await on_message_new({"user_id": str(ctx.user_id), "message_id": str(ctx.message_id)}) + assert "\\Seen" in await _flags(ctx) + + +async def test_hook_no_match_leaves_flags(ctx: _Ctx) -> None: + await sdk("filters").kv(ctx.user_id).set( + "_rules", [{"field": "from", "contains": "newsletter@somewhere", "action": "pin"}] + ) + await on_message_new({"user_id": str(ctx.user_id), "message_id": str(ctx.message_id)}) + assert await _flags(ctx) == [] + + +async def test_hook_pin_applies_flagged(ctx: _Ctx) -> None: + await sdk("filters").kv(ctx.user_id).set( + "_rules", [{"field": "from", "contains": "billing@acme", "action": "pin"}] + ) + await on_message_new({"user_id": str(ctx.user_id), "message_id": str(ctx.message_id)}) + assert "\\Flagged" in await _flags(ctx) # "pin" == the \Flagged flag in this app + + +async def test_hook_matches_on_to(ctx: _Ctx) -> None: + # Multi-account setups match on the recipient address. + await sdk("filters").kv(ctx.user_id).set( + "_rules", [{"field": "to", "contains": "me@example.com", "action": "read"}] + ) + await on_message_new({"user_id": str(ctx.user_id), "message_id": str(ctx.message_id)}) + assert "\\Seen" in await _flags(ctx) + + +async def test_hook_trash_moves_thread_to_trash( + ctx: _Ctx, monkeypatch: pytest.MonkeyPatch +) -> None: + calls: list[tuple[object, ...]] = [] + + async def fake_enqueue(name: str, *args: object, **kwargs: object) -> None: + calls.append((name, *args)) + + # trash() enqueues the same job the UI Delete uses (imported from app.queue). + monkeypatch.setattr("app.queue.enqueue_job", fake_enqueue) + await sdk("filters").kv(ctx.user_id).set( + "_rules", [{"field": "subject", "contains": "invoice", "action": "trash"}] + ) + await on_message_new({"user_id": str(ctx.user_id), "message_id": str(ctx.message_id)}) + assert ("move_thread_to_folder_job", str(ctx.thread_id), "trash") in calls + + +async def test_rules_crud_over_http(ctx: _Ctx) -> None: + assert (await ctx.client.get("/api/plugins/filters/rules")).json() == [] + rule = {"field": "subject", "contains": "invoice", "action": "pin"} + r = await ctx.client.put("/api/plugins/filters/rules", json={"rules": [rule]}) + assert r.status_code == 200 and r.json()["count"] == 1 + assert (await ctx.client.get("/api/plugins/filters/rules")).json() == [rule] diff --git a/backend/tests/plugins/test_hello_plugin.py b/backend/tests/plugins/test_hello_plugin.py new file mode 100644 index 0000000..71d7985 --- /dev/null +++ b/backend/tests/plugins/test_hello_plugin.py @@ -0,0 +1,103 @@ +"""End-to-end test of the hello reference plugin (SPEC §11, §12 M3 acceptance). + +Exercises the whole vertical against the real HTTP surface: entry-point discovery +-> router mounted at /api/plugins/hello -> SDK auth (CurrentUserId) -> RLS-scoped +session -> the plg_hello_note table. Also proves per-user isolation: one user never +sees another's notes. Requires the plugin installed + `install hello` run. +""" + +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 + + +async def _mk_user(sm: async_sessionmaker) -> uuid.UUID: # type: ignore[type-arg] + async with sm() as s: + user = User( + handle=f"hp{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw") + ) + s.add(user) + await s.commit() + return user.id + + +class _Ctx: + user_a: uuid.UUID + user_b: uuid.UUID + client_a: 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.user_a = await _mk_user(sm) + c.user_b = await _mk_user(sm) + 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_a = await client(c.user_a) + c.client_b = await client(c.user_b) + try: + yield c + finally: + await c.client_a.aclose() + await c.client_b.aclose() + async with sm() as s: + await s.execute( + delete(User).where(User.id.in_([c.user_a, c.user_b])) + ) + await s.execute( + text("DELETE FROM plg_hello_note WHERE user_id IN (:a, :b)"), + {"a": str(c.user_a), "b": str(c.user_b)}, + ) + await s.commit() + await engine.dispose() + + +async def test_notes_crud_and_rls_isolation(ctx: _Ctx) -> None: + # A adds a note and reads it back. + r = await ctx.client_a.post("/api/plugins/hello/notes", json={"body": "a-note"}) + assert r.status_code == 200 + a_notes = (await ctx.client_a.get("/api/plugins/hello/notes")).json() + assert [n["body"] for n in a_notes] == ["a-note"] + + # B sees none of A's notes (RLS), then adds and sees only its own. + assert (await ctx.client_b.get("/api/plugins/hello/notes")).json() == [] + await ctx.client_b.post("/api/plugins/hello/notes", json={"body": "b-note"}) + b_notes = (await ctx.client_b.get("/api/plugins/hello/notes")).json() + assert [n["body"] for n in b_notes] == ["b-note"] + + # A still sees only its own note. + a_again = (await ctx.client_a.get("/api/plugins/hello/notes")).json() + assert [n["body"] for n in a_again] == ["a-note"] + + +async def test_notes_requires_auth(ctx: _Ctx) -> None: + transport = ASGITransport(app=create_app()) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as anon: + assert (await anon.get("/api/plugins/hello/notes")).status_code == 401 diff --git a/backend/tests/plugins/test_labels_plugin.py b/backend/tests/plugins/test_labels_plugin.py new file mode 100644 index 0000000..7e09fbb --- /dev/null +++ b/backend/tests/plugins/test_labels_plugin.py @@ -0,0 +1,102 @@ +"""End-to-end test of the labels reference plugin (SPEC §11, §12 M3 acceptance). + +Slots + KV only, zero core diffs. Verifies the real HTTP surface (discovery -> +router -> auth -> KV) and that labels are per-user isolated by construction: one +user never sees another's labels or label set. Requires the plugin installed. +""" + +from __future__ import annotations + +import uuid +from collections.abc import AsyncIterator + +import httpx +import pytest_asyncio +from httpx import ASGITransport +from sqlalchemy import delete +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 + + +class _Ctx: + users: list[uuid.UUID] + client_a: 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.users = [] + async with sm() as s: + for _ in range(2): + user = User( + handle=f"lb{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw") + ) + s.add(user) + await s.flush() + c.users.append(user.id) + await s.commit() + + 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_a = await client(c.users[0]) + c.client_b = await client(c.users[1]) + try: + yield c + finally: + await c.client_a.aclose() + await c.client_b.aclose() + async with sm() as s: # cascade drops the users' plugin_kv rows + await s.execute(delete(User).where(User.id.in_(c.users))) + await s.commit() + await engine.dispose() + + +async def test_labels_crud_and_per_user_isolation(ctx: _Ctx) -> None: + mid = "msg-1" + msg_path = f"/api/plugins/labels/message/{mid}/labels" + + # A labels a message; reads it back; the label joins A's known set. + await ctx.client_a.put(msg_path, json={"labels": ["work", "urgent"]}) + assert (await ctx.client_a.get(msg_path)).json() == [ + "urgent", + "work", + ] + assert (await ctx.client_a.get("/api/plugins/labels/labels")).json() == ["urgent", "work"] + + # B sees neither A's per-message labels nor A's label set (KV is per-user). + assert (await ctx.client_b.get(msg_path)).json() == [] + assert (await ctx.client_b.get("/api/plugins/labels/labels")).json() == [] + + # Empties/whitespace are dropped and the set is deduped/sorted. + await ctx.client_a.put(msg_path, json={"labels": ["work", " ", "later"]}) + assert (await ctx.client_a.get(msg_path)).json() == ["later", "work"] + assert (await ctx.client_a.get("/api/plugins/labels/labels")).json() == [ + "later", + "urgent", + "work", + ] + + +async def test_labels_requires_auth(ctx: _Ctx) -> None: + transport = ASGITransport(app=create_app()) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as anon: + assert (await anon.get("/api/plugins/labels/labels")).status_code == 401 diff --git a/backend/tests/plugins/test_sdk_messages.py b/backend/tests/plugins/test_sdk_messages.py new file mode 100644 index 0000000..3a5cb92 --- /dev/null +++ b/backend/tests/plugins/test_sdk_messages.py @@ -0,0 +1,67 @@ +"""SDK message-read helpers: envelope + raw header access (SPEC §11).""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from email.message import EmailMessage + +from sqlalchemy import delete +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.indexer import blobstore +from app.models import Account, Mailbox, Message, User +from app.plugins.sdk import sdk + + +async def test_messages_header_reads_raw_header() -> None: + engine = create_async_engine(get_settings().database_url, poolclass=NullPool) + sm = async_sessionmaker(engine, expire_on_commit=False) + async with sm() as s: + user = User( + handle=f"hd{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw") + ) + s.add(user) + await s.flush() + account = Account( + user_id=user.id, email="me@example.com", imap_config={}, smtp_config={}, + secret_ct=b"", discovery_src="manual", + ) + s.add(account) + await s.flush() + mbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox") + s.add(mbox) + await s.flush() + + raw = EmailMessage() + raw["Subject"] = "Weekly digest" + raw["From"] = "news@list.example" + raw["List-Unsubscribe"] = "" + raw.set_content("hello") + sha = blobstore.store(user.id, raw.as_bytes()) + + msg = Message( + user_id=user.id, account_id=account.id, mailbox_id=mbox.id, uid=1, + subject="Weekly digest", flags=[], internal_date=datetime.now(UTC), blob_sha256=sha, + ) + s.add(msg) + await s.commit() + user_id, message_id = user.id, msg.id + + try: + messages = sdk("unsubscribe").messages + assert await messages.header(user_id, message_id, "List-Unsubscribe") == ( + "" + ) + assert await messages.header(user_id, message_id, "X-Nope") is None + assert await messages.header(user_id, uuid.uuid4(), "List-Unsubscribe") is None + # Another user can't read this message's headers. + assert await messages.header(uuid.uuid4(), message_id, "List-Unsubscribe") is None + finally: + async with sm() as s: + await s.execute(delete(User).where(User.id == user_id)) + await s.commit() + await engine.dispose() diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..622e5fb --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,384 @@ +# Writing a plugin + +The webmail app has a plugin platform (SPEC §11): self-contained Python packages +that add hooks, HTTP routes, background jobs, database tables, and UI — with **zero +core diffs**. The built-in Calendar and Contacts features are plugins; so are +Filters and Labels. This guide is everything you need to write one. + +> A plugin may import from **`app.plugins.sdk` only** — never other `app.*` +> internals. Everything below is reachable through the SDK. + +--- + +## 1. Anatomy + +A plugin is a Python package `webmail_plugin_` that exposes a module-level +`plugin = Plugin(...)` and registers it under the `webmail.plugins` entry-point group. + +``` +plugins/webmail_plugin_myplugin/ + pyproject.toml + webmail_plugin_myplugin/ + __init__.py # plugin = Plugin(...) + router.py # optional APIRouter + hook.py # optional event handlers + jobs.py # optional background jobs + migrations/versions/ # optional plg_ tables (heavy plugins) + ui/*.js # optional frontend bundles (slots / pages) +``` + +`pyproject.toml`: + +```toml +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "webmail-plugin-myplugin" +version = "0.1.0" +requires-python = ">=3.11" + +[project.entry-points."webmail.plugins"] +myplugin = "webmail_plugin_myplugin:plugin" + +[tool.setuptools] +packages = ["webmail_plugin_myplugin"] +[tool.setuptools.package-data] +webmail_plugin_myplugin = ["ui/*.js", "migrations/versions/*.py"] +``` + +`__init__.py`: + +```python +from app.plugins.sdk import Plugin, Slot, UiManifest +from webmail_plugin_myplugin.router import router + +plugin = Plugin( + name="myplugin", # [a-z][a-z0-9_]*, unique + version="0.1.0", + hooks={}, # {event: async handler} (§4) + router=router, # mounted at /api/plugins/myplugin (§5) + ui_manifest=UiManifest(...), # slots / pages / capabilities (§8) + migrations=None, # path to a versions/ dir (§7) + jobs=[], # [JobSpec(...)] (§6) + builtin=False, # True = core, non-removable +) +``` + +Install & lifecycle: + +``` +pip install -e plugins/webmail_plugin_myplugin # register the entry point +python -m app.plugins.cli list # show discovered plugins +python -m app.plugins.cli install myplugin # run its migrations (if any) +python -m app.plugins.cli uninstall myplugin --drop-data # downgrade + drop tables +``` + +`builtin=True` plugins refuse `uninstall`. Restart the **worker** after installing +(it doesn't auto-reload; the API does). + +--- + +## 2. The SDK + +`sdk("")` returns a facade bound to your plugin. It's cheap — call it on demand. + +```python +from app.plugins.sdk import sdk +s = sdk("myplugin") +``` + +| Member | What it gives you | +|---|---| +| `s.session(user_id)` | RLS-scoped async DB session for your `plg_*` tables (§7) | +| `s.kv(user_id)` | namespaced key/value store: `get/set/delete/items` (§3) | +| `await s.publish(event, payload)` | publish `plugin.myplugin.{event}` (payload needs `user_id`) | +| `await s.enqueue(job_func, **kwargs)` | enqueue one of your jobs (§6) | +| `s.blob` | blob store (`store`/`load`/`exists`/`remove`), keyed by user | +| `s.outbox` | draft/send helpers (`upsert_draft`, `create_outbox`) | +| `await s.http_get(url)` | HTTPS GET through the SSRF guard (blocks private IPs) | +| `s.messages` | read a message: `envelope(user_id, id)`, `header(user_id, id, name)` | +| `s.actions` | act on a message: `set_flags(...)`, `trash(user_id, id)` | + +Plus, for routers: `CurrentUserId` (auth dependency → the user's uuid) and +`require_csrf` (guard mutating routes). + +`s.messages.envelope(user_id, message_id)` returns +`{message_id, subject, from_email, from_name, to: [{email,name}], flags}` or `None`. +`s.messages.header(user_id, message_id, "List-Unsubscribe")` returns a raw header. +`s.actions.set_flags(user_id, id, add=["\\Seen"], remove=[])` — user-settable flags +only (`\Seen`, `\Flagged` [= "Pin"], `\Answered`). `s.actions.trash(user_id, id)` +moves the message's thread to Trash (same as the UI Delete). + +--- + +## 3. KV store + +Light per-user state, no schema needed: + +```python +kv = sdk("myplugin").kv(user_id) +await kv.set("rules", [{"match": "boss"}]) +await kv.get("rules", default=[]) +await kv.delete("rules") +await kv.items() # {key: value, ...} +``` + +Keyed by `(plugin, user_id, key)` — users can't see each other's values. + +--- + +## 4. Hooks + +Subscribe to §8.3 events (`message.new`, `flags.changed`, `thread.updated`, …). +Handlers are `async def h(payload: dict) -> None`; every payload carries `user_id`. +They fire wherever the event is published — `message.new` originates in the **worker**. + +```python +# hook.py +from app.plugins.sdk import sdk +import uuid + +async def on_message_new(payload): + user_id = uuid.UUID(payload["user_id"]) + message_id = uuid.UUID(payload["message_id"]) + env = await sdk("myplugin").messages.envelope(user_id, message_id) + if env and "urgent" in env["subject"].lower(): + await sdk("myplugin").actions.set_flags(user_id, message_id, add=["\\Flagged"]) +``` + +Register in the `Plugin(hooks={"message.new": on_message_new})`. + +--- + +## 5. Router + +An `APIRouter` mounted at `/api/plugins/`. Use `CurrentUserId` for auth and +`require_csrf` on mutating routes; do DB work through the SDK. + +```python +# router.py +from fastapi import APIRouter, Depends +from app.plugins.sdk import CurrentUserId, require_csrf, sdk + +router = APIRouter() + +@router.get("/rules") +async def get_rules(user_id: CurrentUserId) -> list: + return await sdk("myplugin").kv(user_id).get("rules", []) + +@router.put("/rules", dependencies=[Depends(require_csrf)]) +async def set_rules(rules: list[dict], user_id: CurrentUserId) -> dict: + await sdk("myplugin").kv(user_id).set("rules", rules) + return {"status": "ok"} +``` + +To serve a UI bundle, add a `FileResponse` route (see §8). + +--- + +## 6. Background jobs + +```python +# jobs.py +async def sync_now(ctx, *, user_id): ... # ctx is the arq context +``` + +```python +from app.plugins.sdk import JobSpec +jobs = [ + JobSpec(func=sync_now, schedule="*/15 * * * *", singleton=True), +] +``` + +- `schedule`: standard 5-field cron (`min hour dom mon dow`), or `None` for + enqueue-on-demand via `await sdk("myplugin").enqueue(sync_now, user_id=...)`. +- `singleton=True`: at most one running instance per `(plugin, user_id)` (Redis lock). + +Jobs share the worker pool + retry semantics and log under `plg.myplugin.*`. + +--- + +## 7. Plugin tables (heavy plugins) — migrations + RLS + +Only if you need real tables. Ship an Alembic `versions/` dir and point +`Plugin(migrations=".../versions")` at it. Rules: + +- Every table is prefixed **`plg__`**. +- Every user-data table has a **`user_id uuid`** column. +- Enable **Row-Level Security** right after creating the table — the DB then enforces + per-user isolation so a forgotten `WHERE user_id` can't leak across users. + +```python +# migrations/versions/0001_init.py +from alembic import op +from app.plugins.rls import enable_rls + +revision = "myplugin0001" +down_revision = None + +def upgrade(): + op.execute( + "CREATE TABLE plg_myplugin_note (" + "id uuid PRIMARY KEY DEFAULT gen_random_uuid(), " + "user_id uuid NOT NULL, body text NOT NULL DEFAULT '')" + ) + enable_rls("plg_myplugin_note") + +def downgrade(): + op.execute("DROP TABLE IF EXISTS plg_myplugin_note") +``` + +Query only through `sdk(...).session(user_id)` — it scopes the transaction to that +user, so RLS applies: + +```python +async with sdk("myplugin").session(user_id) as db: + rows = (await db.execute(text("SELECT body FROM plg_myplugin_note"))).all() +``` + +`install` runs `upgrade`; `uninstall --drop-data` runs `downgrade` and drops the +version table, leaving zero residue. + +--- + +## 8. Frontend surfaces + +Declare them in `UiManifest`. Bundles are **runtime-loaded ESM** served by your +router; they use a framework-agnostic mount contract (no shared React): + +```js +// ui/actions.js — default export mounts into `el`, returns an optional cleanup. +export default function mount(el, ctx, props) { + el.innerHTML = ``; + return () => { el.innerHTML = ""; }; +} +``` + +`ctx` is `{ pluginName, api, events, t }`: +- `ctx.api.pluginFetch(path, init?)` → fetch under `/api/plugins/` (auth+CSRF handled). +- `ctx.api.fetch(path, init?)` → fetch under `/api`. +- `ctx.events.subscribe(event, handler)` → WS events (`plugin..*` + core). +- `ctx.t(key)` → i18n. + +Serve bundles from the router: + +```python +from pathlib import Path +from fastapi.responses import FileResponse +_UI = Path(__file__).resolve().parent / "ui" + +@router.get("/ui/{filename}") +async def bundle(filename: str) -> FileResponse: + assert filename in {"actions.js"} + return FileResponse(_UI / filename, media_type="text/javascript") +``` + +**Slots** (inject into the mail UI): `thread.toolbar`, `message.actions`, +`message.banner`, `sidebar.section`, `settings.page`. `message.*` slots receive +`props = { messageId }`. + +**Pages** (`app.page`): a full content-area view at `/p/` with a nav entry. + +**Capabilities**: expose an endpoint under a named capability the core UI merges +(v1: `recipients.suggest`). + +```python +from app.plugins.sdk import Page, PageNav, Slot, UiManifest +ui_manifest = UiManifest( + slots=[Slot(name="message.actions", bundle="/api/plugins/myplugin/ui/actions.js")], + pages=[Page(route="/myplugin", nav=PageNav(icon="puzzle", label="My plugin", order=50), + bundle="/api/plugins/myplugin/ui/page.js")], + capabilities={}, +) +``` + +### The plugin UI kit + +Style bundles with these theme-aware classes (they adapt to light/dark) instead of +inline styles: `wm-root`, `wm-h2`, `wm-muted`, `wm-faint`, `wm-card`, `wm-row`, +`wm-input`, `wm-select`, `wm-btn` / `wm-btn-primary` / `wm-btn-danger` / `wm-btn-ghost`, +`wm-chip`, `wm-table`, `wm-list`. + +--- + +## 9. Worked example — an Unsubscribe button + +A `message.actions` plugin that shows an **Unsubscribe** button on mailing-list mail +(messages with a `List-Unsubscribe` header). No table, no job — a router + one slot. + +`__init__.py`: + +```python +from app.plugins.sdk import Plugin, Slot, UiManifest +from webmail_plugin_unsubscribe.router import router + +plugin = Plugin( + name="unsubscribe", + version="0.1.0", + router=router, + ui_manifest=UiManifest( + slots=[Slot(name="message.actions", bundle="/api/plugins/unsubscribe/ui/button.js")], + ), +) +``` + +`router.py`: + +```python +import re +from pathlib import Path +from fastapi import APIRouter, HTTPException +from fastapi.responses import FileResponse +from app.plugins.sdk import CurrentUserId, sdk + +router = APIRouter() +_UI = Path(__file__).resolve().parent / "ui" +_URL = re.compile(r"<(https?://[^>]+)>") # List-Unsubscribe: , + +@router.get("/message/{message_id}/unsub") +async def unsub(message_id: str, user_id: CurrentUserId) -> dict: + import uuid + raw = await sdk("unsubscribe").messages.header(user_id, uuid.UUID(message_id), "List-Unsubscribe") + m = _URL.search(raw or "") + return {"url": m.group(1) if m else None} + +@router.get("/ui/{filename}") +async def bundle(filename: str) -> FileResponse: + if filename != "button.js": + raise HTTPException(404) + return FileResponse(_UI / filename, media_type="text/javascript") +``` + +`ui/button.js`: + +```js +export default function mount(el, ctx, props) { + if (!props || !props.messageId) return; + (async () => { + const { url } = await ctx.api.pluginFetch(`/message/${props.messageId}/unsub`); + if (!url) return; // not a list message → show nothing + el.innerHTML = ``; + el.querySelector("button").addEventListener("click", (e) => { + e.stopPropagation(); + window.open(url, "_blank", "noopener,noreferrer"); + }); + })(); + return () => { el.innerHTML = ""; }; +} +``` + +That's a complete, installable plugin using only this guide: entry point → router +with SDK header access + a bundle → a `message.actions` slot styled with the UI kit. + +--- + +## 10. Security checklist + +- Import only from `app.plugins.sdk`. +- Scope every query to `user_id` — via `sdk.session(user_id)` (RLS) or the KV store. +- Enable RLS on every `plg_*` user-data table (§7). +- Route external HTTP through `sdk.http_get` (SSRF guard). +- Guard mutating routes with `require_csrf`. +- Store secrets encrypted (ask if you need the vault exposed — not in the v1 SDK). diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 36fb36b..d418060 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { Navigate, Route, Routes, useLocation } from "react-router-dom"; import { useMe } from "./lib/auth"; import { connectWs } from "./lib/ws"; +import { PluginPage } from "./plugins/PluginPage"; import { ComposeWindow } from "./routes/ComposeWindow"; import { Login } from "./routes/Login"; import { MailLayout } from "./routes/MailLayout"; @@ -51,6 +52,14 @@ export function App() { } /> + + + + } + /> + {/* Plugin-contributed per-message actions (SPEC §11.3 message.actions). */} + {alsoIn.length > 0 && ( @@ -386,6 +389,8 @@ function MessageItem({ )} + {/* Plugin-contributed per-message banner (SPEC §11.3 message.banner). */} +
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c1836ac..13c33f2 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -25,6 +25,7 @@ import type { UserOut, UserSettings, } from "./types"; +import type { UiManifest } from "../plugins/types"; export class ApiError extends Error { code: string; @@ -47,7 +48,7 @@ function csrfToken(): string { const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]); -async function apiFetch(path: string, init: RequestInit = {}): Promise { +export async function apiFetch(path: string, init: RequestInit = {}): Promise { const method = (init.method ?? "GET").toUpperCase(); const headers = new Headers(init.headers); if (MUTATING.has(method)) { @@ -90,6 +91,7 @@ export const api = { accounts: () => apiFetch("/accounts"), mailboxes: () => apiFetch("/mailboxes"), + uiManifest: () => apiFetch("/plugins/ui-manifest"), syncNow: () => apiFetch<{ status: string }>("/accounts/sync", { method: "POST" }), threads: ( diff --git a/frontend/src/lib/ws.ts b/frontend/src/lib/ws.ts index 2a828ed..213855f 100644 --- a/frontend/src/lib/ws.ts +++ b/frontend/src/lib/ws.ts @@ -22,6 +22,19 @@ const ALLOWED_EVENTS = new Set([ "outbox.state_changed", ]); +// Lightweight in-process fan-out of WS frames to plugin subscribers (SPEC §11.3). +// Plugins receive plugin.{name}.* events plus any core event they subscribe to. +export type WsListener = (event: string, payload: Record) => void; + +const wsListeners = new Set(); + +export function subscribeWs(listener: WsListener): () => void { + wsListeners.add(listener); + return () => { + wsListeners.delete(listener); + }; +} + export function connectWs(queryClient: QueryClient): () => void { const proto = window.location.protocol === "https:" ? "wss" : "ws"; const socket = new WebSocket(`${proto}://${window.location.host}/api/ws`); @@ -40,8 +53,21 @@ export function connectWs(queryClient: QueryClient): () => void { return; } const result = frameSchema.safeParse(parsed); - if (!result.success || !ALLOWED_EVENTS.has(result.data.event)) return; + if (!result.success) return; const { event: name, payload } = result.data; + const isCore = ALLOWED_EVENTS.has(name); + // Fan out core + plugin.* events to plugin subscribers (a failing listener + // must not break the others or the cache invalidation below). + if (isCore || name.startsWith("plugin.")) { + for (const listener of wsListeners) { + try { + listener(name, payload); + } catch (err) { + console.error("ws listener failed", err); + } + } + } + if (!isCore) return; // Coarse invalidation: refetch affected lists (SPEC §9.2 contract). if (name === "sync.progress" || name.startsWith("message.") || name === "thread.updated") { queryClient.invalidateQueries({ queryKey: ["threads"] }); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 018e23e..33f080c 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -7,6 +7,7 @@ import { App } from "./App"; import { I18nProvider } from "./i18n"; import { ThemeProvider } from "./lib/theme"; import "./index.css"; +import "./plugins/plugin-ui.css"; // .wm-* classes for plugin bundles (SPEC §11.3) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } }, diff --git a/frontend/src/plugins/PluginPage.tsx b/frontend/src/plugins/PluginPage.tsx new file mode 100644 index 0000000..df28fb1 --- /dev/null +++ b/frontend/src/plugins/PluginPage.tsx @@ -0,0 +1,75 @@ +// Full-content-area host for plugin pages (app.page, SPEC §11.3). +// +// Mounted at "/p/*": matches the sub-path against declared page routes and mounts +// the page bundle into the whole content area (no mail list/thread). A minimal +// 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 { useT } from "../i18n"; +import { loadBundle } from "./bundleLoader"; +import { usePluginContext } from "./context"; +import { usePluginManifest, usePluginPages } from "./manifest"; +import type { PluginUnmount } from "./types"; + +function PageMount({ + pluginName, + bundle, + subpath, +}: { + pluginName: string; + bundle: string; + subpath: string; +}) { + const ref = useRef(null); + const ctx = usePluginContext(pluginName); + const props = useMemo(() => ({ subpath }), [subpath]); + + useEffect(() => { + let cleanup: PluginUnmount | void; + let alive = true; + loadBundle(bundle) + .then((mount) => { + if (alive && ref.current) cleanup = mount(ref.current, ctx, props); + }) + .catch((err) => console.error(`plugin page ${pluginName} failed`, err)); + return () => { + alive = false; + if (typeof cleanup === "function") cleanup(); + }; + }, [bundle, ctx, props, pluginName]); + + return
; +} + +export function PluginPage() { + const t = useT(); + const location = useLocation(); + const { data, isLoading } = usePluginManifest(); + const pages = usePluginPages(); + + // Sub-path under "/p" (e.g. "/calendar/2026-07"); match the longest page route. + const subpath = location.pathname.replace(/^\/p/, "") || "/"; + const match = pages.find((p) => subpath === p.route || subpath.startsWith(`${p.route}/`)); + + if (isLoading || !data) { + return
{t("common.loading")}
; + } + if (!match) return ; + + return ( +
+
+ + ← {t("nav.mail")} + + {match.nav.label} +
+ +
+ ); +} diff --git a/frontend/src/plugins/PluginSlot.tsx b/frontend/src/plugins/PluginSlot.tsx new file mode 100644 index 0000000..9e52884 --- /dev/null +++ b/frontend/src/plugins/PluginSlot.tsx @@ -0,0 +1,63 @@ +// Renders every plugin bundle registered for a named UI slot (SPEC §11.3). +// +// The host owns a container
and delegates rendering to each bundle's mount +// function (framework-agnostic contract), tearing it down on unmount. Slot-specific +// data (e.g. the current messageId) is passed as `props` at mount time. + +import { useEffect, useRef } from "react"; + +import { loadBundle } from "./bundleLoader"; +import { usePluginContext } from "./context"; +import { usePluginSlots } from "./manifest"; +import type { PluginUnmount } from "./types"; + +function SlotMount({ + pluginName, + bundle, + slotName, + props, +}: { + pluginName: string; + bundle: string; + slotName: string; + props?: Record; +}) { + const ref = useRef(null); + const ctx = usePluginContext(pluginName); + + useEffect(() => { + let cleanup: PluginUnmount | void; + let alive = true; + loadBundle(bundle) + .then((mount) => { + if (alive && ref.current) cleanup = mount(ref.current, ctx, props); + }) + .catch((err) => console.error(`plugin slot ${slotName} (${pluginName}) failed`, err)); + return () => { + alive = false; + if (typeof cleanup === "function") cleanup(); + }; + // props is read once at mount; plugins react to later changes via ctx.events. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [bundle, ctx]); + + return
; +} + +export function PluginSlot({ name, props }: { name: string; props?: Record }) { + const bindings = usePluginSlots(name); + if (bindings.length === 0) return null; + return ( + <> + {bindings.map((b) => ( + + ))} + + ); +} diff --git a/frontend/src/plugins/bundleLoader.ts b/frontend/src/plugins/bundleLoader.ts new file mode 100644 index 0000000..002836c --- /dev/null +++ b/frontend/src/plugins/bundleLoader.ts @@ -0,0 +1,35 @@ +// Runtime loader for plugin ESM bundles (SPEC §11.3). +// +// Bundles are hosted by the backend (the manifest gives absolute URLs), so they +// are imported dynamically at runtime and cached per URL. The importer is +// injectable so the caching logic is unit-testable without a real network module. + +import type { PluginBundle, PluginMount } from "./types"; + +type Importer = (url: string) => Promise; + +const _cache = new Map>(); + +// @vite-ignore keeps Vite from trying to resolve/transform the runtime URL. +const defaultImporter: Importer = (url) => import(/* @vite-ignore */ url); + +export function loadBundle(url: string, importer: Importer = defaultImporter): Promise { + let pending = _cache.get(url); + if (!pending) { + pending = importer(url).then((mod) => { + const mount = (mod as PluginBundle).default; + if (typeof mount !== "function") { + throw new Error(`plugin bundle ${url} has no default mount export`); + } + return mount; + }); + // Don't cache a rejected import — allow a later retry. + pending.catch(() => _cache.delete(url)); + _cache.set(url, pending); + } + return pending; +} + +export function clearBundleCache(): void { + _cache.clear(); +} diff --git a/frontend/src/plugins/capabilities.ts b/frontend/src/plugins/capabilities.ts new file mode 100644 index 0000000..6561fcb --- /dev/null +++ b/frontend/src/plugins/capabilities.ts @@ -0,0 +1,34 @@ +// Capability providers (SPEC §11.3). Core UI features query every plugin that +// provides a named capability and merge the results client-side, keeping core +// code plugin-unaware. v1 capability: "recipients.suggest". + +import { apiFetch } from "../lib/api"; +import { usePluginManifest } from "./manifest"; + +export interface CapabilityProvider { + plugin: string; + endpoint: string; // path under /api, e.g. /plugins/contacts/suggest +} + +/** Providers of `name`, across installed plugins. */ +export function useCapabilityProviders(name: string): CapabilityProvider[] { + const { data } = usePluginManifest(); + return (data?.plugins ?? []).flatMap((p) => + name in p.capabilities ? [{ plugin: p.name, endpoint: p.capabilities[name] }] : [], + ); +} + +/** + * Query every provider of a capability and flatten the results. A failing + * provider is skipped (never breaks the merge). Callers dedupe/rank as needed. + */ +export async function queryCapability( + providers: CapabilityProvider[], + query: Record, +): Promise { + const qs = new URLSearchParams(query).toString(); + const results = await Promise.allSettled( + providers.map((p) => apiFetch(`${p.endpoint}?${qs}`)), + ); + return results.flatMap((r) => (r.status === "fulfilled" ? r.value : [])); +} diff --git a/frontend/src/plugins/context.ts b/frontend/src/plugins/context.ts new file mode 100644 index 0000000..6908f92 --- /dev/null +++ b/frontend/src/plugins/context.ts @@ -0,0 +1,33 @@ +// Builds the PluginContext handed to plugin bundles at mount (SPEC §11.3). + +import { useMemo } from "react"; + +import type { MessageKey } from "../i18n/en"; +import { useT } from "../i18n"; +import { apiFetch } from "../lib/api"; +import { subscribeWs } from "../lib/ws"; +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(); + return useMemo( + () => ({ + pluginName, + api: { + fetch: (path, init) => apiFetch(path, init), + pluginFetch: (path, init) => apiFetch(`/plugins/${pluginName}${path}`, init), + }, + events: { + subscribe: (event, handler) => + subscribeWs((name, payload) => { + if (name === event) handler(payload); + }), + }, + // 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), + }), + [pluginName, t], + ); +} diff --git a/frontend/src/plugins/manifest.ts b/frontend/src/plugins/manifest.ts new file mode 100644 index 0000000..ad6a0a6 --- /dev/null +++ b/frontend/src/plugins/manifest.ts @@ -0,0 +1,39 @@ +// Plugin UI manifest loading + derived selectors (SPEC §11.3). + +import { useQuery } from "@tanstack/react-query"; + +import { api } from "../lib/api"; +import type { PageDecl, SlotDecl } from "./types"; + +export function usePluginManifest() { + // The manifest is small and changes only on install/uninstall; cache it hard. + return useQuery({ + queryKey: ["plugin-ui-manifest"], + queryFn: api.uiManifest, + staleTime: Infinity, + }); +} + +export interface SlotBinding extends SlotDecl { + plugin: string; +} + +export interface PageBinding extends PageDecl { + plugin: string; +} + +/** All bundles registered for a given slot, across plugins. */ +export function usePluginSlots(name: string): SlotBinding[] { + const { data } = usePluginManifest(); + return (data?.plugins ?? []).flatMap((p) => + p.slots.filter((s) => s.name === name).map((s) => ({ ...s, plugin: p.name })), + ); +} + +/** All plugin pages, sorted by nav order (for the sidebar + routing). */ +export function usePluginPages(): PageBinding[] { + const { data } = usePluginManifest(); + return (data?.plugins ?? []) + .flatMap((p) => p.pages.map((pg) => ({ ...pg, plugin: p.name }))) + .sort((a, b) => a.nav.order - b.nav.order); +} diff --git a/frontend/src/plugins/plugin-ui.css b/frontend/src/plugins/plugin-ui.css new file mode 100644 index 0000000..d7b14ea --- /dev/null +++ b/frontend/src/plugins/plugin-ui.css @@ -0,0 +1,128 @@ +/* Plugin UI kit (SPEC §11.3). + * + * Stable, theme-aware CSS classes for plugin bundles, so plugins style their UI + * with `class="wm-…"` instead of re-deriving inline styles from the theme vars + * (which is error-prone — the app's --app-* vars are RGB channel triplets used as + * `rgb(var(--app-fg))`, and dark mode flips them). These classes are part of the + * plugin contract and are documented in docs/plugins.md. Colors come from the app + * theme, so light/dark "just work". + */ + +.wm-root { + font: 15px system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + color: rgb(var(--app-fg)); +} + +.wm-h2 { + font-size: 16px; + font-weight: 600; + margin: 0 0 4px; +} +.wm-muted { + color: rgb(var(--app-muted)); +} +.wm-faint { + color: rgb(var(--app-faint)); +} + +/* Bordered, rounded panel — matches the app's list items. */ +.wm-card { + border: 1px solid rgb(var(--app-border)); + border-radius: 8px; + padding: 12px; +} + +.wm-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +/* Form controls: solid surface background + explicit fg so the native
diff --git a/frontend/src/test/pluginBundleLoader.test.ts b/frontend/src/test/pluginBundleLoader.test.ts new file mode 100644 index 0000000..580f1db --- /dev/null +++ b/frontend/src/test/pluginBundleLoader.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from "vitest"; + +import { clearBundleCache, loadBundle } from "../plugins/bundleLoader"; + +describe("loadBundle", () => { + it("caches per url and returns the default mount export", async () => { + clearBundleCache(); + const mount = () => () => {}; + const importer = vi.fn(async () => ({ default: mount })); + + const a = await loadBundle("/x.js", importer); + const b = await loadBundle("/x.js", importer); + + expect(a).toBe(mount); + expect(b).toBe(mount); + expect(importer).toHaveBeenCalledTimes(1); // second call served from cache + }); + + it("rejects a bundle without a default mount export", async () => { + clearBundleCache(); + await expect(loadBundle("/bad.js", async () => ({}))).rejects.toThrow("default mount"); + }); + + it("does not cache a failed import (allows retry)", async () => { + clearBundleCache(); + const importer = vi + .fn() + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValueOnce({ default: () => () => {} }); + + await expect(loadBundle("/y.js", importer)).rejects.toThrow("boom"); + await loadBundle("/y.js", importer); // retried, not stuck on the failure + expect(importer).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/src/test/pluginCapabilities.test.ts b/frontend/src/test/pluginCapabilities.test.ts new file mode 100644 index 0000000..62ba547 --- /dev/null +++ b/frontend/src/test/pluginCapabilities.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from "vitest"; + +// capabilities.ts (via manifest.ts) imports the api module; provide both symbols. +vi.mock("../lib/api", () => ({ apiFetch: vi.fn(), api: {} })); + +import { apiFetch } from "../lib/api"; +import { queryCapability, type CapabilityProvider } from "../plugins/capabilities"; + +const providers: CapabilityProvider[] = [ + { plugin: "contacts", endpoint: "/plugins/contacts/suggest" }, + { plugin: "dir", endpoint: "/plugins/dir/suggest" }, +]; + +describe("queryCapability", () => { + it("merges results from every provider", async () => { + vi.mocked(apiFetch) + .mockResolvedValueOnce([{ email: "a@x" }]) + .mockResolvedValueOnce([{ email: "b@x" }]); + + const out = await queryCapability<{ email: string }>(providers, { q: "a" }); + expect(out).toEqual([{ email: "a@x" }, { email: "b@x" }]); + + // Each provider queried with the encoded query string. + expect(vi.mocked(apiFetch)).toHaveBeenCalledWith("/plugins/contacts/suggest?q=a"); + expect(vi.mocked(apiFetch)).toHaveBeenCalledWith("/plugins/dir/suggest?q=a"); + }); + + it("skips a failing provider instead of breaking the merge", async () => { + vi.mocked(apiFetch) + .mockRejectedValueOnce(new Error("down")) + .mockResolvedValueOnce([{ email: "b@x" }]); + + const out = await queryCapability<{ email: string }>(providers, { q: "a" }); + expect(out).toEqual([{ email: "b@x" }]); + }); +}); diff --git a/frontend/src/test/pluginSlot.test.tsx b/frontend/src/test/pluginSlot.test.tsx new file mode 100644 index 0000000..c7a04ab --- /dev/null +++ b/frontend/src/test/pluginSlot.test.tsx @@ -0,0 +1,69 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { I18nProvider } from "../i18n"; +import type { UiManifest } from "../plugins/types"; + +// A fake bundle whose mount writes into the container and returns a cleanup spy. +const h = vi.hoisted(() => { + const cleanup = vi.fn(); + const mount = vi.fn((el: HTMLElement, _ctx?: unknown, _props?: unknown) => { + el.textContent = "plugin-ui"; + return cleanup; + }); + return { cleanup, mount }; +}); + +vi.mock("../plugins/bundleLoader", () => ({ + loadBundle: () => Promise.resolve(h.mount), + clearBundleCache: () => {}, +})); + +// Imported after the mock so PluginSlot picks up the mocked loader. +import { PluginSlot } from "../plugins/PluginSlot"; + +const MANIFEST: UiManifest = { + plugins: [ + { + name: "labels", + version: "1.0", + builtin: false, + slots: [{ name: "message.actions", bundle: "/labels/actions.js" }], + pages: [], + capabilities: {}, + }, + ], +}; + +function wrap(children: ReactNode) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + qc.setQueryData(["plugin-ui-manifest"], MANIFEST); // seed so no network fetch + return ( + + {children} + + ); +} + +describe("PluginSlot", () => { + it("mounts registered bundles and tears them down on unmount", async () => { + const { unmount } = render(wrap()); + + // The bundle's mount ran into the slot container. + expect(await screen.findByText("plugin-ui")).toBeTruthy(); + expect(h.mount).toHaveBeenCalledTimes(1); + const [, ctx, props] = h.mount.mock.calls[0] as [HTMLElement, { pluginName: string }, unknown]; + expect(ctx.pluginName).toBe("labels"); + expect(props).toEqual({ messageId: "m1" }); + + unmount(); + expect(h.cleanup).toHaveBeenCalledTimes(1); + }); + + it("renders nothing when no plugin provides the slot", () => { + const { container } = render(wrap()); + expect(container.querySelector("[data-plugin-slot]")).toBeNull(); + }); +}); diff --git a/plugins/webmail_plugin_filters/pyproject.toml b/plugins/webmail_plugin_filters/pyproject.toml new file mode 100644 index 0000000..82b3508 --- /dev/null +++ b/plugins/webmail_plugin_filters/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "webmail-plugin-filters" +version = "0.1.0" +description = "Rules on message.new (match -> flag) as a plugin — hook + KV + slot, zero core diffs." +requires-python = ">=3.11" +dependencies = [] + +[project.entry-points."webmail.plugins"] +filters = "webmail_plugin_filters:plugin" + +[tool.setuptools] +packages = ["webmail_plugin_filters"] + +[tool.setuptools.package-data] +webmail_plugin_filters = ["ui/*.js"] diff --git a/plugins/webmail_plugin_filters/webmail_plugin_filters/__init__.py b/plugins/webmail_plugin_filters/webmail_plugin_filters/__init__.py new file mode 100644 index 0000000..e1d5cd2 --- /dev/null +++ b/plugins/webmail_plugin_filters/webmail_plugin_filters/__init__.py @@ -0,0 +1,23 @@ +"""``filters`` — rules on incoming mail, built on a hook + KV + slot (SPEC §11, §12 M3). + +On ``message.new`` the hook matches the message against the user's rules (stored +in KV) and applies a flag (mark read / pin) via the SDK — no core diffs. Rules +are managed from a ``settings.page`` slot. (Flag actions for now; ``move`` would +follow the same pattern once the SDK exposes it.) +""" + +from __future__ import annotations + +from app.plugins.sdk import Plugin, Slot, UiManifest +from webmail_plugin_filters.hook import on_message_new +from webmail_plugin_filters.router import router + +plugin = Plugin( + name="filters", + version="0.1.0", + hooks={"message.new": on_message_new}, + router=router, + ui_manifest=UiManifest( + slots=[Slot(name="settings.page", bundle="/api/plugins/filters/ui/settings.js")], + ), +) diff --git a/plugins/webmail_plugin_filters/webmail_plugin_filters/hook.py b/plugins/webmail_plugin_filters/webmail_plugin_filters/hook.py new file mode 100644 index 0000000..ace6871 --- /dev/null +++ b/plugins/webmail_plugin_filters/webmail_plugin_filters/hook.py @@ -0,0 +1,62 @@ +"""message.new hook: match the user's rules and flag the message (SPEC §11). + +Runs wherever message.new is published (the sync worker). Reads rules from KV, +reads the message envelope through the SDK, and applies flags user-scoped. +""" + +from __future__ import annotations + +import logging +import uuid +from typing import Any + +from app.plugins.sdk import sdk + +logger = logging.getLogger("plg.filters") + +# rule.action "read"/"pin" -> IMAP flag on a match ("pin" is the app's name for +# \Flagged; setting it re-derives Thread.pinned). "trash" -> move to Trash. +_ACTION_FLAG = {"read": "\\Seen", "pin": "\\Flagged"} + + +def _haystack(env: dict[str, Any], field: str) -> str: + """The text a rule matches against, per its field.""" + if field == "from": + return f"{env['from_email']} {env['from_name']}" + if field == "to": + return " ".join(f"{a['email']} {a['name']}" for a in env["to"]) + return env["subject"] + + +async def on_message_new(payload: dict[str, Any]) -> None: + uid_raw, mid_raw = payload.get("user_id"), payload.get("message_id") + if not isinstance(uid_raw, str) or not isinstance(mid_raw, str): + return + user_id, message_id = uuid.UUID(uid_raw), uuid.UUID(mid_raw) + + s = sdk("filters") + rules = await s.kv(user_id).get("_rules", []) + if not rules: + return + env = await s.messages.envelope(user_id, message_id) + if env is None: + return + + add: set[str] = set() + trash = False + for rule in rules: + needle = str(rule.get("contains", "")).strip().lower() + if not needle or needle not in _haystack(env, rule.get("field", "")).lower(): + continue + action = rule.get("action", "") + if action == "trash": + trash = True + elif action in _ACTION_FLAG: + add.add(_ACTION_FLAG[action]) + + # Trash wins: no point flagging a message we're removing. + if trash: + if await s.actions.trash(user_id, message_id): + logger.info("filters: trashed message %s", message_id) + elif add and await s.actions.set_flags(user_id, message_id, add=add): + logger.info("filters: applied %s to message %s", sorted(add), message_id) diff --git a/plugins/webmail_plugin_filters/webmail_plugin_filters/router.py b/plugins/webmail_plugin_filters/webmail_plugin_filters/router.py new file mode 100644 index 0000000..96bbe27 --- /dev/null +++ b/plugins/webmail_plugin_filters/webmail_plugin_filters/router.py @@ -0,0 +1,51 @@ +"""HTTP surface for the filters plugin, mounted at /api/plugins/filters (SPEC §11). + +Rules live in the KV store under ``_rules`` (per user). The message.new hook reads +them; this router lets the settings.page slot read/write them. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import FileResponse +from pydantic import BaseModel + +from app.plugins.sdk import CurrentUserId, require_csrf, sdk + +router = APIRouter() + +_UI = Path(__file__).resolve().parent / "ui" +_SDK = sdk("filters") +_KEY = "_rules" + + +class Rule(BaseModel): + field: str # "from" | "to" | "subject" + contains: str + action: str # "read" | "pin" | "trash" + + +class RulesIn(BaseModel): + rules: list[Rule] + + +@router.get("/rules", response_model=list[Rule]) +async def get_rules(user_id: CurrentUserId) -> list[dict[str, Any]]: + return await _SDK.kv(user_id).get(_KEY, []) + + +@router.put("/rules", dependencies=[Depends(require_csrf)]) +async def set_rules(body: RulesIn, user_id: CurrentUserId) -> dict[str, object]: + rules = [r.model_dump() for r in body.rules] + await _SDK.kv(user_id).set(_KEY, rules) + return {"status": "ok", "count": len(rules)} + + +@router.get("/ui/{filename}") +async def bundle(filename: str) -> FileResponse: + if filename != "settings.js": + raise HTTPException(status_code=404) + return FileResponse(_UI / filename, media_type="text/javascript") diff --git a/plugins/webmail_plugin_filters/webmail_plugin_filters/ui/settings.js b/plugins/webmail_plugin_filters/webmail_plugin_filters/ui/settings.js new file mode 100644 index 0000000..c4b3d7d --- /dev/null +++ b/plugins/webmail_plugin_filters/webmail_plugin_filters/ui/settings.js @@ -0,0 +1,84 @@ +// filters · settings.page slot (SPEC §11.3). Manage match->action rules; persists +// to the plugin's KV-backed API. New mail is matched by the message.new hook. +// Styling uses the plugin UI kit (.wm-* classes, see docs/plugins.md). + +export default function mount(el, ctx) { + el.innerHTML = ` +
+

Filters

+

When new mail matches, apply an action. Rules run on arrival.

+
+
+ + contains + + → + + +
+
`; + + let rules = []; + const rows = el.querySelector("[data-rows]"); + const ACTION_LABEL = { read: "Mark read", pin: "Pin", trash: "Delete" }; + const FIELD_LABEL = { from: "From", to: "To", subject: "Subject" }; + + async function save() { + await ctx.api.pluginFetch("/rules", { method: "PUT", body: JSON.stringify({ rules }) }); + } + + function render() { + rows.innerHTML = rules + .map( + (r, i) => + ` + ${FIELD_LABEL[r.field] || r.field} contains ${esc(r.contains)} + ${ACTION_LABEL[r.action] || r.action} + + `, + ) + .join(""); + rows.querySelectorAll("[data-rm]").forEach((b) => + b.addEventListener("click", async () => { + rules.splice(Number(b.getAttribute("data-rm")), 1); + await save(); + render(); + }), + ); + } + + el.querySelector("[data-add]").addEventListener("submit", async (e) => { + e.preventDefault(); + const contains = el.querySelector("[data-contains]").value.trim(); + if (!contains) return; + rules.push({ + field: el.querySelector("[data-field]").value, + contains, + action: el.querySelector("[data-action]").value, + }); + el.querySelector("[data-contains]").value = ""; + await save(); + render(); + }); + + (async () => { + rules = await ctx.api.pluginFetch("/rules"); + render(); + })(); + + return () => { + el.innerHTML = ""; + }; + + function esc(s) { + return String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]); + } +} diff --git a/plugins/webmail_plugin_hello/pyproject.toml b/plugins/webmail_plugin_hello/pyproject.toml new file mode 100644 index 0000000..050fbe4 --- /dev/null +++ b/plugins/webmail_plugin_hello/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "webmail-plugin-hello" +version = "0.1.0" +description = "Skeleton page-plugin for the webmail plugin platform (SPEC §11 / §12 M3 acceptance)." +requires-python = ">=3.11" +dependencies = [] + +# Discovery: the loader enumerates this entry-point group (SPEC §11). +[project.entry-points."webmail.plugins"] +hello = "webmail_plugin_hello:plugin" + +[tool.setuptools] +packages = ["webmail_plugin_hello"] + +[tool.setuptools.package-data] +webmail_plugin_hello = ["ui/*.js", "migrations/versions/*.py"] diff --git a/plugins/webmail_plugin_hello/webmail_plugin_hello/__init__.py b/plugins/webmail_plugin_hello/webmail_plugin_hello/__init__.py new file mode 100644 index 0000000..9a6fa13 --- /dev/null +++ b/plugins/webmail_plugin_hello/webmail_plugin_hello/__init__.py @@ -0,0 +1,34 @@ +"""``hello`` — the skeleton page-plugin (SPEC §11, §12 M3 acceptance). + +Exercises the whole platform in the smallest form: an ``app.page`` (route + nav + +ESM bundle), its own RLS-isolated table ``plg_hello_note`` (a migration branch), +and one cron ``JobSpec`` — all via ``app.plugins.sdk`` only, with zero core diffs. +Not builtin, so it round-trips install → run → uninstall --drop-data cleanly. +""" + +from __future__ import annotations + +from pathlib import Path + +from app.plugins.sdk import JobSpec, Page, PageNav, Plugin, UiManifest +from webmail_plugin_hello.jobs import heartbeat +from webmail_plugin_hello.router import router + +_HERE = Path(__file__).resolve().parent + +plugin = Plugin( + name="hello", + version="0.1.0", + router=router, + ui_manifest=UiManifest( + pages=[ + Page( + route="/hello", + nav=PageNav(icon="puzzle", label="Hello", order=50), + bundle="/api/plugins/hello/ui/page.js", + ) + ], + ), + migrations=str(_HERE / "migrations" / "versions"), + jobs=[JobSpec(func=heartbeat, schedule="*/5 * * * *")], +) diff --git a/plugins/webmail_plugin_hello/webmail_plugin_hello/jobs.py b/plugins/webmail_plugin_hello/webmail_plugin_hello/jobs.py new file mode 100644 index 0000000..317adf2 --- /dev/null +++ b/plugins/webmail_plugin_hello/webmail_plugin_hello/jobs.py @@ -0,0 +1,13 @@ +"""Background job for the hello plugin (SPEC §11.2). Trivial heartbeat proving +cron registration on the shared worker; logs under the ``plg`` namespace.""" + +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger("plg.hello") + + +async def heartbeat(ctx: dict[str, Any]) -> None: + logger.info("hello plugin heartbeat") diff --git a/plugins/webmail_plugin_hello/webmail_plugin_hello/migrations/versions/0001_note.py b/plugins/webmail_plugin_hello/webmail_plugin_hello/migrations/versions/0001_note.py new file mode 100644 index 0000000..716eeb9 --- /dev/null +++ b/plugins/webmail_plugin_hello/webmail_plugin_hello/migrations/versions/0001_note.py @@ -0,0 +1,33 @@ +"""hello plugin: user-owned note table with RLS (SPEC §11.1) + +Revision ID: hello0001 +Revises: +Create Date: 2026-07-26 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op + +from app.plugins.rls import enable_rls + +revision: str = "hello0001" +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_hello_note (" + "id uuid PRIMARY KEY DEFAULT gen_random_uuid(), " + "user_id uuid NOT NULL, " + "body text NOT NULL DEFAULT '')" + ) + enable_rls("plg_hello_note") + + +def downgrade() -> None: + op.execute("DROP TABLE IF EXISTS plg_hello_note") diff --git a/plugins/webmail_plugin_hello/webmail_plugin_hello/router.py b/plugins/webmail_plugin_hello/webmail_plugin_hello/router.py new file mode 100644 index 0000000..b19cd90 --- /dev/null +++ b/plugins/webmail_plugin_hello/webmail_plugin_hello/router.py @@ -0,0 +1,55 @@ +"""HTTP surface for the hello plugin, mounted at /api/plugins/hello (SPEC §11). + +Notes live in ``plg_hello_note`` and are read/written only through the RLS-scoped +``sdk.session(user_id)``, so a user only ever sees their own — the DB enforces it. +""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi import APIRouter, Depends +from fastapi.responses import FileResponse +from pydantic import BaseModel +from sqlalchemy import text + +from app.plugins.sdk import CurrentUserId, require_csrf, sdk + +router = APIRouter() + +_UI = Path(__file__).resolve().parent / "ui" +_SDK = sdk("hello") + + +class NoteIn(BaseModel): + body: str + + +class NoteOut(BaseModel): + id: str + body: str + + +@router.get("/notes", response_model=list[NoteOut]) +async def list_notes(user_id: CurrentUserId) -> list[NoteOut]: + async with _SDK.session(user_id) as session: + rows = ( + await session.execute(text("SELECT id, body FROM plg_hello_note ORDER BY body")) + ).mappings().all() + return [NoteOut(id=str(r["id"]), body=r["body"]) for r in rows] + + +@router.post("/notes", dependencies=[Depends(require_csrf)]) +async def add_note(body: NoteIn, user_id: CurrentUserId) -> dict[str, str]: + async with _SDK.session(user_id) as session: + await session.execute( + text("INSERT INTO plg_hello_note (user_id, body) VALUES (:u, :b)"), + {"u": str(user_id), "b": body.body}, + ) + return {"status": "ok"} + + +@router.get("/ui/page.js") +async def page_bundle() -> FileResponse: + """Serve the page's ESM bundle for the frontend loader (SPEC §11.3).""" + return FileResponse(_UI / "page.js", media_type="text/javascript") diff --git a/plugins/webmail_plugin_hello/webmail_plugin_hello/ui/page.js b/plugins/webmail_plugin_hello/webmail_plugin_hello/ui/page.js new file mode 100644 index 0000000..0e86efd --- /dev/null +++ b/plugins/webmail_plugin_hello/webmail_plugin_hello/ui/page.js @@ -0,0 +1,49 @@ +// hello plugin page bundle (SPEC §11.3). Framework-agnostic: the host calls this +// default export with a container element and the plugin context. No build step — +// hand-written ESM served by the plugin router, imported at runtime. Styling uses +// the plugin UI kit (.wm-* classes, see docs/plugins.md). + +export default function mount(el, ctx) { + el.innerHTML = ` +
+

Hello plugin

+

+ A skeleton page-plugin: its own RLS-isolated table, a cron job, and this page — + all through the SDK, zero core diffs. +

+
+ + +
+
    +
    `; + + const list = el.querySelector('[data-role="list"]'); + const input = el.querySelector('[data-role="input"]'); + + async function refresh() { + const notes = await ctx.api.pluginFetch("/notes"); + list.innerHTML = notes.map((n) => `
  • ${escapeHtml(n.body)}
  • `).join(""); + } + + el.querySelector('[data-role="add"]').addEventListener("submit", async (e) => { + e.preventDefault(); + const body = input.value.trim(); + if (!body) return; + await ctx.api.pluginFetch("/notes", { method: "POST", body: JSON.stringify({ body }) }); + input.value = ""; + await refresh(); + }); + + refresh(); + return () => { + el.innerHTML = ""; + }; + + function escapeHtml(s) { + return String(s).replace( + /[&<>"']/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c], + ); + } +} diff --git a/plugins/webmail_plugin_labels/pyproject.toml b/plugins/webmail_plugin_labels/pyproject.toml new file mode 100644 index 0000000..5c066c0 --- /dev/null +++ b/plugins/webmail_plugin_labels/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "webmail-plugin-labels" +version = "0.1.0" +description = "Per-message labels as a plugin — slots + KV only, zero core diffs (SPEC §11 / §12 M3)." +requires-python = ">=3.11" +dependencies = [] + +[project.entry-points."webmail.plugins"] +labels = "webmail_plugin_labels:plugin" + +[tool.setuptools] +packages = ["webmail_plugin_labels"] + +[tool.setuptools.package-data] +webmail_plugin_labels = ["ui/*.js"] diff --git a/plugins/webmail_plugin_labels/webmail_plugin_labels/__init__.py b/plugins/webmail_plugin_labels/webmail_plugin_labels/__init__.py new file mode 100644 index 0000000..7c0a6b8 --- /dev/null +++ b/plugins/webmail_plugin_labels/webmail_plugin_labels/__init__.py @@ -0,0 +1,23 @@ +"""``labels`` — per-message labels, built entirely on slots + KV (SPEC §11, §12 M3). + +No plugin tables and no core diffs: labels live in the namespaced KV store (keyed +per user, so isolation is by construction), surfaced through a ``message.actions`` +slot (add/remove on a message) and a ``sidebar.section`` slot (list all labels). +""" + +from __future__ import annotations + +from app.plugins.sdk import Plugin, Slot, UiManifest +from webmail_plugin_labels.router import router + +plugin = Plugin( + name="labels", + version="0.1.0", + router=router, + ui_manifest=UiManifest( + slots=[ + Slot(name="message.actions", bundle="/api/plugins/labels/ui/actions.js"), + Slot(name="sidebar.section", bundle="/api/plugins/labels/ui/sidebar.js"), + ], + ), +) diff --git a/plugins/webmail_plugin_labels/webmail_plugin_labels/router.py b/plugins/webmail_plugin_labels/webmail_plugin_labels/router.py new file mode 100644 index 0000000..7848058 --- /dev/null +++ b/plugins/webmail_plugin_labels/webmail_plugin_labels/router.py @@ -0,0 +1,55 @@ +"""HTTP surface for the labels plugin, mounted at /api/plugins/labels (SPEC §11). + +State is the namespaced KV store: key ```` -> that message's labels, +and key ``_labels`` -> the user's known label set. The KV store is scoped to +(plugin, user_id) by construction, so users never see each other's labels. +""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import FileResponse +from pydantic import BaseModel + +from app.plugins.sdk import CurrentUserId, require_csrf, sdk + +router = APIRouter() + +_UI = Path(__file__).resolve().parent / "ui" +_SDK = sdk("labels") +_ALL_KEY = "_labels" +_BUNDLES = {"actions.js", "sidebar.js"} + + +class LabelsIn(BaseModel): + labels: list[str] + + +@router.get("/message/{message_id}/labels", response_model=list[str]) +async def get_labels(message_id: str, user_id: CurrentUserId) -> list[str]: + return await _SDK.kv(user_id).get(message_id, []) + + +@router.put("/message/{message_id}/labels", dependencies=[Depends(require_csrf)]) +async def set_labels(message_id: str, body: LabelsIn, user_id: CurrentUserId) -> dict[str, object]: + labels = sorted({name.strip() for name in body.labels if name.strip()}) + kv = _SDK.kv(user_id) + await kv.set(message_id, labels) + known = set(await kv.get(_ALL_KEY, [])) + known.update(labels) + await kv.set(_ALL_KEY, sorted(known)) + return {"status": "ok", "labels": labels} + + +@router.get("/labels", response_model=list[str]) +async def all_labels(user_id: CurrentUserId) -> list[str]: + return await _SDK.kv(user_id).get(_ALL_KEY, []) + + +@router.get("/ui/{filename}") +async def bundle(filename: str) -> FileResponse: + if filename not in _BUNDLES: + raise HTTPException(status_code=404) + return FileResponse(_UI / filename, media_type="text/javascript") diff --git a/plugins/webmail_plugin_labels/webmail_plugin_labels/ui/actions.js b/plugins/webmail_plugin_labels/webmail_plugin_labels/ui/actions.js new file mode 100644 index 0000000..7047850 --- /dev/null +++ b/plugins/webmail_plugin_labels/webmail_plugin_labels/ui/actions.js @@ -0,0 +1,62 @@ +// labels · message.actions slot (SPEC §11.3). Removable label chips + an add +// control on a message, persisted via the plugin's KV-backed API. Styling uses +// the plugin UI kit (.wm-* classes, see docs/plugins.md). + +export default function mount(el, ctx, props) { + const messageId = props && props.messageId; + if (!messageId) return; + el.style.display = "inline-flex"; + el.style.gap = "4px"; + el.style.alignItems = "center"; + + let labels = []; + + async function save() { + await ctx.api.pluginFetch(`/message/${messageId}/labels`, { + method: "PUT", + body: JSON.stringify({ labels }), + }); + // Let the sidebar list refresh (both slots live in the same document). + window.dispatchEvent(new CustomEvent("webmail:labels-changed")); + } + + function render() { + const chips = labels + .map( + (name, i) => + `${esc(name)}`, + ) + .join(""); + el.innerHTML = `${chips}`; + el.querySelectorAll("[data-rm]").forEach((b) => + b.addEventListener("click", async (e) => { + e.stopPropagation(); + labels.splice(Number(b.getAttribute("data-rm")), 1); + await save(); + render(); + }), + ); + el.querySelector("[data-add]").addEventListener("click", async (e) => { + e.stopPropagation(); + const name = window.prompt("Label name:"); + if (name && name.trim() && !labels.includes(name.trim())) { + labels.push(name.trim()); + await save(); + render(); + } + }); + } + + (async () => { + labels = await ctx.api.pluginFetch(`/message/${messageId}/labels`); + render(); + })(); + + return () => { + el.innerHTML = ""; + }; + + function esc(s) { + return String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]); + } +} diff --git a/plugins/webmail_plugin_labels/webmail_plugin_labels/ui/sidebar.js b/plugins/webmail_plugin_labels/webmail_plugin_labels/ui/sidebar.js new file mode 100644 index 0000000..84d1bb5 --- /dev/null +++ b/plugins/webmail_plugin_labels/webmail_plugin_labels/ui/sidebar.js @@ -0,0 +1,30 @@ +// labels · sidebar.section slot (SPEC §11.3). Lists the user's known labels; +// refreshes when the message.actions slot signals a change (same-document event). +// Styling uses the plugin UI kit (.wm-* classes, see docs/plugins.md). + +export default function mount(el, ctx) { + el.innerHTML = ` +
    +
    Labels
    +
      +
      `; + const list = el.querySelector("[data-list]"); + + async function refresh() { + const labels = await ctx.api.pluginFetch("/labels"); + list.innerHTML = labels.length + ? labels.map((name) => `
    • 🏷 ${esc(name)}
    • `).join("") + : `
    • No labels yet
    • `; + } + + refresh(); + window.addEventListener("webmail:labels-changed", refresh); + return () => { + window.removeEventListener("webmail:labels-changed", refresh); + el.innerHTML = ""; + }; + + function esc(s) { + return String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]); + } +}