Files
webmail-client/docs/plugins.md
T
lukasandClaude Opus 5 5aa133e18c Antispam: plugin docs, the learning acceptance test, and a bootstrap that refuses bad corpora
docs/plugins.md described an SDK that no longer exists. It now covers
messages.raw/sample, actions.move_to_special, the envelope's account_id /
mailbox_special_use / account_state, the message.changed transition payload, the
message.actions.menu slot, Slot.title and .wm-menu-item — with the reasons, not
just the signatures. M3's acceptance is that a plugin can be written from this
file alone, and it had drifted.

Acceptance 4 (§C.8): a test that teaches 20 spam + 20 ham to a real engine and
checks the classifier generalises to held-out mail, keeps ham under the banner
threshold, and does not leak between users. It skips when no engine is reachable.
It does NOT assert the literal move_threshold — a synthetic fixture cannot reach
12.0 once the SMTP-time symbols are zeroed, which is the whole point of the
tuning. Recorded as OQ-8 rather than quietly asserting something weaker.

Bayes ignores training until min_learns, stock 200. For a personal mailbox that
means every correction appears to be ignored for months, so it is now 20.

The bootstrap would have made this filter worse. Accounts do not sync their
provider's spam folder by default, so the real corpus here is 3 junk beside 1,294
aged inbox — teaching both as found trains "everything is ham". It now refuses
below 10 spam samples (leaving the account unmarked so a later run can do it
properly) and otherwise caps ham at 3x the spam count.

Run against the live account, it correctly declined:
  "only 3 spam samples (need 10); enable spam-folder sync to build a corpus"

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-17 20:34:50 +02:00

15 KiB

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:

[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:

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.

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, header, raw, sample
s.actions act on a message: set_flags, trash, move_to_special

Plus, for routers: CurrentUserId (auth dependency → the user's uuid) and require_csrf (guard mutating routes).

Reading a message

s.messages.envelope(user_id, message_id) → None, or:

{message_id, subject, from_email, from_name, to: [{email, name}], flags,
 account_id,             # which account it arrived on
 mailbox_special_use,    # "inbox" | "junk" | "sent" | … | None
 account_state}          # "backfilling" | "live" | …

The last three matter more than they look. message.new fires for imported history as well as new arrivals, and for every folder — so a plugin that should only react to genuinely new inbox mail has to check account_state == "live" and mailbox_special_use == "inbox" itself. Acting on a backfill means acting on years of someone's archive, inline, in the sync loop.

s.messages.header(user_id, id, "List-Unsubscribe") returns one raw header.

s.messages.raw(user_id, id) returns the whole stored RFC822 as bytes, or None when the message isn't the user's or its blob is unreadable. For engines that need headers and body — spam scoring, signature checks.

s.messages.sample(user_id, account_id=…, special_use="junk", limit=500, older_than_days=30) returns message ids from one folder, newest first. For building a corpus out of mail the user already has. Ids only, so the caller pulls bodies one at a time rather than materialising 500 messages at once.

Acting on a message

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 whole thread to Trash (same as the UI Delete).

s.actions.move_to_special(user_id, id, "junk") moves one message into the account's folder with that special-use, on the same path as the UI's move: optimistic DB update, then the IMAP move, then message.changed. Per-message on purpose — filing one spam reply must not drag a legitimate conversation with it. Returns False (never raises) when the message isn't the user's, the account has no such folder, or it is already there; treat that as "could not act", because mail must still arrive for an account whose server has no Junk folder.


3. KV store

Light per-user state, no schema needed:

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.

message.changed describes a move, not just a destination:

{user_id, message_id, thread_id, mailbox_id,
 previous_mailbox_id, special_use, previous_special_use}

Read the pair, not special_use alone — "now in Junk" and "moved out of Junk" are different facts, and only the transition distinguishes them. They fire wherever the event is published — message.new originates in the worker.

# 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.

# 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

# jobs.py
async def sync_now(ctx, *, user_id): ...      # ctx is the arq context
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.
# 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:

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):

// 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:

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.actions.menu, message.banner, sidebar.section, settings.page. message.* slots receive props = { messageId }.

  • message.actions sits on the message header — a persistent control.

  • message.actions.menu sits inside the Actions dropdown, beside Reply and Delete — for a verb the user reaches for occasionally. The host closes the menu after any click inside, so the bundle only has to act.

  • settings.page becomes its own section in Settings. Give the slot a title and that is its nav label; without one it falls back to the plugin name:

    Slot(name="settings.page", bundle=f"{_UI}/settings.js", title="Antispam filtering")
    

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).

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, wm-menu-item.

wm-menu-item is for the message.actions.menu slot: it applies the exact utilities the core Actions dropdown uses, so a plugin verb is indistinguishable from Reply or Delete rather than approximately similar.


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:

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:

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:

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).