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 import uuid
from contextlib import AbstractAsyncContextManager from contextlib import AbstractAsyncContextManager
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Annotated, Any
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.accounts.ssrf import guarded_get from app.accounts.ssrf import guarded_get
from app.config import Settings, get_settings from app.config import Settings, get_settings
from app.events import get_event_bus from app.events import get_event_bus
from app.identity.deps import current_user, require_csrf
from app.indexer import blobstore from app.indexer import blobstore
from app.models import User
from app.outbox import service as outbox_service 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.db import plugin_session
from app.plugins.kv import PluginKvStore from app.plugins.kv import PluginKvStore
from app.plugins.types import ( from app.plugins.types import (
@@ -39,6 +43,7 @@ from app.plugins.types import (
from app.queue import enqueue_job from app.queue import enqueue_job
__all__ = [ __all__ = [
"CurrentUserId",
"Hook", "Hook",
"JobFunc", "JobFunc",
"JobSpec", "JobSpec",
@@ -49,10 +54,21 @@ __all__ = [
"Slot", "Slot",
"UiManifest", "UiManifest",
"blobstore", "blobstore",
"require_csrf",
"sdk", "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) @dataclass(frozen=True)
class PluginSdk: class PluginSdk:
"""A lightweight facade over shared services, scoped to one plugin. """A lightweight facade over shared services, scoped to one plugin.
@@ -105,6 +121,17 @@ class PluginSdk:
"""Draft/send helpers (``upsert_draft``, ``create_outbox``, …).""" """Draft/send helpers (``upsert_draft``, ``create_outbox``, …)."""
return outbox_service 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 ------------------------------------------------------ # --- outbound HTTP ------------------------------------------------------
async def http_get(self, url: str) -> str | None: async def http_get(self, url: str) -> str | None:
"""GET ``url`` through the §10.6 SSRF guard (blocks private IPs/oversize).""" """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() await get_redis().flushdb()
yield yield
from app.db import dispose_engine from app.db import dispose_engine
from app.queue import close_arq_pool
from app.redis_client import close_redis from app.redis_client import close_redis
await dispose_engine() await dispose_engine()
await close_redis() 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 @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()
+384
View File
@@ -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_<name>` 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("<name>")` 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/<name>`. 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_<name>_`**.
- 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 = `<button class="wm-btn">Hi</button>`;
return () => { el.innerHTML = ""; };
}
```
`ctx` is `{ pluginName, api, events, t }`:
- `ctx.api.pluginFetch(path, init?)` → fetch under `/api/plugins/<name>` (auth+CSRF handled).
- `ctx.api.fetch(path, init?)` → fetch under `/api`.
- `ctx.events.subscribe(event, handler)` → WS events (`plugin.<name>.*` + 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/<route>` 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: <https://...>, <mailto:...>
@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 = `<button class="wm-btn-ghost">Unsubscribe</button>`;
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).
+9
View File
@@ -4,6 +4,7 @@ import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import { useMe } from "./lib/auth"; import { useMe } from "./lib/auth";
import { connectWs } from "./lib/ws"; import { connectWs } from "./lib/ws";
import { PluginPage } from "./plugins/PluginPage";
import { ComposeWindow } from "./routes/ComposeWindow"; import { ComposeWindow } from "./routes/ComposeWindow";
import { Login } from "./routes/Login"; import { Login } from "./routes/Login";
import { MailLayout } from "./routes/MailLayout"; import { MailLayout } from "./routes/MailLayout";
@@ -51,6 +52,14 @@ export function App() {
</RequireAuth> </RequireAuth>
} }
/> />
<Route
path="/p/*"
element={
<RequireAuth>
<PluginPage />
</RequireAuth>
}
/>
<Route <Route
path="/settings/accounts/new" path="/settings/accounts/new"
element={ element={
@@ -5,6 +5,7 @@ import { useT } from "../../i18n";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { printMessages } from "../../lib/print"; import { printMessages } from "../../lib/print";
import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types"; import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types";
import { PluginSlot } from "../../plugins/PluginSlot";
import { Composer, type ComposerInit } from "../compose/Composer"; import { Composer, type ComposerInit } from "../compose/Composer";
import { Icon } from "../Icon"; import { Icon } from "../Icon";
import { MessageBody } from "./MessageBody"; import { MessageBody } from "./MessageBody";
@@ -357,6 +358,8 @@ function MessageItem({
onDelete={onDelete} onDelete={onDelete}
replyDisabled={!canReply} replyDisabled={!canReply}
/> />
{/* Plugin-contributed per-message actions (SPEC §11.3 message.actions). */}
<PluginSlot name="message.actions" props={{ messageId: message.id }} />
</div> </div>
</div> </div>
{alsoIn.length > 0 && ( {alsoIn.length > 0 && (
@@ -386,6 +389,8 @@ function MessageItem({
)} )}
</div> </div>
</div> </div>
{/* Plugin-contributed per-message banner (SPEC §11.3 message.banner). */}
<PluginSlot name="message.banner" props={{ messageId: message.id }} />
<div className="px-4 pb-3"> <div className="px-4 pb-3">
<MessageBody message={message} onMailto={onMailto} /> <MessageBody message={message} onMailto={onMailto} />
</div> </div>
+3 -1
View File
@@ -25,6 +25,7 @@ import type {
UserOut, UserOut,
UserSettings, UserSettings,
} from "./types"; } from "./types";
import type { UiManifest } from "../plugins/types";
export class ApiError extends Error { export class ApiError extends Error {
code: string; code: string;
@@ -47,7 +48,7 @@ function csrfToken(): string {
const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]); const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> { export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
const method = (init.method ?? "GET").toUpperCase(); const method = (init.method ?? "GET").toUpperCase();
const headers = new Headers(init.headers); const headers = new Headers(init.headers);
if (MUTATING.has(method)) { if (MUTATING.has(method)) {
@@ -90,6 +91,7 @@ export const api = {
accounts: () => apiFetch<AccountOut[]>("/accounts"), accounts: () => apiFetch<AccountOut[]>("/accounts"),
mailboxes: () => apiFetch<MailboxOut[]>("/mailboxes"), mailboxes: () => apiFetch<MailboxOut[]>("/mailboxes"),
uiManifest: () => apiFetch<UiManifest>("/plugins/ui-manifest"),
syncNow: () => apiFetch<{ status: string }>("/accounts/sync", { method: "POST" }), syncNow: () => apiFetch<{ status: string }>("/accounts/sync", { method: "POST" }),
threads: ( threads: (
+27 -1
View File
@@ -22,6 +22,19 @@ const ALLOWED_EVENTS = new Set([
"outbox.state_changed", "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<string, unknown>) => void;
const wsListeners = new Set<WsListener>();
export function subscribeWs(listener: WsListener): () => void {
wsListeners.add(listener);
return () => {
wsListeners.delete(listener);
};
}
export function connectWs(queryClient: QueryClient): () => void { export function connectWs(queryClient: QueryClient): () => void {
const proto = window.location.protocol === "https:" ? "wss" : "ws"; const proto = window.location.protocol === "https:" ? "wss" : "ws";
const socket = new WebSocket(`${proto}://${window.location.host}/api/ws`); const socket = new WebSocket(`${proto}://${window.location.host}/api/ws`);
@@ -40,8 +53,21 @@ export function connectWs(queryClient: QueryClient): () => void {
return; return;
} }
const result = frameSchema.safeParse(parsed); 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 { 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). // Coarse invalidation: refetch affected lists (SPEC §9.2 contract).
if (name === "sync.progress" || name.startsWith("message.") || name === "thread.updated") { if (name === "sync.progress" || name.startsWith("message.") || name === "thread.updated") {
queryClient.invalidateQueries({ queryKey: ["threads"] }); queryClient.invalidateQueries({ queryKey: ["threads"] });
+1
View File
@@ -7,6 +7,7 @@ import { App } from "./App";
import { I18nProvider } from "./i18n"; import { I18nProvider } from "./i18n";
import { ThemeProvider } from "./lib/theme"; import { ThemeProvider } from "./lib/theme";
import "./index.css"; import "./index.css";
import "./plugins/plugin-ui.css"; // .wm-* classes for plugin bundles (SPEC §11.3)
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } }, defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
+75
View File
@@ -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<HTMLDivElement>(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 <div ref={ref} data-plugin-page={pluginName} className="min-h-0 flex-1 overflow-auto" />;
}
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 <div className="p-8 text-app-muted">{t("common.loading")}</div>;
}
if (!match) return <Navigate to="/mail" replace />;
return (
<div className="flex h-screen flex-col bg-app-bg">
<header className="flex items-center gap-3 border-b border-app-border px-4 py-2">
<Link
to="/mail"
className="rounded px-2 py-1 text-sm text-app-muted hover:bg-app-hover hover:text-app-fg"
>
← {t("nav.mail")}
</Link>
<span className="text-sm font-semibold text-app-fg">{match.nav.label}</span>
</header>
<PageMount pluginName={match.plugin} bundle={match.bundle} subpath={subpath} />
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
// Renders every plugin bundle registered for a named UI slot (SPEC §11.3).
//
// The host owns a container <div> 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<string, unknown>;
}) {
const ref = useRef<HTMLDivElement>(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 <div ref={ref} data-plugin-slot={slotName} data-plugin={pluginName} />;
}
export function PluginSlot({ name, props }: { name: string; props?: Record<string, unknown> }) {
const bindings = usePluginSlots(name);
if (bindings.length === 0) return null;
return (
<>
{bindings.map((b) => (
<SlotMount
key={`${b.plugin}:${b.bundle}`}
pluginName={b.plugin}
bundle={b.bundle}
slotName={name}
props={props}
/>
))}
</>
);
}
+35
View File
@@ -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<unknown>;
const _cache = new Map<string, Promise<PluginMount>>();
// @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<PluginMount> {
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();
}
+34
View File
@@ -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<T>(
providers: CapabilityProvider[],
query: Record<string, string>,
): Promise<T[]> {
const qs = new URLSearchParams(query).toString();
const results = await Promise.allSettled(
providers.map((p) => apiFetch<T[]>(`${p.endpoint}?${qs}`)),
);
return results.flatMap((r) => (r.status === "fulfilled" ? r.value : []));
}
+33
View File
@@ -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<PluginContext>(
() => ({
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],
);
}
+39
View File
@@ -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);
}
+128
View File
@@ -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 <option>
* popup and text stay readable in both themes. */
.wm-input,
.wm-select {
padding: 6px 8px;
border: 1px solid rgb(var(--app-border));
border-radius: 6px;
background: rgb(var(--app-surface));
color: rgb(var(--app-fg));
font: inherit;
}
.wm-input {
flex: 1;
min-width: 120px;
}
.wm-btn {
padding: 6px 12px;
border: 0;
border-radius: 6px;
cursor: pointer;
font: inherit;
background: rgb(var(--app-hover));
color: rgb(var(--app-fg));
}
.wm-btn-primary {
background: rgb(var(--app-accent));
color: rgb(var(--app-accent-fg));
}
.wm-btn-danger {
background: none;
border: 0;
padding: 0;
cursor: pointer;
color: rgb(220 38 38);
font: inherit;
}
.wm-btn-ghost {
background: none;
border: 1px dashed rgb(var(--app-border));
border-radius: 10px;
padding: 1px 8px;
cursor: pointer;
font-size: 12px;
color: rgb(var(--app-muted));
}
/* Removable pill (e.g. a label). Put an <button> inside for the × control. */
.wm-chip {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 6px;
border-radius: 10px;
background: rgb(var(--app-hover));
color: rgb(var(--app-fg));
font-size: 12px;
}
.wm-chip button {
border: 0;
background: none;
cursor: pointer;
color: inherit;
font-size: 12px;
line-height: 1;
}
.wm-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.wm-table td {
padding: 6px 4px;
border-bottom: 1px solid rgb(var(--app-border));
}
.wm-list {
list-style: none;
margin: 0;
padding: 0;
}
.wm-list li {
padding: 4px 12px;
font-size: 14px;
color: rgb(var(--app-muted));
}
+69
View File
@@ -0,0 +1,69 @@
// Frontend plugin surface types (SPEC §11.3), mirroring the backend ui-manifest.
export interface SlotDecl {
name: string; // one of the SPEC §11.3 slot names
bundle: string; // URL of the ESM bundle to mount (served by the plugin router)
}
export interface PageNavDecl {
icon: string;
label: string;
order: number;
}
export interface PageDecl {
route: string; // e.g. "/calendar"; mounted at "/p/calendar"
nav: PageNavDecl;
bundle: string;
}
export interface PluginManifestEntry {
name: string;
version: string;
builtin: boolean; // core plugins the UI must not offer to remove
slots: SlotDecl[];
pages: PageDecl[];
capabilities: Record<string, string>; // capability name -> endpoint path
}
export interface UiManifest {
plugins: PluginManifestEntry[];
}
// --- runtime bundle contract -------------------------------------------------
// Plugin bundles are built separately and loaded at runtime, so they use a
// framework-agnostic mount contract rather than sharing the host's React:
// the host renders a container element and calls the bundle's default export.
export interface PluginApi {
/** Fetch relative to /api (same auth/CSRF as the app client). */
fetch<T = unknown>(path: string, init?: RequestInit): Promise<T>;
/** Fetch relative to this plugin's router, /api/plugins/{name}. */
pluginFetch<T = unknown>(path: string, init?: RequestInit): Promise<T>;
}
export type PluginEventHandler = (payload: Record<string, unknown>) => void;
export interface PluginEvents {
/** Subscribe to a single WS event (plugin.{name}.* or a core event). Returns an unsubscribe. */
subscribe(event: string, handler: PluginEventHandler): () => void;
}
export interface PluginContext {
pluginName: string;
api: PluginApi;
events: PluginEvents;
t: (key: string, vars?: Record<string, string | number>) => string;
}
export type PluginUnmount = () => void;
/** A bundle's default export: render into `el`, optionally return a cleanup. */
export type PluginMount = (
el: HTMLElement,
ctx: PluginContext,
props?: Record<string, unknown>,
) => void | PluginUnmount;
export interface PluginBundle {
default: PluginMount;
}
+20
View File
@@ -16,6 +16,8 @@ import type { MessageKey } from "../i18n/en";
import { api } from "../lib/api"; import { api } from "../lib/api";
import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types"; import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types";
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts"; import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
import { usePluginPages } from "../plugins/manifest";
import { PluginSlot } from "../plugins/PluginSlot";
function quotaLabel(q: QuotaOut): string { function quotaLabel(q: QuotaOut): string {
const pct = q.quota_bytes > 0 ? (q.used_bytes / q.quota_bytes) * 100 : 0; const pct = q.quota_bytes > 0 ? (q.used_bytes / q.quota_bytes) * 100 : 0;
@@ -43,6 +45,7 @@ const FOLDERS: { view: string; icon: string; label: MessageKey }[] = [
export function MailLayout() { export function MailLayout() {
const t = useT(); const t = useT();
const pluginPages = usePluginPages();
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const match = useMatch("/mail/t/:threadId"); const match = useMatch("/mail/t/:threadId");
@@ -536,6 +539,23 @@ export function MailLayout() {
)} )}
</> </>
)} )}
{/* Plugin pages (app.page) get a nav entry; clicking opens /p{route}. */}
{pluginPages.map((p) => (
<SidebarItem
key={`${p.plugin}${p.route}`}
active={false}
collapsed={collapsed}
title={collapsed ? p.nav.label : undefined}
onClick={() => navigate(`/p${p.route}`)}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" className="shrink-0" aria-hidden>
<path d="M10 3.5a1.5 1.5 0 0 1 3 0V5h3.5A1.5 1.5 0 0 1 18 6.5V10h1.5a1.5 1.5 0 0 1 0 3H18v3.5a1.5 1.5 0 0 1-1.5 1.5H13v-1.5a1.5 1.5 0 0 0-3 0V18H6.5A1.5 1.5 0 0 1 5 16.5V13H6.5a1.5 1.5 0 0 0 0-3H5V6.5A1.5 1.5 0 0 1 6.5 5H10V3.5z" />
</svg>
{!collapsed && <span className="flex-1 truncate">{p.nav.label}</span>}
</SidebarItem>
))}
{/* Plugin-contributed sidebar sections (SPEC §11.3 sidebar.section). */}
{!collapsed && <PluginSlot name="sidebar.section" />}
</nav> </nav>
<div className="border-t border-app-border p-2 text-[15px]"> <div className="border-t border-app-border p-2 text-[15px]">
<Link <Link
@@ -8,6 +8,7 @@ import { useT } from "../../i18n";
import { api, ApiError } from "../../lib/api"; import { api, ApiError } from "../../lib/api";
import { useMe } from "../../lib/auth"; import { useMe } from "../../lib/auth";
import { useTheme } from "../../lib/theme"; import { useTheme } from "../../lib/theme";
import { PluginSlot } from "../../plugins/PluginSlot";
const SECTIONS = ["profile", "security", "accounts", "appearance"] as const; const SECTIONS = ["profile", "security", "accounts", "appearance"] as const;
type Section = (typeof SECTIONS)[number]; type Section = (typeof SECTIONS)[number];
@@ -44,6 +45,13 @@ export function Settings() {
{section === "security" && <SecuritySection />} {section === "security" && <SecuritySection />}
{section === "accounts" && <AccountsSection />} {section === "accounts" && <AccountsSection />}
{section === "appearance" && <AppearanceSection />} {section === "appearance" && <AppearanceSection />}
{/* Plugin-contributed settings UI (SPEC §11.3 settings.page), under Accounts.
Matches the accounts section width (max-w-5xl) with a gap above it. */}
{section === "accounts" && (
<div className="mt-8 max-w-5xl">
<PluginSlot name="settings.page" />
</div>
)}
</main> </main>
</div> </div>
</div> </div>
@@ -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);
});
});
@@ -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" }]);
});
});
+69
View File
@@ -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 (
<I18nProvider>
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
</I18nProvider>
);
}
describe("PluginSlot", () => {
it("mounts registered bundles and tears them down on unmount", async () => {
const { unmount } = render(wrap(<PluginSlot name="message.actions" props={{ messageId: "m1" }} />));
// 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(<PluginSlot name="thread.toolbar" />));
expect(container.querySelector("[data-plugin-slot]")).toBeNull();
});
});
@@ -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"]
@@ -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")],
),
)
@@ -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)
@@ -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")
@@ -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 = `
<section class="wm-root">
<h2 class="wm-h2">Filters</h2>
<p class="wm-muted" style="margin:0 0 12px">When new mail matches, apply an action. Rules run on arrival.</p>
<table class="wm-table"><tbody data-rows></tbody></table>
<form data-add class="wm-row wm-card" style="margin-top:12px">
<select data-field class="wm-select">
<option value="subject">Subject</option>
<option value="from">From</option>
<option value="to">To</option>
</select>
<span>contains</span>
<input data-contains class="wm-input" placeholder="text" aria-label="contains"/>
<span>→</span>
<select data-action class="wm-select">
<option value="read">Mark read</option>
<option value="pin">Pin</option>
<option value="trash">Delete</option>
</select>
<button class="wm-btn wm-btn-primary">Add rule</button>
</form>
</section>`;
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) =>
`<tr>
<td>${FIELD_LABEL[r.field] || r.field} contains <b>${esc(r.contains)}</b></td>
<td class="wm-muted">${ACTION_LABEL[r.action] || r.action}</td>
<td style="text-align:right"><button class="wm-btn-danger" data-rm="${i}">Remove</button></td>
</tr>`,
)
.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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
}
}
@@ -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"]
@@ -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 * * * *")],
)
@@ -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")
@@ -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")
@@ -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")
@@ -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 = `
<div class="wm-root" style="max-width:640px;margin:24px auto;padding:0 16px">
<h1 class="wm-h2" style="font-size:20px">Hello plugin</h1>
<p class="wm-muted" style="margin:0 0 16px">
A skeleton page-plugin: its own RLS-isolated table, a cron job, and this page —
all through the SDK, zero core diffs.
</p>
<form data-role="add" class="wm-row" style="margin:0 0 16px">
<input data-role="input" class="wm-input" placeholder="Add a note" aria-label="note"/>
<button class="wm-btn wm-btn-primary">Add</button>
</form>
<ul data-role="list" class="wm-list"></ul>
</div>`;
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) => `<li>${escapeHtml(n.body)}</li>`).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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c],
);
}
}
@@ -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"]
@@ -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"),
],
),
)
@@ -0,0 +1,55 @@
"""HTTP surface for the labels plugin, mounted at /api/plugins/labels (SPEC §11).
State is the namespaced KV store: key ``<message_id>`` -> 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")
@@ -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) =>
`<span class="wm-chip">${esc(name)}<button data-rm="${i}" title="remove">×</button></span>`,
)
.join("");
el.innerHTML = `${chips}<button class="wm-btn-ghost" data-add title="add label">+label</button>`;
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
}
}
@@ -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 = `
<div style="padding:8px 12px 0">
<div class="wm-faint" style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.03em;padding-bottom:4px">Labels</div>
<ul data-list class="wm-list"></ul>
</div>`;
const list = el.querySelector("[data-list]");
async function refresh() {
const labels = await ctx.api.pluginFetch("/labels");
list.innerHTML = labels.length
? labels.map((name) => `<li>🏷 ${esc(name)}</li>`).join("")
: `<li class="wm-faint">No labels yet</li>`;
}
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
}
}