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