Files
lukasandClaude Opus 5 8eb699a05f Antispam: bootstrap every account, not just the ones we happen to see go live
Warming Bayes hung on a single event — `account.state_changed` reaching "live".
That covers an account added from now on and nothing else. An account that went
live before this plugin existed never gets one; neither does one whose Junk
folder was empty at the time and filled up later, or one whose owner enabled
spam-folder sync afterwards. A worker restarting at the wrong moment loses it
too. The result was a feature that quietly required someone to run it by hand
per account, which is not a feature.

An hourly sweep now walks every live account and offers each a bootstrap. That
works because the per-account job was already cheap to repeat: it returns at once
for an account already done, and declines — without marking it done — when the
corpus is still too small to learn from. So the sweep simply keeps asking until
there is something worth learning, then does it once.

`s.accounts.live()` is the new SDK surface it needs: cross-user by design,
because a sweep runs for the instance rather than a request. Ids and state only,
so reading anything still goes through the user-scoped accessors.

One failing account cannot stop the sweep — the mailbox that raises is logged and
the rest still get their turn.

docs/plugins.md now says this in general terms next to `JobSpec`: a state-change
hook is one-shot, so pair it with a sweep when the work must eventually happen
for everything.

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

16 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
s.accounts live() → (user_id, account_id) for every account past its initial import — cross-user, for scheduled jobs

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

A hook on a state change is a one-shot event: it cannot help anything that already happened before your plugin was installed, and a worker restart can miss it. If a job must eventually run for every account, pair the hook with a scheduled sweep over s.accounts.live() and make the per-account work cheap to repeat — the antispam bootstrap does exactly this.

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