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 spamscore ${Number(v.score).toFixed(1)}${why ? ` · ${esc(why)}` : ""}
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.