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:
@@ -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
|
||||||
@@ -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)."""
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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).
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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"] });
|
||||||
|
|||||||
@@ -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 } },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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 : []));
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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" }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">" })[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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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) => ({ "&": "&", "<": "<", ">": ">" })[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) => ({ "&": "&", "<": "<", ">": ">" })[c]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user