M3 plugin platform: frontend host, reference plugins, UI kit + docs

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcj3TpydMTzTSmU7kunzf8
This commit is contained in:
2026-07-27 08:30:24 +02:00
co-authored by Claude Opus 4.8
parent 4e077f1a37
commit d46ad13b7b
42 changed files with 2284 additions and 3 deletions
+28 -1
View File
@@ -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)."""