From 6414504f6e5c55e5e4bf23b01521809d334a58f1 Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Thu, 17 Sep 2026 08:19:11 +0200 Subject: [PATCH] Settings: a section per plugin, and call the folder Spam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin settings pages were stacked at the bottom of Settings -> Accounts, which put antispam's thresholds and lists under a heading about mail accounts. Each `settings.page` now gets its own entry in the Settings nav: "Antispam filtering" and "Mail filters". Slot gains an optional `title` for this — the host needs a label when it gives a slot an area of its own, and falls back to the plugin name. PluginSlot gains an optional `plugin` filter so a host can render one plugin's bundle rather than every binding for a slot name. Wording: the app calls that folder Spam (i18n `nav.spam`), so the plugin's copy now does too — "filed into Spam", "Move to Spam", "marked as spam". The IMAP special-use stays `junk`, which is what the protocol calls it; only what the user reads changed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo --- backend/app/plugins/loader.py | 9 +++--- backend/app/plugins/types.py | 4 +++ frontend/src/plugins/PluginSlot.tsx | 15 ++++++++-- frontend/src/plugins/manifest.ts | 5 ++++ frontend/src/plugins/types.ts | 1 + frontend/src/routes/settings/Settings.tsx | 30 +++++++++++++++---- .../webmail_plugin_antispam/__init__.py | 6 +++- .../webmail_plugin_antispam/ui/actions.js | 2 +- .../webmail_plugin_antispam/ui/banner.js | 2 +- .../webmail_plugin_antispam/ui/settings.js | 10 +++---- .../webmail_plugin_filters/__init__.py | 8 ++++- 11 files changed, 71 insertions(+), 21 deletions(-) diff --git a/backend/app/plugins/loader.py b/backend/app/plugins/loader.py index 526c026..221cdde 100644 --- a/backend/app/plugins/loader.py +++ b/backend/app/plugins/loader.py @@ -96,7 +96,10 @@ def mount_routers(app: FastAPI) -> None: def _slot_dict(slot: Slot) -> dict[str, str]: - return {"name": slot.name, "bundle": slot.bundle} + d = {"name": slot.name, "bundle": slot.bundle} + if slot.title: + d["title"] = slot.title + return d def _page_dict(page: Page) -> dict[str, Any]: @@ -120,9 +123,7 @@ def _manifest_dict(plugin: Plugin, manifest: UiManifest) -> dict[str, Any]: def build_ui_manifest() -> dict[str, Any]: """Aggregate every plugin's UI manifest for the frontend loader (SPEC §11.3).""" - plugins = [ - _manifest_dict(p, p.ui_manifest) for p in get_plugins() if p.ui_manifest is not None - ] + plugins = [_manifest_dict(p, p.ui_manifest) for p in get_plugins() if p.ui_manifest is not None] return {"plugins": plugins} diff --git a/backend/app/plugins/types.py b/backend/app/plugins/types.py index b27ae83..9ec83ff 100644 --- a/backend/app/plugins/types.py +++ b/backend/app/plugins/types.py @@ -38,6 +38,10 @@ class Slot: name: str bundle: str # path served by the plugin router, resolved by the frontend loader + # Human label for slots the host renders as a named area of its own — today + # `settings.page`, which becomes a section in Settings rather than being + # stacked under someone else's. Falls back to the plugin's name. + title: str | None = None def __post_init__(self) -> None: if self.name not in SLOT_NAMES: diff --git a/frontend/src/plugins/PluginSlot.tsx b/frontend/src/plugins/PluginSlot.tsx index 9e52884..56392e0 100644 --- a/frontend/src/plugins/PluginSlot.tsx +++ b/frontend/src/plugins/PluginSlot.tsx @@ -44,8 +44,19 @@ function SlotMount({ return
; } -export function PluginSlot({ name, props }: { name: string; props?: Record }) { - const bindings = usePluginSlots(name); +export function PluginSlot({ + name, + props, + plugin, +}: { + name: string; + props?: Record; + /** Render only this plugin's bundle. Used where the host gives each plugin its + * own area (Settings sections) instead of stacking them together. */ + plugin?: string; +}) { + const all = usePluginSlots(name); + const bindings = plugin ? all.filter((b) => b.plugin === plugin) : all; if (bindings.length === 0) return null; return ( <> diff --git a/frontend/src/plugins/manifest.ts b/frontend/src/plugins/manifest.ts index ad6a0a6..50866f8 100644 --- a/frontend/src/plugins/manifest.ts +++ b/frontend/src/plugins/manifest.ts @@ -37,3 +37,8 @@ export function usePluginPages(): PageBinding[] { .flatMap((p) => p.pages.map((pg) => ({ ...pg, plugin: p.name }))) .sort((a, b) => a.nav.order - b.nav.order); } + +/** Plugins contributing a `settings.page`, each shown as its own Settings section. */ +export function usePluginSettingsSections(): SlotBinding[] { + return usePluginSlots("settings.page"); +} diff --git a/frontend/src/plugins/types.ts b/frontend/src/plugins/types.ts index 090c6dc..a412a78 100644 --- a/frontend/src/plugins/types.ts +++ b/frontend/src/plugins/types.ts @@ -3,6 +3,7 @@ export interface SlotDecl { name: string; // one of the SPEC §11.3 slot names bundle: string; // URL of the ESM bundle to mount (served by the plugin router) + title?: string; // label when the host gives the slot an area of its own (settings.page) } export interface PageNavDecl { diff --git a/frontend/src/routes/settings/Settings.tsx b/frontend/src/routes/settings/Settings.tsx index eb39b3c..6957bb3 100644 --- a/frontend/src/routes/settings/Settings.tsx +++ b/frontend/src/routes/settings/Settings.tsx @@ -11,6 +11,7 @@ import { useMe } from "../../lib/auth"; import { useTheme } from "../../lib/theme"; import { setTimeFormat } from "../../lib/timeFormat"; import { PluginSlot } from "../../plugins/PluginSlot"; +import { usePluginSettingsSections } from "../../plugins/manifest"; import { AccountWizard } from "../wizard/AccountWizard"; const SECTIONS = ["profile", "security", "accounts", "calendars", "appearance", "integrations"] as const; @@ -29,10 +30,14 @@ export function Settings() { // The add-account wizard renders inside this layout (route /settings/accounts/new), // with the Accounts nav item highlighted. const addingAccount = useLocation().pathname.endsWith("/accounts/new"); + // Plugins contributing `settings.page` each get their own nav entry, keyed by + // plugin name, rather than being stacked at the bottom of Accounts. + const pluginSections = usePluginSettingsSections(); + const isPluginSection = pluginSections.some((b) => b.plugin === params.section); const section = ( addingAccount ? "accounts" - : SECTIONS.includes(params.section as Section) + : SECTIONS.includes(params.section as Section) || isPluginSection ? params.section : "profile" ) as Section; @@ -58,6 +63,19 @@ export function Settings() { {t(`settings.${s}` as const)} ))} + {pluginSections.map((b) => ( + + {b.title ?? b.plugin} + + ))}
{addingAccount && navigate("/settings/accounts")} />} @@ -67,11 +85,11 @@ export function Settings() { {section === "calendars" && } {section === "appearance" && } {section === "integrations" && } - {/* Plugin-contributed settings UI (SPEC §11.3 settings.page), under Accounts. - Matches the accounts section width (max-w-5xl) with a gap above it. */} - {!addingAccount && section === "accounts" && ( -
- + {/* Plugin-contributed settings UI (SPEC §11.3 settings.page): one section + per plugin, selected from the nav like any built-in section. */} + {isPluginSection && ( +
+
)}
diff --git a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/__init__.py b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/__init__.py index 03b8aac..16aa3ef 100644 --- a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/__init__.py +++ b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/__init__.py @@ -33,7 +33,11 @@ plugin = Plugin( migrations=str(_HERE / "migrations" / "versions"), ui_manifest=UiManifest( slots=[ - Slot(name="settings.page", bundle=f"{_UI}/settings.js"), + Slot( + name="settings.page", + bundle=f"{_UI}/settings.js", + title="Antispam filtering", + ), # Mounts on every message but renders only for a `banner` verdict — # a spam warning on ordinary mail is worse than no warning at all. Slot(name="message.banner", bundle=f"{_UI}/banner.js"), diff --git a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/actions.js b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/actions.js index c720421..9c0d2b9 100644 --- a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/actions.js +++ b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/actions.js @@ -33,7 +33,7 @@ export default function mount(el, ctx, props) { function render() { const d = busy ? " disabled" : ""; el.innerHTML = - `` + + `` + ``; el.querySelectorAll("[data-v]").forEach((b) => b.addEventListener("click", (e) => { diff --git a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/banner.js b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/banner.js index a00b3dc..5f65696 100644 --- a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/banner.js +++ b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/banner.js @@ -36,7 +36,7 @@ export default function mount(el, ctx, props) { Looks like spam score ${Number(v.score).toFixed(1)}${why ? ` · ${esc(why)}` : ""}
- +
diff --git a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/settings.js b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/settings.js index fb3e4e4..5b05374 100644 --- a/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/settings.js +++ b/plugins/webmail_plugin_antispam/webmail_plugin_antispam/ui/settings.js @@ -78,11 +78,11 @@ export default function mount(el, ctx) { } el.className = "wm-root"; el.innerHTML = ` -

Spam filtering

-

A second pass over mail your provider already delivered. Nothing is deleted — suspect mail is filed into Junk, and the uncertain middle only gets a warning.

+

Antispam filtering

+

A second pass over mail your provider already delivered. Nothing is deleted — suspect mail is filed into Spam, and the uncertain middle only gets a warning.

-