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,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