2026-07-27 08:30:24 +02:00
|
|
|
# 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) |
|
2026-09-17 20:34:50 +02:00
|
|
|
| `s.messages` | read a message: `envelope`, `header`, `raw`, `sample` |
|
|
|
|
|
| `s.actions` | act on a message: `set_flags`, `trash`, `move_to_special` |
|
2026-07-27 08:30:24 +02:00
|
|
|
|
|
|
|
|
Plus, for routers: `CurrentUserId` (auth dependency → the user's uuid) and
|
|
|
|
|
`require_csrf` (guard mutating routes).
|
|
|
|
|
|
2026-09-17 20:34:50 +02:00
|
|
|
### Reading a message
|
|
|
|
|
|
|
|
|
|
`s.messages.envelope(user_id, message_id)` → `None`, or:
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
{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.
|
2026-07-27 08:30:24 +02:00
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 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`.
|
2026-09-17 20:34:50 +02:00
|
|
|
|
|
|
|
|
`message.changed` describes a **move**, not just a destination:
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
{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.
|
2026-07-27 08:30:24 +02:00
|
|
|
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`,
|
2026-09-17 20:34:50 +02:00
|
|
|
`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:
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
Slot(name="settings.page", bundle=f"{_UI}/settings.js", title="Antispam filtering")
|
|
|
|
|
```
|
2026-07-27 08:30:24 +02:00
|
|
|
|
|
|
|
|
**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`,
|
2026-09-17 20:34:50 +02:00
|
|
|
`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.
|
2026-07-27 08:30:24 +02:00
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 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).
|