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
+119
View File
@@ -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
+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)."""
+4
View File
@@ -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
@@ -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]
+103
View File
@@ -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
+102
View File
@@ -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
@@ -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"] = "<https://list.example/unsub?u=42>"
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") == (
"<https://list.example/unsub?u=42>"
)
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()